Skip to content
Go back

Redis quicklist 详解:双向链表 + listpack 的混合结构

一句话结论(30s)

quicklist 本质是「双向链表 + 压缩列表」的混合结构,外层链表保证 O(1) 头尾操作,内层 ziplist/listpack 把多个元素塞进一段连续内存以减少指针开销。关键设计:因为链表节点大小由 list-max-ziplist-size(默认 -2 即 8KB)控制,所以每个节点内的连锁更新被隔离在单节点内,最坏代价从全表 O(N) 降为单节点内 O(几百)。权衡:8KB 是社区经验平衡点——节点太小则数量过多、指针开销回归 linkedlist,太大则单次 memmove 代价过高。

核心原理(2min)

quicklist 外层是双向链表(head/tail/len/count),每个 quicklistNode 内嵌一个 ziplist(7.0 起 listpack)。插入时若头/尾节点还能装下就直接 push,否则新建节点;中间插入若目标节点已满则分裂成两个节点;删除到半满以下会合并相邻节点。list-max-ziplist-size 的负值表示「每个节点最大字节数」(-1=4KB … -5=64KB),正值表示「最多元素数」。Redis 7.0 用 listpack 替代 ziplist:listpack 把「前驱长度 prevlen」改为「末尾自描述 backlen」,从结构上消除了连锁更新。

底层深入(5-10min)

一、从 linkedlist + ziplist 到 quicklist

Redis 3.0 及之前

List 类型有两种底层实现:

选择逻辑:元素少或元素短 → ziplist,否则 → linkedlist。

Redis 3.2 引入 quicklist

quicklist 的灵感来自 “链表 + 数组” 的混合结构:外层是双向链表(快速头尾操作),每个链表节点内是一段 ziplist(减少指针开销)。本质上这是对 linkedlist 和 ziplist 的折衷:

quicklist 结构:
┌──────────┐    ┌──────────┐    ┌──────────┐
│ quicklist │───→│  Node 0   │←──→│  Node 1   │←──→│  Node 2   │
│  head     │    │  ziplist  │    │  ziplist  │    │  ziplist  │
│  tail     │    │ [a,b,c]   │    │ [d,e,f,g] │    │ [h]       │
│  len=3    │    └──────────┘    └──────────┘    └──────────┘
│  count=8  │
└──────────┘

思考:为什么 quicklist 要「链表 + 压缩列表」两层,而不是纯链表或纯 ziplist?反着想两个极端——纯 linkedlist 每个元素一个节点,prev/next/value 三个指针就是 24B 额外开销,100 万元素光指针就 24MB 还高度碎片化;纯 ziplist 无指针但插入删除要 memmove 整个列表,长列表单次操作 O(N)。quicklist 让外层链表管「定位到节点」(O(1) 头尾)、内层 listpack 管「节点内紧凑存元素」,两头好处都拿到、两头的坏处都被节点边界隔断。

二、核心数据结构

2.1 quicklist

quicklist 外层的「链表骨架」——只负责把节点串起来,真正的元素都塞在节点内的 listpack 里:

typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all listpacks */
    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 */
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;

head/tail 两个指针让头尾压入、弹出都是 O(1),这是外层链表的职责;count 是所有 listpack 里 entry 的总数(跨节点累加),len 是 quicklistNode 的数量,两者语义分离——链表负责「定位到节点」,listpack 负责「节点内紧凑存元素」。alloc_size 缓存总分配内存,避免统计时反复遍历。

2.2 quicklistNode

真正承载数据的节点,内嵌一段 listpack(早期是 ziplist):

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *entry;
    size_t sz;             /* entry size in bytes */
    unsigned int count : 16;     /* count of items in listpack */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* PLAIN==1 or PACKED==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    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;

位域把整个节点压进 32 字节。entry 在 Redis 7.0+ 指向一段 listpack(更早版本指向 ziplist),container=PACKED(2) 表示 listpack、PLAIN(1) 表示超大元素单独存成裸字节数组;encoding 标记是否被 LZF 压缩,压缩只作用于中间节点,两端保留若干未压缩节点用于快速头尾访问。

三、插入策略与节点分裂

3.1 头插:能否塞进节点由 fill 决定

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);
        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);
        quicklistNodeUpdateSz(quicklist->head);
        quicklistUpdateAllocSize(quicklist, quicklist->head->sz, oldsize);
    } else {
        quicklistNode *node = quicklistCreateNode(quicklist);
        node->entry = lpPrepend(lpNew(0), value, sz);
        quicklistNodeUpdateSz(node);
        quicklistUpdateAllocSize(quicklist, node->sz, 0);
        _quicklistInsertNodeBefore(quicklist, quicklist->head, node);
    }
    quicklist->count++;
    quicklist->head->count++;
    return (orig_head != quicklist->head);
}

