quicklist
quicklist 源码分析(List 底层)
quicklist 是 List 类型的底层结构:一个双向链表,但每个节点装的不是单个元素,而是一段 listpack(紧凑连续内存,塞多个元素)。
它解决的是两难:纯双向链表每个元素背 prev/next 两个指针,内存开销大、碎片多;单个大 listpack 中间插删要整块 memmove,元素一多成本失控。
quicklist 把两者拼起来——链表负责 O(1) 两端增删,listpack 负责省内存,中段冷数据还能 LZF 压缩。
先看三个核心结构。quicklistNode 用位域死死压在 32 字节,一个字段都不多给:
代码块收起展开
// 基于本地 Redis 仓 (unstable), src/quicklist.h
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *entry; // 指向 listpack;被压缩时指向 quicklistLZF
size_t sz; /* entry size in bytes */ // 永远记「未压缩」的字节数,解压时靠它分配缓冲
unsigned int count : 16; /* count of items in listpack */ // listpack 上限 64KB,元素数撑不爆 16 位
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* PLAIN==1 or PACKED==2 */ // PLAIN=大元素独占节点,entry 是裸字节数组不是 listpack
unsigned int recompress : 1; /* was this node previous compressed? */ // 被临时解压给人用,用完要压回去的标记
unsigned int attempted_compress : 1; /* node can't compress; too small */ // 仅 REDIS_TEST 下的验证标记,生产路径不读它
unsigned int dont_compress : 1; /* prevent compression of entry that will be used later */
unsigned int extra : 9; /* more bits to steal for future usage */
} quicklistNode;
// ...
typedef struct quicklistLZF {
size_t sz; /* LZF size in bytes*/ // 压缩后的长度;解压后的长度在 node->sz 里
char compressed[]; // 柔性数组:头 + 压缩数据一次 zmalloc,少一层指针
} quicklistLZF;
// ...
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* total count of all entries in all listpacks */ // LLEN O(1) 靠它,不用遍历
unsigned long len; /* number of quicklistNodes */
size_t alloc_size; /* total allocated memory (in bytes) */
signed int fill : QL_FILL_BITS; /* fill factor for individual nodes */ // list-max-listpack-size:正=限元素个数,负=限字节数
unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */ // list-compress-depth:两端各留几个节点不压
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[]; // 柔性数组:不用 bookmark 时零开销
} quicklist;写入路径的全部智慧集中在「一个节点还能不能再塞」这一个判断上。fill 为负时查表限字节数(-1 到 -5 对应 4KB 到 64KB,quicklistCreate 的初始值是 -2 即 8KB),为正时限元素个数但仍有 8KB 兜底:
代码块收起展开
// 基于本地 Redis 仓 (unstable), src/quicklist.c
static const size_t optimization_level[] = {4096, 8192, 16384, 32768, 65536}; // fill=-1..-5 对应的单节点字节上限
// ...
#define SIZE_SAFETY_LIMIT 8192 // 按个数限制时的兜底:listpack 再怎么也不许超 8KB
// ...
#define SIZE_ESTIMATE_OVERHEAD 8 // 预估一条 listpack entry 的头部开销,故意高估
// ...
void quicklistNodeLimit(int fill, size_t *size, unsigned int *count) {
*size = SIZE_MAX;
*count = UINT_MAX;
if (fill >= 0) {
/* Ensure that one node have at least one entry */
*count = (fill == 0) ? 1 : fill;
} else {
*size = quicklistNodeNegFillLimit(fill); // 负 fill 查 optimization_level 表
}
}
// ...
static int isLargeElement(size_t sz, int fill) { // 「大元素」的标准跟着 fill 走,不是写死的 8KB
if (unlikely(packed_threshold != 0)) return sz >= packed_threshold; // 仅测试套件用的开关
if (fill >= 0)
return !sizeMeetsSafetyLimit(sz);
else
return sz > quicklistNodeNegFillLimit(fill);
}
REDIS_STATIC int _quicklistNodeAllowInsert(const quicklistNode *node,
const int fill, const size_t sz) {
if (unlikely(!node))
return 0;
if (unlikely(QL_NODE_IS_PLAIN(node) || isLargeElement(sz, fill)))
return 0; // PLAIN 节点只容一个大元素,谁都不能再挤进去
// ...
size_t new_sz = node->sz + sz + SIZE_ESTIMATE_OVERHEAD; // 不真插一次再量,用高估的增量预判,省一次 realloc+memmove
if (unlikely(quicklistNodeExceedsLimit(fill, new_sz, node->count + 1)))
return 0;
return 1;
}
// ...
/* 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 (unlikely(isLargeElement(sz, quicklist->fill))) {
__quicklistInsertPlainNode(quicklist, quicklist->head, value, sz, 0); // 大元素不进 listpack,独占一个 PLAIN 节点
return 1;
}
if (likely(
_quicklistNodeAllowInsert(quicklist->head, quicklist->fill, sz))) {
size_t oldsize = quicklist->head->sz;
quicklist->head->entry = lpPrepend(quicklist->head->entry, value, sz); // 头节点没满:直接进它的 listpack
quicklistNodeUpdateSz(quicklist->head);
quicklistUpdateAllocSize(quicklist, quicklist->head->sz, oldsize);
} else {
quicklistNode *node = quicklistCreateNode(quicklist);
node->entry = lpPrepend(lpNew(0), value, sz); // 满了:新建节点装单元素 listpack,挂到链表头
quicklistNodeUpdateSz(node);
quicklistUpdateAllocSize(quicklist, node->sz, 0);
_quicklistInsertNodeBefore(quicklist, quicklist->head, node);
}
quicklist->count++;
quicklist->head->count++;
return (orig_head != quicklist->head);
}中段压缩是 quicklist 独有的第二层省内存手段。压缩以节点为单位、尽力而为,压不动就保持 RAW:
代码块收起展开
// 基于本地 Redis 仓 (unstable), src/quicklist.c
#define MIN_COMPRESS_BYTES 48 // 不到 48 字节不压:连 LZF 的头部开销都赚不回来
// ...
#define MIN_COMPRESS_IMPROVE 8 // 至少省 8 字节才保留压缩结果,防「越压越大」
// ...
REDIS_STATIC int __quicklistCompressNode(quicklist *quicklist, quicklistNode *node) {
// ...
if (node->dont_compress) return 0;
/* validate that the node is neither
* tail nor head (it has prev and next)*/
assert(node->prev && node->next); // head/tail 永远不压:两端是 push/pop 热点,压了每次操作都要先解压
node->recompress = 0;
/* Don't bother compressing small values */
if (node->sz < MIN_COMPRESS_BYTES)
return 0;
quicklistLZF *lzf = zmalloc(sizeof(*lzf) + node->sz);
/* Cancel if compression fails or doesn't compress small enough */
if (((lzf->sz = lzf_compress(node->entry, node->sz, lzf->compressed,
node->sz)) == 0) ||
lzf->sz + MIN_COMPRESS_IMPROVE >= node->sz) {
/* lzf_compress aborts/rejects compression if value not compressible. */
zfree(lzf);
return 0;
}
lzf = zrealloc(lzf, sizeof(*lzf) + lzf->sz); // 按实际压缩后大小收缩,不留水分
zfree(node->entry);
node->entry = (unsigned char *)lzf;
node->encoding = QUICKLIST_NODE_ENCODING_LZF;
quicklistUpdateAllocSize(quicklist, sizeof(*lzf) + lzf->sz, node->sz);
return 1;
}
// ...
/* Force node to not be immediately re-compressible */
#define quicklistDecompressNodeForUse(_ql, _node) \
do { \
if ((_node) && (_node)->encoding == QUICKLIST_NODE_ENCODING_LZF) { \
__quicklistDecompressNode((_ql), (_node)); \
(_node)->recompress = 1; \
} \
} while (0) // 中段节点被读写前先走这里解压,recompress=1 记下「用完压回去」
// ...
#define quicklistCompress(_ql, _node) \
do { \
if ((_node)->recompress) \
quicklistCompressNode((_ql), (_node)); \
else \
__quicklistCompress((_ql), (_node)); \
} while (0) // 压缩总入口:带 recompress 标记走快路径直接压回,否则做一次深度扫描
// ...
REDIS_STATIC void __quicklistCompress(quicklist *quicklist,
quicklistNode *node) {
if (quicklist->len == 0) return;
/* The head and tail should never be compressed (we should not attempt to recompress them) */
assert(quicklist->head->recompress == 0 && quicklist->tail->recompress == 0);
/* If length is less than our compress depth (from both sides),
* we can't compress anything. */
if (!quicklistAllowsCompression(quicklist) ||
quicklist->len < (unsigned int)(quicklist->compress * 2))
return;
// ...
quicklistNode *forward = quicklist->head;
quicklistNode *reverse = quicklist->tail;
int depth = 0;
int in_depth = 0;
while (depth++ < quicklist->compress) { // 双指针从两端向内各走 compress 步,路过的节点全部解压
quicklistDecompressNode(quicklist, forward);
quicklistDecompressNode(quicklist, reverse);
if (forward == node || reverse == node)
in_depth = 1;
/* We passed into compress depth of opposite side of the quicklist
* so there's no need to compress anything and we can exit. */
if (forward == reverse || forward->next == reverse)
return;
forward = forward->next;
reverse = reverse->prev;
}
if (!in_depth)
quicklistCompressNode(quicklist, node); // 刚动过的节点落在深度之外,把它压掉
/* At this point, forward and reverse are one node beyond depth */
quicklistCompressNode(quicklist, forward);
quicklistCompressNode(quicklist, reverse);
}原理串讲
以一条 LPUSH key value 走完整链路。命令层最终调到 quicklistPushHead,第一件事不是找位置,而是 isLargeElement:元素超过当前 fill 对应的字节上限(默认 -2 即 8KB)就直接 __quicklistInsertPlainNode,新建一个 container=PLAIN 的节点独占它,根本不碰 listpack。
为什么大元素要隔离?因为 listpack 的一切操作都是整块 realloc + memmove,一个 5MB 的元素混进去,之后这个节点的每次插删都要搬这 5MB,而且它也会把「按字节数限容」的判断彻底打穿——干脆让它自成一个节点,链表指针天然隔离了搬移成本。
普通元素走 _quicklistNodeAllowInsert 判断头节点还装不装得下。
这里有个容易忽略的细节:它不真的插入再检查,而是用 node->sz + sz + SIZE_ESTIMATE_OVERHEAD 预估插入后的大小。
代码块收起展开
为什么用估算而且故意高估?因为 listpack 每条 entry 有 1~11 字节不等的头部,精确值只有真插进去才知道,而「插进去发现超限再拆出来」等于白付一次 realloc + memmove;高估 8 字节最多让节点比 4KB 下限小几个字节,代价几乎为零,换来的是判断和执行一次完成。
装得下就 `lpPrepend` 进头节点的 listpack 并 `quicklistNodeUpdateSz` 刷新缓存的 sz;装不下则 `quicklistCreateNode` + `lpNew` 造一个单元素新节点,`_quicklistInsertNodeBefore` 挂到链表头。挂接最终落到 __quicklistInsertNode,它做完指针缝合、len++ 之后调 quicklistCompress,这是压缩机制的总入口:若节点带着 recompress 标记就地压回去,否则走 __quicklistCompress——双指针从 head 和 tail 同时向内走 compress 步,沿途节点全部解压(保证两端深度内永远是 RAW),走完后把深度边界外的第一个节点压掉。
为什么每次插入节点都要跑这个扫描?因为插入会把原来处于「深度边界」的节点向内挤一格,它的压缩状态必须跟着变;而深度通常配 1 或 2,这个循环实际只走一两步,代价是常数级的。
也因此 quicklistPush 入口处直接 assert head/tail 一定未压缩——两端是 List 的访问热点(LPUSH/RPOP 的工作面),永远保持可直接读写。
反方向的 RPOP 走 quicklistPop → quicklistPopCustom:lpSeek(node->entry, -1) 定位尾元素,lpGetValue 取值(字符串走 saver 回调拷贝,整数直接由 sval 带出),然后 quicklistDelIndex 删除。
节点里最后一个元素被删掉时 __quicklistDelNode 摘掉整个节点,并再次调 __quicklistCompress(quicklist, NULL)——摘节点让两端深度内「多出」一个被压缩的节点,得把它解压回来,压缩不变量在增删两个方向都要维护。
中间插入(LINSERT)在 _quicklistInsert 里,是各种边界的集大成:
- 目标节点没满直接
lpInsertString; - 满了但插入点恰好在节点边缘、邻居有空位,就把元素塞进邻居(
lpPrepend进 next 或lpAppend进 prev); - 邻居也满就新建节点;
- 最糟的情况——满节点的正中间——只能
_quicklistSplitNode把 listpack 一分为二,插完再_quicklistMergeNodes尝试把碎节点合并回去,避免链表被切得越来越碎。
设计取舍
- fill 一个字段两种语义:正数限个数(给用户直觉),负数查表限字节(给内存控制),正数时还有 8KB 安全上限兜底——防止「个数没超但单节点巨大」。
- 压缩全程尽力而为:小于 48 字节不压、省不到 8 字节不存,任何失败都静默保持 RAW,encoding 位保证读路径永远能分辨。
count/sz/alloc_size全是冗余缓存,换 LLEN、MEMORY USAGE 的 O(1);代价是每次增删都要同步维护,源码里大量quicklistNodeUpdateSz/quicklistUpdateAllocSize就是在还这笔债。- Redis 7.0 起 listpack 全面接替 ziplist:ziplist 每条 entry 记「前一条的长度」,一次插入可能引发连锁扩容(级联更新);listpack 只记自身长度,从结构上消灭了这个问题。
- 版本注意:7.x 的小 List 编码直接是单个 listpack(OBJ_ENCODING_LISTPACK),超过 list-max-listpack-size 或出现大元素才升级为 quicklist,quicklist 是「大 List」的形态。