跟着大彬读源码 - Redis 9 - 对象编码之 三种list

Redis 底层使用了 ziplist、skiplist 和 quicklist 三种 list 结构来实现相关对象。顾名思义,ziplist 更节省空间、skiplist 则注重查找效率,quicklist 则对空间和时间进行折中。node

在典型的双向链表中,咱们有称为节点的结构,它表示列表中的每一个值。每一个节点都有三个属性:指向列表中的前一个和下一个节点的指针,以及指向节点中字符串的指针。而每一个值字符串值实际上存储为三个部分:一个表示长度的整数、一个表示剩余空闲字节数的整数以及字符串自己后跟一个空字符。redis

能够看到,链表中的每一项都占用独立的一块内存,各项之间用地址指针(或引用)链接起来。这种方式会带来大量的内存碎片,并且地址指针也会占用额外的内存。这就是普通链表的内存浪费问题。算法

此外,在普通链表中执行随机查找操做时,它的时间复杂度为 O(n),这对于注重效率的 Redis 而言也是不可接受的。这是普通链表的查找效率过低问题。数组

针对上述两个问题,Redis 设计了ziplist(压缩列表)skiplist(跳跃表)快速链表进行相关优化。数据结构

1 ziplist

对于 ziplist,它要解决的就是内存浪费的问题。也就是说,它的设计目标就是是为了节省空间,提升存储效率性能

基于此,Redis 对其进行了特殊设计,使其成为一个通过特殊编码的双向链表。将表中每一项存放在先后连续的地址空间内,一个 ziplist 总体占用一大块内存。它是一个表(list),但其实不是一个链表(linked list)。优化

除此以前,ziplist 为了在细节上节省内存,对于值的存储采用了变长的编码方式,大概意思是说,对于大的整数,就多用一些字节来存储,而对于小的整数,就少用一些字节来存储。ui

也正是为了这种高效率的存储,ziplist 有不少 bit 级别的操做,使得代码变得较为晦涩难懂。不过没关系,咱们本节的目标之一是为了了解 ziplist 对比普通链表,作了哪些优化,能够更好的节省空间this

接下来咱们来正式认识下压缩列表的结构。编码

1.1 压缩列表的结构

一个压缩列表能够包含任意多个节点(entry),每一个节点能够保存一个字节数组或者一个整数值。

图 1-1 展现了压缩列表的各个组成部分:

图 1-1:压缩列表的结构

相关字段说明以下:

  • zlbytes:4 字节,表示 ziplist 占用的字节总数(包括 zlbytes 自己占用的 4 个字节)。
  • zltail:4 字节,表示 ziplist 表中最后一项距离列表的起始地址有多少字节,也就是表尾节点的偏移量。经过此字段,程序能够快速肯定表尾节点的地址。
  • zllen:2 字节:表示 ziplist 的节点个数。要注意的是,因为此字段只有 16bit,因此可表达的最大值为 2^16-1。一旦列表节点个数超过这个值,就要遍历整个压缩列表才能获取真实的节点数量。
  • entry:表示 ziplist 的节点。长度不定,由保存的内容决定。要注意的是,列表的节点(entry)也有本身的数据结构,后续会详细说明。
  • zlend:ziplist 的结束标记,值固定为 255,用于标记压缩列表的末端。

图 1-2 展现了一个包含五个节点的压缩列表:

图 1-2:包含五个节点的压缩列表

  • 列表 zlbytes 属性的值为 0xd2(十进制 210),表示压缩列表的总长为 210 字节。
  • 列表 zltail 属性的值为 0xb3,(十进制 179),表示尾结点相比列表的起始地址有 179 字节的距离。假如列表的起始地址为 p,那么指针 p + 179 = entry5 的地址。
  • 列表 zllen 属性的值为 0x5(十进制 5),表示压缩列表包含 5 个节点。

1.2 列表节点的结构

节点的结构源码以下(ziplist.c):

typedef struct zlentry {
    unsigned int prevrawlensize, prevrawlen;
    unsigned int lensize, len;
    unsigned int headersize;
    unsigned char encoding;
    unsigned char *p;
} zlentry;

如图 1-3,展现了压缩列表节点的结构。