头插的完整逻辑一目了然:先看元素是不是「大元素」——若是则单独放进一个 PLAIN 节点(不压进 listpack);否则用 _quicklistNodeAllowInsert 判断当前 head 节点的 listpack 是否还能装下,能装就 lpPrepend 原地前插,不能装就新建节点接到链表头部。这正体现了 quicklist 的折中:链表负责增删节点,listpack 负责节点内紧凑存元素,fill 决定两者的切换点

3.2 中间插入的节点分裂

当在中间位置插入元素时,如果目标 ziplist 已满(根据 list-max-ziplist-size 配置),quicklist 会 分裂 节点:

分裂前:[Node A: ziplist[a,b,c,d,e]] (已满)
插入 'x' 在 'c' 之后
分裂后:[Node A: ziplist[a,b,c]] → [Node B: ziplist[x,d,e]]

分裂策略:插入位置作为分裂点,前一半元素留在原节点,后一半 + 新元素进入新节点。注意这里是 2 节点 分裂(不是 B+Tree 的 1→2 分裂 + 向上传播),因为 quicklist 没有层级索引。

3.3 删除时的节点合并

删除后,quicklist 会尝试把相邻节点合并,判断依据不是「少于半满」,而是「合并后的 listpack 是否还满足 fill 限制」:

REDIS_STATIC int _quicklistNodeAllowMerge(const quicklistNode *a,
                                          const quicklistNode *b,
                                          const int fill) {
    if (!a || !b)
        return 0;

    if (unlikely(QL_NODE_IS_PLAIN(a) || QL_NODE_IS_PLAIN(b)))
        return 0;

    /* approximate merged listpack size (- 7 to remove one listpack
     * header/trailer, see LP_HDR_SIZE and LP_EOF) */
    unsigned int merge_sz = a->sz + b->sz - 7;
    if (unlikely(quicklistNodeExceedsLimit(fill, merge_sz, a->count + b->count)))
        return 0;
    return 1;
}

合并时估算两个 listpack 合并后的字节数(减 7 是因为两个 listpack 共用一个 6 字节头 + 1 字节尾),只要不超过 fill 限制就执行 _quicklistListpackMerge 把右侧元素拼进左侧。这保证删除后节点不会「碎」成大量半空小节点,从而维持高填充率、避免指针开销回退到 linkedlist。

四、list-max-ziplist-size 配置

# redis.conf
list-max-ziplist-size -2

这个配置的 负值含义常常被误解。实际语义:

含义
-1每个 ziplist 节点 ≤ 4KB
-2每个 ziplist 节点 ≤ 8KB(默认
-3每个 ziplist 节点 ≤ 16KB
-4每个 ziplist 节点 ≤ 32KB
-5每个 ziplist 节点 ≤ 64KB
正数 N每个 ziplist 节点最多包含 N 个元素

为什么默认是 -2(8KB)? 这是 Redis 社区长期实践的经验值:

思考:为什么节点大小默认 -2(8KB),而不是更大或更小?顺着权衡推——节点太小(如 1KB),节点数量暴增、prev/next 指针开销回归 linkedlist;节点太大(如 64KB),单次插入删除的 memmove 代价过高、还让连锁更新的爆炸半径变大。8KB 是「节点数量」与「单次 memmove 成本」之间的社区经验平衡点,单次 memmove 被控制在 ≤8KB 这个可接受的量级。

五、连锁更新的范围控制

ziplist 的经典问题——连锁更新——在 quicklist 中得到了范围控制。

5.1 ziplist 的连锁更新原罪

ziplist 中每个 entry 的前一个 entry 的长度被编码在 prevlen 字段中,阈值正是 254:

#define ZIP_END 255         /* Special "end of ziplist" entry. */
#define ZIP_BIG_PREVLEN 254 /* ZIP_BIG_PREVLEN - 1 是 prevlen 能用 1 字节表示的最大前驱长度 */
/* Encode the length of the previous entry and write it to "p". Return the
 * number of bytes needed to encode this length if "p" is NULL. */
unsigned int zipStorePrevEntryLength(unsigned char *p, unsigned int len) {
    if (p == NULL) {
        return (len < ZIP_BIG_PREVLEN) ? 1 : sizeof(uint32_t) + 1;
    } else {
        if (len < ZIP_BIG_PREVLEN) {
            p[0] = len;
            return 1;
        } else {
            return zipStorePrevEntryLengthLarge(p,len);
        }
    }
}

/* 从 prevlen 前缀读回「前一个 entry 的长度」和它占用的字节数 */
#define ZIP_DECODE_PREVLENSIZE(ptr, prevlensize) do {                          \
    if ((ptr)[0] < ZIP_BIG_PREVLEN) {                                          \
        (prevlensize) = 1;                                                     \
    } else {                                                                   \
        (prevlensize) = 5;                                                     \
    }                                                                          \
} while(0)

