快速列表(quicklist)是在 Redis 3.2 版本中引入的,之前版本用的是 listNode 组成的无环双链表实现的。
// adlist.h
typedef struct listNode {
struct listNode *prev; /* 前置节点 */
struct listNode *next; /* 后置节点 */
void *value; /* 节点的值 */
} listNode;
typedef struct list {
listNode *head; /* 表头节点 */
listNode *tail; /* 表尾节点 */
unsigned long len; /* 链表所包含的节点数量 */
void (*dup)(void *ptr); /* 节点值复制函数 */
void (*free)(void *ptr); /* 节点值释放函数 */
void (*match)(void *ptr, void *key); /* 节点值对比函数 */
} list;
总体来说,list/listNode 结构体还是很简单的,看下内存布局。
在 3.2 版本之前,是以 ziplist 和 list 作为其底层实现,规则和字典类似,默认是以 ziplist 为底层实现,当 ziplist 里节点超过 512 个或节点值长度超过 64 就转为 list 实现。
// redis.conf
list-max-ziplist-entries 512
list-max-ziplist-value 64
这么做的好处是,当元素较少较小时,采用 ziplist 可以有效节省内存,但 ziplist 的内存是连续的,当节点过多且修改节点值触发连锁更新时就必须重新分配存储空间,这样会影响 Redis 的性能,因而当元素个数超过 512 时就升级到双向链表了。
Redis 3.2 版本后,对列表底层结构进行了改进,不再是 ziplist 动态变化到 list 结构,而是由双向链表和 ziplist 相结合而成的 quicklist 结构。这个 quicklist 其实是综合考虑了时间效率和空间效率,是对两者的折中。
- 列表的常用操作有 lpush、lpop、rpush、rpop,其实这些都是正对链表的头部和尾部进行的操作,因为链表里保存了头部和尾部的指针,因此这些常用的操作都非常便捷。另外列表的每个节点都保存中前后两个指针,加大了节点内存占用,每个节点都是单独的内存,这就造成了节点的不连续,容易产生内存碎片。同时由于节点内存的不连续,造成读取节点时不能充分利用 CPU 的局部性原理。
- ziplist 在内存中是一整块连续的内存,所以存储读取效率高。但是修改时,都需要重新分配存储空间,而且有时也会引起连锁更新。
// quicklist.h
/* quicklist is a 40 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 long 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 结构在内存占用 40 个字节:头四个字段都是 8 个字节,最后 fill 和 compress 加起来 32 位共 4 个字节,由于需要内存对齐因此要再加上 4 个字节,这样就是共 40 个字节了。
- head、tail 分别执行节点的头和尾;
- count 为 quicklist 中元素的总个数,也就是所有节点里所有 ziplist 里的所有节点加起来的值,说白了,就是列表里的值的个数;
- len 代表 quicklistNodes 的个数;
- fill 用来指明每个 quicklistNodes 节点中 ziplist 的长度,为正数时,表明每个 ziplist 最多有多少数据节点,为负数时,参见配置文件。
redis.conf
# Lists are also encoded in a special way to save a lot of space.
# The number of entries allowed per internal list node can be specified
# as a fixed maximum size or a maximum number of elements.
# For a fixed maximum size, use -5 through -1, meaning:
# -5: max size: 64 Kb <-- not recommended for normal workloads
# -4: max size: 32 Kb <-- not recommended
# -3: max size: 16 Kb <-- probably not recommended
# -2: max size: 8 Kb <-- good
# -1: max size: 4 Kb <-- good
# Positive numbers mean store up to _exactly_ that number of elements
# per list node.
# The highest performing option is usually -2 (8 Kb size) or -1 (4 Kb size),
# but if your use case is unique, adjust the settings as necessary.
list-max-ziplist-size -2
fill 默认为 -2,也就是每个 quicklistNodes 上的 ziplist 大小不能超过8 Kb(1Kb = 1024 个字节)。
由于 Redis 列表常被访问的是两端的数据,如果中间数据过多,会造成内存浪费,为了节省内存,则需要对中间节点进行压缩。compress 就代表了两端各有 compress 个节点不用压缩,此值可参见配置文件。
redis.conf
# Lists may also be compressed.
# Compress depth is the number of quicklist ziplist nodes from *each* side of
# the list to *exclude* from compression. The head and tail of the list
# are always uncompressed for fast push/pop operations. Settings are:
# 0: disable all list compression
# 1: depth 1 means "don't start compressing until after 1 node into the list,
# going from either the head or tail"
# So: [head]->node->node->...->node->[tail]
# [head], [tail] will always be uncompressed; inner nodes will compress.
# 2: [head]->[next]->node->node->...->node->[prev]->[tail]
# 2 here means: don't compress head or head->next or tail->prev or tail,
# but compress all nodes between them.
# 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail]
# etc.
list-compress-depth 0
compress 默认为 0,也就是都不压缩。当值大于 0 表明,N 表示 quicklist 两端各有 N 个节点不压缩,中间的节点压缩。
// 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: 10 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;
quicklistNode 内存占 32 个字节,三个指针(在 64 位的操作系统中,指针占 8 个字节的内存)和两个整型,正好 32 个字节。
- prev、next 分别执行前后两个 quicklistNode 节点;
- zl 指向 ziplist 结构,当然了,这是在 quicklist 的 compress 为 0 的情况,或正好在前后两端不被压缩的节点内,一旦节点被压缩,那么 zl 指向 quicklistLZF 结构(这个待会儿介绍);
- sz 为整个 ziplist 的大小;
- count 为 ziplist 中的节点数, 16 位那么就是最多 2^16 个节点;
- encoding 表示 ziplist 是否压缩了(以及用了哪个压缩算法)。1 表示没有压缩,2 表示被压缩了(而且用的是 LZF压缩算法);
- container 是一个预留字段,默认为 2,表示使用 ziplist 结构保存数据;
- recompress 表示使用类似 lindex 这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置 recompress 置为 1 做一个标记,等有机会再把数据重新压缩;
- attempted_compress 测试时使用,不用理会;
- extra 预留字段。
// quicklist.h
/* 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;
quicklistLZF 表示 quicklist 中 compress 非 1 时,被压缩 quicklistNode 中 zl 指向的结构体,默认占 4 个字节,柔性数组 compressed 紧邻其后。
- sz 为被压缩后占的字节数
- compressed 表示被压缩后的节点
LZF 算法的大致为:遍历输入字符串,数据与前面重复的,记录重复位置以及重复长度,否则就直接记录原始数据内容。
在介绍压缩列表时说过,当 Redis 解析 ziplist 的中节点时,会使用一个 zlentry 结构体存储被解析的节点数据。这里解析 quicklistNode 中的 ziplist 里的节点时,也提供了一个 quicklistEntry 结构体来存放解析后的节点数据。
// quicklist.h
typedef struct quicklistEntry {
const quicklist *quicklist;
quicklistNode *node;
unsigned char *zi;
unsigned char *value;
long long longval;
unsigned int sz;
int offset;
} quicklistEntry;
quicklistEntry 占 48 个字节,前 5 个字段都是 8 个字节,后面两个字段各 4 个字节。
- quicklist 指向当前的快速列表
- node 指向当前的 quicklistNode 节点
- zi 指向元素所在的 ziplist
- value 指向该节点的字符串成员
- longval 为该节点的整型值 【因为节点既可存字符串又可存整型,所以就分别用 value 和 longval】
- sz 表示当前 ziplist 结构的大小【占多少字节】
- offset 表示相对 ziplist 的偏移量
当遍历 quicklist 结构中的节点时,快速列表实现了自己的迭代器。
typedef struct quicklistIter {
const quicklist *quicklist;
quicklistNode *current;
unsigned char *zi;
long offset; /* offset in current ziplist */
int direction;
} quicklistIter;
整个 quicklistIter 占 40 个字节,前 4 个字段占 32 个字节,最后一个占 4 个字节,由于内存对齐的需要,额外补了 4 个字节。
- quicklist 指向当前的快速列表
- current 指向当前的 quicklistNode 节点
- zi 指向元素所在的 ziplist
- offset 表示相对 ziplist 的偏移量
- direction 表示迭代的方向【压缩列表介绍过,可以正向和逆向迭代】
quicklist 整个内存布局大致如下。
最后看看常用的 API。
首先是 quicklist 的初始化。
// quicklist.c
/* Create a new quicklist.
* Free with quicklistRelease(). */
quicklist *quicklistCreate(void) {
struct quicklist *quicklist;
quicklist = zmalloc(sizeof(*quicklist));
quicklist->head = quicklist->tail = NULL;
quicklist->len = 0;
quicklist->count = 0;
quicklist->compress = 0;
quicklist->fill = -2;
return quicklist;
}
默认初始化后,quicklistNode 中每个 ziplist 的大小限制为 8Kb,且不对 ziplist 进行压缩。
Redis 除了这个 API 外,还提供了其他三个 API 来实现配置文件中 fill、compress 属性设置。
// quicklist.h
void quicklistSetCompressDepth(quicklist *quicklist, int depth);
void quicklistSetFill(quicklist *quicklist, int fill);
void quicklistSetOptions(quicklist *quicklist, int fill, int depth);
// quicklist.c
#define COMPRESS_MAX (1 << 16)
void quicklistSetCompressDepth(quicklist *quicklist, int compress) {
if (compress > COMPRESS_MAX) {
compress = COMPRESS_MAX;
} else if (compress < 0) {
compress = 0;
}
quicklist->compress = compress;
}
#define FILL_MAX (1 << 15)
void quicklistSetFill(quicklist *quicklist, int fill) {
if (fill > FILL_MAX) {
fill = FILL_MAX;
} else if (fill < -5) {
fill = -5;
}
quicklist->fill = fill;
}
void quicklistSetOptions(quicklist *quicklist, int fill, int depth) {
quicklistSetFill(quicklist, fill);
quicklistSetCompressDepth(quicklist, depth);
}
代码很简单,就是初始化 quicklist 后,分别设置对应的属性。
Redis 中列表添加元素对外提供的命令时 lpush、rpush,对应到 API 就是 quicklistPush。
// quicklist.c
/* Wrapper to allow argument-based switching between HEAD/TAIL pop */
void quicklistPush(quicklist *quicklist, void *value, const size_t sz,
int where) {
if (where == QUICKLIST_HEAD) {
quicklistPushHead(quicklist, value, sz);
} else if (where == QUICKLIST_TAIL) {
quicklistPushTail(quicklist, value, sz);
}
}
其中 QUICKLIST_HEAD 对应 lpush,把元素插入到头部;QUICKLIST_TAIL 对应 rpush,把元素插入到尾部。
// quicklist.c
/* Add new entry to head node of quicklist.
*
* Returns 0 if used existing head.
* Returns 1 if new head created. */
int quicklistPushHead(quicklist *quicklist, void *value, size_t sz) {
quicklistNode *orig_head = quicklist->head;
if (likely( /* 头部节点可以插入 */
_quicklistNodeAllowInsert(quicklist->head, quicklist->fill, sz))) {
quicklist->head->zl = /* 将节点 push 到 quicklist->head->zl */
ziplistPush(quicklist->head->zl, value, sz, ZIPLIST_HEAD);
quicklistNodeUpdateSz(quicklist->head); /* 更新 头部节点中记录 ziplist大小的 sz 字段 */
} else { /* 头部节点满了,或是超过限制了 */
quicklistNode *node = quicklistCreateNode(); /* 新建 quicklistNode */
node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD); /* 把 node->zl 指向初始化 ziplist 结构 */
quicklistNodeUpdateSz(node); /* 更新 头部节点中记录 ziplist大小的 sz 字段 */
_quicklistInsertNodeBefore(quicklist, quicklist->head, node); /* 将新创建的节点插入到头节点前 */
}
quicklist->count++; /* 更新 quicklist 中元素的个数 */
quicklist->head->count++; /* 更新头部节点的 ziplist 的元素的个数 */
return (orig_head != quicklist->head); /* 是否新建了头部节点,1 是 0 否 */
}
/* Add new entry to tail node of quicklist.
*
* Returns 0 if used existing tail.
* Returns 1 if new tail created.
* 这里和头部插入类似,就不一一注释了
*/
int quicklistPushTail(quicklist *quicklist, void *value, size_t sz) {
quicklistNode *orig_tail = quicklist->tail;
if (likely(
_quicklistNodeAllowInsert(quicklist->tail, quicklist->fill, sz))) {
quicklist->tail->zl =
ziplistPush(quicklist->tail->zl, value, sz, ZIPLIST_TAIL);
quicklistNodeUpdateSz(quicklist->tail);
} else {
quicklistNode *node = quicklistCreateNode();
node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_TAIL);
quicklistNodeUpdateSz(node);
_quicklistInsertNodeAfter(quicklist, quicklist->tail, node);
}
quicklist->count++;
quicklist->tail->count++;
return (orig_tail != quicklist->tail);
}
lpush、rpush 在添加元素的时候,都需要判断对应的头节点/尾结点是否能继续添加元素,不能的话则新创建 quicklistNode 和 ziplist,再添加元素,并且更新 quicklist 的头节点/尾结点。
Redis 添加元素还提供了个 LINSERT 命令,也就是任意位置写入。
127.0.0.1:6379> RPUSH mylist Hello World
(integer) 2
127.0.0.1:6379> LINSERT mylist BEFORE World There
(integer) 3
127.0.0.1:6379> lrange mylist 0 -1
1) "Hello"
2) "There"
3) "World"
查看相关代码
// quicklist.c
void quicklistInsertBefore(quicklist *quicklist, quicklistEntry *entry,
void *value, const size_t sz) {
_quicklistInsert(quicklist, entry, value, sz, 0);
}
void quicklistInsertAfter(quicklist *quicklist, quicklistEntry *entry,
void *value, const size_t sz) {
_quicklistInsert(quicklist, entry, value, sz, 1);
}
before 和 after 都调用了 _quicklistInsert 函数。
// quicklist.c
/* Insert a new entry before or after existing entry 'entry'.
*
* If after==1, the new value is inserted after 'entry', otherwise
* the new value is inserted before 'entry'. */
REDIS_STATIC void _quicklistInsert(quicklist *quicklist, quicklistEntry *entry,
void *value, const size_t sz, int after) {
int full = 0, at_tail = 0, at_head = 0, full_next = 0, full_prev = 0;
int fill = quicklist->fill;
quicklistNode *node = entry->node;
quicklistNode *new_node = NULL;
if (!node) {
/* we have no reference node, so let's create only node in the list */
D("No node given!");
new_node = quicklistCreateNode();
new_node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD);
__quicklistInsertNode(quicklist, NULL, new_node, after);
new_node->count++;
quicklist->count++;
return;
}
/* Populate accounting flags for easier boolean checks later */
if (!_quicklistNodeAllowInsert(node, fill, sz)) {
D("Current node is full with count %d with requested fill %lu",
node->count, fill);
full = 1;
}
if (after && (entry->offset == node->count)) {
D("At Tail of current ziplist");
at_tail = 1;
if (!_quicklistNodeAllowInsert(node->next, fill, sz)) {
D("Next node is full too.");
full_next = 1;
}
}
if (!after && (entry->offset == 0)) {
D("At Head");
at_head = 1;
if (!_quicklistNodeAllowInsert(node->prev, fill, sz)) {
D("Prev node is full too.");
full_prev = 1;
}
}
/* Now determine where and how to insert the new element */
if (!full && after) {
D("Not full, inserting after current position.");
quicklistDecompressNodeForUse(node);
unsigned char *next = ziplistNext(node->zl, entry->zi);
if (next == NULL) {
node->zl = ziplistPush(node->zl, value, sz, ZIPLIST_TAIL);
} else {
node->zl = ziplistInsert(node->zl, next, value, sz);
}
node->count++;
quicklistNodeUpdateSz(node);
quicklistRecompressOnly(quicklist, node);
} else if (!full && !after) {
D("Not full, inserting before current position.");
quicklistDecompressNodeForUse(node);
node->zl = ziplistInsert(node->zl, entry->zi, value, sz);
node->count++;
quicklistNodeUpdateSz(node);
quicklistRecompressOnly(quicklist, node);
} else if (full && at_tail && node->next && !full_next && after) {
/* If we are: at tail, next has free space, and inserting after:
* - insert entry at head of next node. */
D("Full and tail, but next isn't full; inserting next node head");
new_node = node->next;
quicklistDecompressNodeForUse(new_node);
new_node->zl = ziplistPush(new_node->zl, value, sz, ZIPLIST_HEAD);
new_node->count++;
quicklistNodeUpdateSz(new_node);
quicklistRecompressOnly(quicklist, new_node);
} else if (full && at_head && node->prev && !full_prev && !after) {
/* If we are: at head, previous has free space, and inserting before:
* - insert entry at tail of previous node. */
D("Full and head, but prev isn't full, inserting prev node tail");
new_node = node->prev;
quicklistDecompressNodeForUse(new_node);
new_node->zl = ziplistPush(new_node->zl, value, sz, ZIPLIST_TAIL);
new_node->count++;
quicklistNodeUpdateSz(new_node);
quicklistRecompressOnly(quicklist, new_node);
} else if (full && ((at_tail && node->next && full_next && after) ||
(at_head && node->prev && full_prev && !after))) {
/* If we are: full, and our prev/next is full, then:
* - create new node and attach to quicklist */
D("\tprovisioning new node...");
new_node = quicklistCreateNode();
new_node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD);
new_node->count++;
quicklistNodeUpdateSz(new_node);
__quicklistInsertNode(quicklist, node, new_node, after);
} else if (full) {
/* else, node is full we need to split it. */
/* covers both after and !after cases */
D("\tsplitting node...");
quicklistDecompressNodeForUse(node);
new_node = _quicklistSplitNode(node, entry->offset, after);
new_node->zl = ziplistPush(new_node->zl, value, sz,
after ? ZIPLIST_HEAD : ZIPLIST_TAIL);
new_node->count++;
quicklistNodeUpdateSz(new_node);
__quicklistInsertNode(quicklist, node, new_node, after);
_quicklistMergeNodes(quicklist, node);
}
quicklist->count++;
}
因为任意插入考虑的情况要多,表现最为明显的就是代码分支较多,这里罗列下相关的场景逻辑。
- 插入位置所在的 ziplist 大小没有超过限制时(可以参见 fill 的配置),直接插入;
- 插入位置所在的 ziplist 大小超过了限制,但插入的位置位于 ziplist 两端,并且相邻的 quicklistNode 的 ziplist 大小没有超过限制,那么就转而插入到相邻的 quicklistNode 的 ziplist 中;
- 插入位置所在的 ziplist 大小超过了限制,但插入的位置位于 ziplist 两端,并且相邻的 quicklistNode 的 ziplist 大小也超过限制,于是新建一个 quicklistNode 节点、一个 ziplist ,并把 zl 指向 ziplist,然后插入到 ziplist 中【当然了这里还得考虑是否启用 compress,也就是节点压缩】。
- 以上情况不满足,主要对应于插入在 ziplist 中间的任意位置,需要分割当前 quicklistNode 节点。最后如果能够合并,还要合并。
【注】 此博文中的 Redis 版本为 5.0。
参考书籍 :
【1】redis设计与实现(第二版)
【2】Redis 5设计与源码分析