图 1-3:压缩列表节点结构

  • prevrawlen:表示前一个节点占用的总字节数。此字段是为了让 ziplist 可以从后向前遍历。
  • encoding:此字段记录了节点的 content 属性中所保存的数据类型。
  • lensize:此字段记录了节点所保存的数据长度。
  • headersize:此字段记录了节点 header 的大小。
  • *p:此字段记录了指向节点保存内容的指针。

1.3 压缩列表如何节省了内存

回到咱们最开始对普通链表的认识,普通链表中,每一个节点包:

  • 一个表示长度的整数
  • 一个表示剩余空闲字节数的整数
  • 字符串自己
  • 结尾空字符。

以图 1-4 为例:

图 1-4:普通链

图 1-4 展现了一个普通链表的三个节点,这三个节点中,每一个节点实际存储内容只有 1 字节,可是它们除了实际存储内容外,还都要有:

  • 3 个指针 - 占用 3 byte
  • 2 个整数 - 占用 2 byte
  • 内容字符串 - 占用 1 byte
  • 结尾空字符的空间 - 占用 1 byte

这样来看,存储 3 个字节的数据,至少须要 21 字节的开销。能够看到,这样的存储效率是很低的。

另外一方面,普通链表经过先后指针来关联节点,地址不连续,多节点时容易产生内存碎片,下降了内存的使用率。

最后,普通链表对存储单位的操做粒度是 byte,这种方式在存储较小整数或字符串时,每一个字节实际上会有很大的空间是浪费的。就像上面三个节点中,用来存储剩余空闲字节数的整数,实际存储空间只须要 1 bit,可是有了 1 byte 来表示剩余空间大小,这一个 byte 中,剩余 7 个 bit 就被浪费了。

那么,Redis 是如何使用 ziplist 来改造普通链表的呢?经过如下两方面:

一方面,ziplist 使用一整块连续内存,避免产生内存碎片,提升了内存的使用率

另外一方面,ziplist 将存储单位的操做粒度从 byte 下降到 bit,有效的解决了存储较小数据时,单个字节中浪费 bit 的问题。

2 skiplist

skiplist 是一种有序数据结构,它经过在每一个节点中维持多个指向其余节点的指针,来达到快速访问节点的目的。

skiplist 本质上是一种查找结构,用于解决算法中的查找问题。即根据指定的值,快速找到其所在的位置。

此外,咱们知道,"查找" 问题的解决方法通常分为两大类:平衡树哈希表。有趣的是,skiplist 这种查找结构,由于其特殊性,并不在上述两大类中。但在大部分状况下,它的效率能够喝平衡树想媲美,并且跳跃表的实现要更为简单,因此有很多程序都使用跳跃表来代替平衡树。

本节没有介绍跳跃表的定义及其原理,有兴趣的童鞋能够参考这里

认识了跳跃表是什么,以及作什么的,接下来,咱们再来看下在 redis 中,是怎么实现跳跃表的。

server.h 中能够找到跳跃表的源码,以下:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