zipStorePrevEntryLength 揭示了问题的本质:prevlen 编码的是前驱的长度,前驱 < 254 字节时占 1 字节,≥ 254 字节时占 sizeof(uint32_t)+1 = 5 字节。因为「当前 entry 的头」依赖「前驱的内容」,前驱一旦跨越 254 这个阈值,当前 entry 的头部就要从 1 字节膨胀到 5 字节。

当某个 entry 从 253B 变为 254B 时,其后继 entry 的 prevlen 从 1B 膨胀到 5B,导致后继 entry 自身增大 4B → 可能触发再后继的 prevlen 膨胀 → 级联传播。

在纯 ziplist 场景下(Redis 3.0),list 的所有元素在一个 ziplist 中,连锁更新可能传播整个链表——O(N) 的最坏时间复杂度。

5.2 quicklist 如何限制范围

quicklist 将元素分散到多个 ziplist 节点中,每个节点独立维护自己的连锁更新域。一个 ziplist 节点内的连锁更新不会传播到相邻节点,因为 prevlen 只在 ziplist 内部有效。

quicklist 中:
[Node 0: ziplist] → [Node 1: ziplist] → [Node 2: ziplist]
                   ↑ 隔离边界
连锁更新最多影响 Node 1 的全部 entry,不会跨 Node 传播

这意味着最坏情况下,连锁更新的代价从 O(N) 降为 O(N_entries_in_one_ziplist)(通常 ≤ 几百个 entry)。

思考:quicklist 为什么能把连锁更新的代价从 O(N) 压到 O(单个节点内)?关键在「prevlen 只在 ziplist 内部有效」——每个节点是独立的一段连续内存,prevlen 记录的是「本 ziplist 内的前驱长度」,不会跨节点引用。所以连锁更新最多在单个节点内传播,被节点边界硬生生切断。这其实也解释了「为什么要切成多个小 ziplist」的另一个理由:不仅控制 memmove 成本,也顺带把连锁更新的爆炸半径框在一个节点里。

六、Redis 7.0 的 listpack 迁移

Redis 7.0 将 quicklist 节点的内部存储从 ziplist 迁移到 listpack。listpack 是 ziplist 的后继替代品,核心改进是 消除了连锁更新——把「前驱长度 prevlen」换成了「末尾自描述 backlen」,写入时即确定、不再随前驱变化:

/* 决定元素用整数还是字符串编码,并算出编码头 enclen 的字节数 */
static inline int lpEncodeGetType(unsigned char *ele, uint32_t size, unsigned char *intenc, uint64_t *enclen) {
    int64_t v;
    if (lpStringToInt64((const char*)ele, size, &v)) {
        lpEncodeIntegerGetType(v, intenc, enclen);
        return LP_ENCODING_INT;
    } else {
        if (size < 64) *enclen = 1+size;
        else if (size < 4096) *enclen = 2+size;
        else *enclen = 5+(uint64_t)size;
        return LP_ENCODING_STRING;
    }
}

/* 把字符串连同变长编码头写入 buf:<encoding+len><data> */
static inline void lpEncodeString(unsigned char *buf, unsigned char *s, uint32_t len) {
    if (len < 64) {
        buf[0] = len | LP_ENCODING_6BIT_STR;
        memcpy(buf+1,s,len);
    } else if (len < 4096) {
        buf[0] = (len >> 8) | LP_ENCODING_12BIT_STR;
        buf[1] = len & 0xff;
        memcpy(buf+2,s,len);
    } else {
        buf[0] = LP_ENCODING_32BIT_STR;
        buf[1] = len & 0xff;
        buf[2] = (len >> 8) & 0xff;
        buf[3] = (len >> 16) & 0xff;
        buf[4] = (len >> 24) & 0xff;
        memcpy(buf+5,s,len);
    }
}