typedef struct zskiplistNode {
    robj *obj;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

Redis 中的 skiplist 和普通的 skiplist 相比,在结构上并无太大不一样,只是在一些细节上有如下差别:

  • 分数(score)能够重复。就是说 Redis 中的 skiplist 在分值字段上是容许重复的,而普通的 skiplist 则不容许重复。
  • 第一层链表不是单向链表,而是双向链表。这种方式能够用倒序方式获取一个范围内的元素。
  • 比较时,除了比较 score 以外,还比较数据自己。在 Redis 的 skiplist 中,数据自己的内容是这份数据的惟一标识,而不是由 score 字段作惟一标识。此外,当多个元素分数相同时,还须要根据数据内容进行字典排序。

3 quicklist

对于 quicklist,在 quicklist.c 中有如下说明:

A doubly linked list of ziplists

它是一个双向链表,而且是一个由 ziplist 组成的双向链表。

相关源码结构可在 quicklist.h 中查找,以下:

/* quicklistNode is a 32 byte struct describing a ziplist for a quicklist.
 * We use bit fields keep the quicklistNode at 32 bytes.
 * count: 16 bits, max 65536 (max zl bytes is 65k, so max count actually < 32k).
 * encoding: 2 bits, RAW=1, LZF=2.
 * container: 2 bits, NONE=1, ZIPLIST=2.
 * recompress: 1 bit, bool, true if node is temporarry decompressed for usage.
 * attempted_compress: 1 bit, boolean, used for verifying during testing.
 * extra: 12 bits, free for future use; pads out the remainder of 32 bits */
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

/* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'.
 * 'sz' is byte length of 'compressed' field.
 * 'compressed' is LZF data with total (compressed) length 'sz'
 * NOTE: uncompressed length is stored in quicklistNode->sz.
 * When quicklistNode->zl is compressed, node->zl points to a quicklistLZF */
typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

/* quicklist is a 32 byte struct (on 64-bit systems) describing a quicklist.
 * 'count' is the number of total entries.
 * 'len' is the number of quicklist nodes.
 * 'compress' is: -1 if compression disabled, otherwise it's the number
 *                of quicklistNodes to leave uncompressed at ends of quicklist.
 * 'fill' is the user-requested (or default) fill factor. */
typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned int len;           /* number of quicklistNodes */
    int fill : 16;              /* fill factor for individual nodes */
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;

上面介绍链表的时候有说过,链表由多个节点组成。而对于 quicklist 而言,它的每个节点都是一个 ziplist。quicklist 这样设计,其实就是咱们篇头所说的,是一个空间和时间的折中。

ziplist 相比普通链表,主要优化了两个点:下降内存开销减小内存碎片。正所谓,事物老是有两面性。ziplist 经过连续内存解决了普通链表的内存碎片问题,但与此同时,也带来了新的问题:不利于修改操做

因为 ziplist 是一整块连续内存,因此每次数据变更都会引起一次内存的重分配。当在 ziplist 很大的时候,每次重分配都会出现大批量的数据拷贝操做,下降性能。

因而,结合了双向链表和 ziplist 的优势,就有了 quicklist。

quicklist 的基本思想就是,给每个节点的 ziplist 分配合适的大小,避免出现因数据拷贝,下降性能的问题。这又是一个须要找平衡点的难题。咱们先从存储效率上分析:

  • 每一个 quicklist 节点上的 ziplist 越短,则内存碎片越多。而内存碎片多了,就颇有可能产生不少没法被利用的内存小碎片,下降存储效率。
  • 每一个 quicklist 节点上的 ziplist 越长,则为 ziplist 分配大块连续内存的难度就越大。有可能出现,内存里有不少较小块的内存,却找不到一块足够大的空闲空间分配给 ziplist 的状况。这一样会下降存储效率。

可见,一个 quicklist 节点上的 ziplist 须要保持一个合理的长度。这里的合理取决于实际应用场景。基于此,Redis 提供了一个配置参数,让使用者能够根据状况,本身调整:

list-max-ziplist-size -2

这个参数能够取正值,也能够取负值。

当取正值的时候,表示按照数据项个数来限定每一个 quicklist 节点上 ziplist 的长度。好比配置为 2 时,就表示 quicklist 的每一个节点上的 ziplist 最多包含 2 个数据项。

当取负值的时候,表示按照占用字节数来限定每一个 quicklist 节点上 ziplist 的长度。此时,它的取值范围是 [-1, -5],每一个值对应不一样含义:

  • -1:每一个 quicklist 节点上的 ziplist 大小不能超过 4Kb;
  • -2:每一个 quicklist 节点上的 ziplist 大小不能超过 8Kb(默认值);
  • -3:每一个 quicklist 节点上的 ziplist 大小不能超过 16Kb;
  • -4:每一个 quicklist 节点上的 ziplist 大小不能超过 32Kb;
  • -5:每一个 quicklist 节点上的 ziplist 大小不能超过 64Kb;

总结

  1. 普通链表存在两个问题:内存利用率低容易产生内存碎片
  2. ziplist 使用连续内存,减小内存碎片,提供内存利用率。
  3. skiplist 能够用相对简单的实现,达到和平衡树相同的查找效率。
  4. quicklist 汲取了普通链表和压缩链表的优势,保证性能的前提下,尽量的提升内存利用率。
相关文章
相关标签/搜索