/* 在 entry 末尾反向写回变长 backlen(记录的是自身的长度),返回占用字节数 */
static inline unsigned long lpEncodeBacklen(unsigned char *buf, uint64_t l) {
    if (l <= 127) {
        if (buf) buf[0] = l;
        return 1;
    } else if (l <= 16383) {
        if (buf) {
            buf[0] = l>>7;
            buf[1] = (l&127)|128;
        }
        return 2;
    } else if (l <= 2097151) {
        if (buf) {
            buf[0] = l>>14;
            buf[1] = ((l>>7)&127)|128;
            buf[2] = (l&127)|128;
        }
        return 3;
    } else if (l <= 268435455) {
        if (buf) {
            buf[0] = l>>21;
            buf[1] = ((l>>14)&127)|128;
            buf[2] = ((l>>7)&127)|128;
            buf[3] = (l&127)|128;
        }
        return 4;
    } else {
        if (buf) {
            buf[0] = l>>28;
            buf[1] = ((l>>21)&127)|128;
            buf[2] = ((l>>14)&127)|128;
            buf[3] = ((l>>7)&127)|128;
            buf[4] = (l&127)|128;
        }
        return 5;
    }
}

对比 ziplist 的 [prevlen][encoding][data],listpack 的布局是 [encoding+len][data][backlen]lpEncodeString 把长度编码在 entry 的头部(1/2/5 字节的变长 encoding),lpEncodeBacklen自身长度写在 entry 的末尾(1~5 字节的 7-bit 分组编码)。反向遍历时从尾部读 backlen 即可回跳到前一个 entry。因为 backlen 记录的是「自己有多长」,这个值在写入时就确定了,前驱再怎么变化都不会波及它——连锁更新的根源(级联 prevlen 膨胀)被从结构上根除

思考:backlen 为什么能根除连锁更新,而 prevlen 不能?追到根因——prevlen 存「前驱长度」,当前 entry 的头部依赖前驱内容,前驱一变就要跟着改;backlen 存「自身长度」,写入那一刻就确定,前驱再怎么变都不影响它。一个「依赖别人」、一个「只依赖自己」,这就是级联更新被从结构上消除的本质区别——这也是理解 ziplist 和 listpack 差异的那把钥匙。

quicklistNode 的 container 取值也随之变化:

/* quicklist node container formats */
#define QUICKLIST_NODE_CONTAINER_PLAIN 1
#define QUICKLIST_NODE_CONTAINER_PACKED 2

升级兼容性PACKED(2) 就是 listpack 节点,PLAIN(1) 是超大元素节点——这个版本的 quicklist 已彻底移除 ziplist 容器,listpack 全面取代 ziplist;旧版 RDB 里的 ziplist 编码 list 在载入时会被转换为 listpack。

章末提问

Q1:quicklist 为什么是「双向链表 + listpack」的混合结构,而不是纯链表或纯 ziplist?

回答思路:结论——外层链表保证 O(1) 头尾操作,内层 listpack 把多个元素塞进连续内存减少指针开销,是两者的折中。因为:纯链表每个元素一个节点、三个指针 24B 额外开销且高度碎片化;纯 ziplist 无指针但长列表插入删除要 memmove 整个列表、O(N)。混合后「定位」交给链表、「紧凑存储」交给 listpack,两种结构的缺点都被节点边界隔断。

Q2:list-max-ziplist-size 为什么默认 -2(8KB)?太大太小各有什么问题?

回答思路:结论——-2 表示每个节点 ≤8KB,是「节点数量」与「单次 memmove 成本」之间的平衡点。因为:节点太小则节点数量暴增、指针开销回归 linkedlist;太大则单次插入删除的 memmove 代价过高,连锁更新的爆炸半径也变大。另外负值是「按字节数上限」、正值是「按元素数上限」,默认 -2 是社区长期实践的经验值。

Q3:ziplist 的连锁更新是什么?quicklist 和 listpack 分别怎么解决/限制它?

回答思路:结论——连锁更新是 prevlen 记录前驱长度、前驱跨过 254 字节阈值导致 prevlen 从 1B 膨胀到 5B 的级联重写,最坏 O(N)。因为:当前 entry 头部依赖前驱内容,前驱一变就触发后继头部膨胀;quicklist 把元素分散到多节点、让连锁更新被隔离在单个节点内(最坏降到 O(节点内 entry 数));listpack 把 prevlen 换成末尾 backlen 记录自身长度、写入即定不随前驱变化,从结构上彻底根除。


Share this post on:

Previous Post
Redis Sentinel选主——三轮打分机制详解
Next Post
Redis jemalloc 内存分配器:红黑树、线程缓存与 size class