☰
Redis ziplist 源码解析:内存紧凑链表设计与级联更新
2026/10/22 18:51:50 网站建设 项目流程

1. 先搞清楚 ziplist 到底解决了什么问题

1.1 双向链表很香,但不是没代价

很多人一看到“链表”两个字,第一反应就是普通的双向链表:每个节点有 prev 指针和 next 指针,加上数据区。Reids 早期版本,list 键底层就是普通的双向链表,后来也一直保留着。这种结构优点很明显:插入删除只要改指针,遍历双向都能走。但代价同样明显——每个节点都要额外存两个指针,在 64 位系统上一个指针就是 8 字节,两个就是 16 字节。如果你存的是一个很小的整数,或者一个只有几个字节的短字符串,那指针的开销比数据本身还大,内存利用率低得离谱。

更麻烦的是,链表节点是分别 malloc 出来的,每个节点的内存地址散落各处,CPU 缓存基本吃不到红利。你在内存里跳来跳去访问,和跑随机 IO 没什么区别。Redis 是单线程模型,一次命令处理时间的波动会直接影响所有请求,所以这种 cache miss 带来的延迟抖动在 Redis 里是致命的。

我当年第一次看 ziplist 源码时就觉得:与其说它是一种新的链表,不如说它是“把链表拍扁成一个字节数组”。ziplist 不存指针,不搞分散的内存块,一整块连续内存从头到尾排下来,所有元素紧挨着。这样既拿到了链表“顺序访问”的能力,又尽可能把内存利用率拉满。它是 Redis 为了小数据量场景专门设计的一种内存紧凑型顺序存储结构。

1.2 ziplist 的设计哲学:用 CPU 换内存,还得算好账

连续内存数组的代价是什么?最典型的就是插入删除要搬数据。你在中间插一个元素,后面所有元素都得往后挪;删除也一样,后面全部往前挪。这个挪动是 memmove 级别的批量拷贝,数据量大起来非常伤人。所以 ziplist 的设计适用面很清晰:少元素、小数据。一旦元素多了、value 大了,Redis 就会把它转换成其他结构(比如 dict、skiplist、quicklist)。

源码里频繁出现的一个宏是 ZIPLIST_HEAD 和 ZIPLIST_TAIL,另一个很重要的宏是 ZIP_ENTRY_ENCODING,读这类代码最大的感受就是“省”:能用一个字节表示的绝不用两个,能复用字段的绝不多开空间。每个 entry 里同时塞下 previous entry length、encoding 和 data 三部分,长度字段还支持变长编码。这本质上是在用“读取时多判断几次”来换“存储时少占几字节”。

这种取舍特别值得普通业务开发借鉴。写业务代码时,大部分人还没到需要在意那几十个字节的程度,但如果你在处理类似“千万级短 key”“高并发小对象列表”这种数据量爆炸的局面,内存里的每字节都在烧钱。ziplist 的思路是:先想清楚你的数据画像,再去选数据结构,而不是什么场景都上通用结构。

2. 从源码看 ziplist 的内存布局

2.1 头尾元数据:四个字段撑起整个结构

ziplist 整体的内存布局分三块:头部、元素区、尾部。头部固定三个字段,分别叫 zlbytes、zltail、zllen,总共占 10 字节(32 位系统也是同样设计,一般 64 位环境都是 10 字节)。

zlbytes 是 4 字节无符号整数,记录整个 ziplist 占用的总字节数,包括头部、所有 entry 和尾部标记。为什么要把总长度存起来?因为 ziplist 的内存是 realloc 动态管理的,系统经常需要知道这块内存到底多大,才能安全地扩大、缩小或者重新分配。zltail 是 4 字节,记录的是最后一个 entry 相对于 ziplist 起始地址的偏移量。有了它,做 tail pop 的时候不用从头部一路遍历到最后,直接拿起始地址加偏移量就能定位到 tail entry,时间复杂度是 O(1)。zllen 是 2 字节,记录 entry 的数量。这里有个细节:如果元素数量超过 65535,zllen 会被设为 65535,此时你想知道精确数量就只能遍历统计。这是源码里一个常见的“妥协”。

尾部是一个特殊字节 0xFF,也就是 255,标记 ziplist 结束。遍历的时候看到这个字节就知道到头了。这里要提醒一点,0xFF 本身也可能作为数据内容出现在 entry 里,所以 ziplist 的解析逻辑不是只判断值,而是先根据 encoding 判断 entry 边界,再往前进,不会把数据里的 0xFF 误认为结束标记。

2.2 entry 三个部分:prevlen、encoding、data

每个 entry 由三个部分组成,顺序固定:prevlen、encoding、data。

prevlen 记录的是前一个 entry 的总字节数。这个设计是为了实现从尾部往头部遍历:你站在当前 entry 的起始位置,减掉 prevlen,就精准落到了前一个 entry 的起始位置。这个字段本身也是变长编码的:如果前一个 entry 长度小于 254 字节,prevlen 只占 1 字节;如果前一个 entry 长度大于等于 254 字节,prevlen 就占 5 字节,第一个字节固定为 0xFE(254),后续 4 字节存真实长度。为什么阈值是 254 而不是 255?因为 255 已经被 zlend 占用了,必须避让。

encoding 字段标识 data 的类型和长度。对整数来说,encoding 直接告诉我们整数占几字节、是有符号还是无符号;对字符串来说,encoding 低两位或低几位表示字符串长度,而且字符串长度超过一定范围时 encoding 本身也要扩张。data 就是真正的数据内容:整数直接以二进制小端存储,字符串直接存原始字节。

读源码时建议盯住 zipEntry 这个函数,它的职责就是解析一个 entry:先读 prevlen,再读 encoding,最后把 data 的指针和长度填到 zlentry 结构体里。理解了这个函数,后面所有操作源码都会顺畅很多。

2.3 从宏定义看整数编码的细节

ziplist 支持 4 种整数长度:4 位、1 字节、2 字节、4 字节,以及 8 字节。对应 encoding 的取值很有意思。

如果 encoding 的最高 2 位是 00,表示 data 内容是一个 6 位长度的短字符串,长度值直接编码在低 6 位里。所以这种 entry 最多存 63 字节的短串。如果最高 2 位是 01,表示这是一个 14 位长度的字符串,encoding 占 2 字节,低 14 位表示长度,最大能到 16383。如果最高 2 位是 10,表示这是一个 32 位长度的字符串,encoding 占 5 字节,其中 4 字节存长度。

整数编码藏在 11 开头的情形里。当最高 2 位是 11,encoding 的剩余位用来区分整数的位宽和类型:11000000 表示 int16,11010000 表示 int32,11100000 表示 int64,11110000 表示 int24,11111110 表示 int8,1111xxxx 表示 4 位立即数(xxxx 为 0-14 的实际数值)。你没看错,ziplist 连“4 位整数塞进 encoding 本身”这种操作都干得出来,目的就是把一个 0-12 的整数几乎零成本存进去,连 data 字段都不需要单独存在。为什么是 0-12?因为 13 和 14 被保留用于编码标记,15 和 11111110 冲突也不能用,剩下的才能存数据。

读到这里你会明显感到,ziplist 的设计者把“字节抠到极致”这件事做到了近乎偏执的程度。实际业务里我们经常为了可读性多建几个字段,但这种级别的优化在 Redis 这种内存数据库里是实打实能省几 GB 内存的。

2.4 一段真实的 ziplist 字节,逐字节拆开

只看理论太抽象,我直接用一个例子把内存按字节展开。假设我们往一个新建的 ziplist 里 push 两个元素:整数 5 和字符串 “ab”。

初始 ziplist 只有头部四个字段加上结束字节 0xFF,一共 11 字节。push 整数 5 的时候,因为 5 可以走 4 位立即数编码,entry 里 prevlen 是 1(前面只有 header),encoding 是 0xF5(1111 前缀加 0101),data 不存在。所以这 1 字节 entry 里,数据值 5 是直接躺在 encoding 的低 4 位里的。整个过程只是把 zlbytes 改成 12、zltail 改成 10、zllen 改成 1,再在结尾前插入一个 0xF5 字节。

再 push 字符串 “ab” 时,字符串长度 2,走短字符串编码:最高 2 位是 00,低 6 位是长度 2,所以 encoding 是 0x02。prevlen 呢?前一个 entry 是 1 字节,所以 prevlen 就一个字节,值为 1。整个新 entry 是:1(prevlen)、2(encoding)、97 98(a 和 b)。内存里依次排出来就是 zlbytes、zltail、zllen、0xF5、0x01 0x02 0x61 0x62、0xFF。

你拿 xxd 去看 Redis 存储的小 list 键的内存,基本就是这种字节序列。我第一次亲手把字节流拆出来和源码里的逻辑一一对上时,才真正敢说自己“看懂 ziplist 了”。建议学习的时候也照着这个方法:构造一个小数据,手动算一遍字节布局,再结合源码验证,比反复读十遍代码都管用。

3. 核心操作源码逐行拆解

3.1 查找:运气好 O(1),运气差 O(n)

ziplist 没有索引,也没有跳表。想拿到第 N 个元素,只能从头部开始一个一个往后走。源码里对应的函数是 ziplistIndex,传入一个 index,正数从头部开始数,负数从尾部开始数,最后返回指向对应 entry 起始位置的指针。整个过程靠的是 p = zipEntry + prevlen + encoding 的实际长度这种迭代方式,走到第 N 步为止。

复杂度上,如果有 zltail 帮忙,取最后一个元素是 O(1),取第一个也是 O(1),中间的位置都是 O(n)。Redis 之所以敢在数据量小的时候用 ziplist,就是因为 n 很小,O(n) 的代价完全可以接受。但如果你把大量数据塞进一个 ziplist,这个查找就会变成灾难。这也是为什么 Redis 各个结构都有“超过阈值就转换”的机制,本质上是防止你把 O(n) 用成 O(100000)。

顺带一提,ziplistIndex 里有很多边界判断。index 传负数时,它会先从尾部往前跳过,然后判断是否越界。传正数时走正向循环,每步都要检查 p 是否已经等于 zlend,防止越界访问。读这段代码最容易踩的思维误区是:以为负数索引就是倒着数几个 entry,其实它是先在 offset 上做减法,再在前向遍历中判断是不是正好落在 entry 的起始位置。

3.2 插入:内存重新分配与 memmove 的账

插一个元素,源码逻辑整体是:先算出新 entry 要占多少字节,再算当前插入位置距离表尾还有多少字节,然后 realloc 扩展整个 ziplist,把插入位置之后的字节用 memmove 整体后移,空出来的地方写入新 entry,最后更新头部三个字段和受影响 entry 的 prevlen。

这里有一个关键点:每次插入,只要长度变了,几乎都要 realloc。realloc 在内存池紧张时可能触发数据拷贝,成本不低。所以 ziplist 在“频繁写、大写入”的场景下是非常惨的,这也是后来 Redis 开发了 quicklist 的根本原因。

另一个容易被忽略的细节是 memmove 和 memcpy 的区别:memmove 允许源和目标内存重叠,而 memcpy 不允许。ziplist 源码里用的是 memmove,因为后移数据时源区域和目标区域几乎必然重叠。如果你自己写类似代码,用 memcpy 就是 undefined behavior,轻则数据错乱,重则当场崩。

插入后,某些情况下不光要搬数据,还要改后面 entry 的 prevlen,这就是下一节说的级联更新。级联更新是 ziplist 最“臭名昭著”的成本黑洞,值得单独说。

3.3 删除与级联更新:ziplist 最大的坑

删除某个 entry 时,第一件事是把后面所有 entry 往前搬,缩容再 realloc。但麻烦的是,如果被删除 entry 很长,后面的第一个 entry 的 prevlen 原本可能只要 1 字节;删除后它紧邻的新前驱可能很长,prevlen 需要改成 5 字节。这一改,该 entry 整体长度变了,又影响它后面一个 entry 的 prevlen……就像多米诺骨牌一样一路改到表尾。

这个连锁反应在源码里叫 __ziplistCascadeUpdate,虽然它的名字里带 update,但几乎所有读源码的人都会把它和“连锁更新”划等号。最坏情况下,一次删除或插入可能触发 O(n²) 级别的数据搬移。当然,概率上要所有 entry 的长度都恰好处于 253/254 这个分界附近才会踩中,实际上很少见,但它确实存在。Redis 官方也因此建议:不要在 ziplist 中存放大量长度接近 254 字节的字符串。

级联更新这段代码值得反复读几遍。它不只是看几个 if 分支,更重要的是理解“prevlen 和 entry 长度互相耦合”这件事。任何结构如果出现了“字段长度依赖于相邻节点”,都可能存在类似的连锁更新问题。设计类似的存储结构时,要么把 prevlen 固定成 5 字节,彻底消灭连锁;要么兼容这种极端情况,但要有心理准备和性能兜底。

3.4 遍历:从表头走到表尾要经历什么

遍历就是 ziplistNext 和 ziplistPrev 两个函数的循环调用。正向遍历时,从当前 entry 起点,解析出 prevlen 和 encoding,算出总长度,指针跳到下一个 entry 起点;反向遍历时,直接读 prevlen,用当前地址减去它,回到前一个 entry。

这里最考验人的是编码判断。比如拿到一个 entry,怎么知道它到底占几字节?源码里有一堆 ZIP_DECODE_LENGTH、ZIP_DECODE_PREVLEN 之类的宏。它们本质上是对字节流做位运算:读高两位,判断是字符串还是整数,再进一步确定具体长度。这种代码看起来像天书,但一旦你拿真实字节流走一遍,后面再看任何解析逻辑都会很快。

遍历还有一个容易出错的地方是“空 ziplist”和“只有一个 entry”的特殊情况。空 ziplist 只有 header 和 zlend,没有 entry。ziplistNext 面对这种情况要能干净地返回 NULL,不能因为 0xFF 被误判成 entry 就造成死循环。源码里对 zlend 的判断放在每轮循环开头,正是为了防止这种问题。

4. ziplist 在 Redis 里到底怎么被用的

4.1 list / hash / zset 的编码转换条件

ziplist 不是一种对外暴露的数据类型,它只是内部编码。Redis 的 list、hash、zset 在数据量小、元素体积小的时候都会默认采用 ziplist 编码,等到超过阈值再转换为其他结构。

list 键的转换逻辑过去是用 ziplist 存所有元素,后来引入了 quicklist,quicklist 本质是“多个 ziplist 串成的双向链表”,既保住了 ziplist 的紧凑性,又解决了大列表下单块内存过大、realloc 成本过高的问题。每个 quicklist node 是一个 ziplist,每个 ziplist 默认最多放 8KB 左右数据(由 list-max-ziplist-size 等参数控制)。这个设计相当聪明:把一个巨型 ziplist 拆成多段,段内紧凑,段间用指针串起来。

hash 键默认在 field 数量少于 hash-max-ziplist-entries 且每个 field 和 value 的字节数都小于 hash-max-ziplist-value 时使用 ziplist,超过后转成 hashtable。注意这里的条件有两个:数量和大小,必须同时满足才继续用 ziplist,任何一个超过就转换。zset 同理,数量少于 zset-max-ziplist-entries 且每个 member 长度小于 zset-max-ziplist-value 时用 ziplist,超过后转 skiplist。

4.2 ziplist 的阈值参数怎么调

Redis 配置文件里常见的几个参数值得认真对待。list-max-ziplist-size 控制每个 quicklist node 里 ziplist 的最大长度,可取正负值:正值表示 entry 数量,负值表示字节数。比如 -2 表示每个 ziplist 最多 8KB(2 的 2 次方乘以 4KB 的基准值……官方默认就是这么算的)。如果你想减少链表节点数,可以把值调大,但单个 ziplist 过大又会带来 realloc 和级联更新的风险。

hash-max-ziplist-entries 和 hash-max-ziplist-value 的调参经验是:如果 hash 里的 field 数量稳定在几千、每个 value 都很小,可以适当调大 entries 阈值,让内存占用更低;如果某个 field 的 value 偶尔变得很大,建议调小 value 阈值,尽早转换成 hashtable,避免单次写入放大。

zset 的调参思路类似,但要注意 zset 的 skiplist 转换是单向不可逆的。数据量长上去之后,就算删掉很多元素,也不会再自动变回 ziplist。所以阈值设太大,意味着长期停留在低效区;设太小,又会过早抛弃 ziplist 的内存优势。这里我给一个个人实践:一般线上环境如果明确知道短结构是主流,可以保守地把 entries 设在 256 左右、value 设在 64 字节左右,然后观察内存和 CPU 的变化再微调。

4.3 ziplist 与 quicklist 的关系

很多人把 quicklist 和 ziplist 搞混。quicklist 的节点本身是一个 ziplist,所以在宏观层面它是“分段的 ziplist”。每个节点单独维护自己的内存块,互相独立;插入元素时,如果当前节点 ziplist 没满就直接插,满了就分裂成两个节点或者在节点间分配。这样一来,realloc 的代价被限制在每个节点内部,不会因为整个列表无限增长而出现一次拷贝几十 MB 的情况。

这套设计是典型的“权衡之后再权衡”:纯双向链表的指针开销大,纯 ziplist 的 realloc 代价高,quicklist 就把两者结合。读源码时,建议把 quicklist.c 打开,重点看 quicklistInsert 这个函数里“插入到当前节点”“插入到新节点”“节点分裂”三个分支。你会立刻意识到,任何一种所谓“最优结构”都是有适用边界的,工程实现永远是各种约束条件下的妥协产物。

5. 实战:哪些场景适合用 ziplist,哪些千万别用

5.1 适合场景

最适合 ziplist 的场景就是“少量元素 + 小长度数据”。典型如:一个 hash 存用户最近 20 条操作记录,field 是时间戳,value 是几字节的状态码;一个 zset 存某个排行榜前 50 名的分数和昵称;一个 list 做短消息队列,但长度稳定在几十条。这些场景下 ziplist 内存占用极低,缓存命中率极高,读写都在几微秒级别。

我实际做过一个统计型项目,用 hash 结构存每个用户过去 7 天的每日活跃标记,field 是日期字符串,value 是 0 或 1 的整数。单条 hash 很小,但用户量大,总量上亿。如果每个 hash 都用 hashtable,光指针开销就是天文数字;用 ziplist 之后,整个集群内存下降了近 30%,这个降幅在容量规划里非常可观。ziplist 在这种“海量小对象”场景里的价值,远超一般人的直觉。

5.2 不适合场景

不适合的场景也有明显特征。第一,元素数量大,比如 list 里塞几万个元素,ziplist 的插入删除会触发大量的 realloc 和 memmove,CPU 飙升,时延抖动剧烈。第二,value 长度分布不均,有些元素几字节,有些几十 KB,此时 ziplist 的 prevlen 编码会频繁处于两种模式切换的边缘,极端情况下甚至触发级联更新。第三,随机访问多,频繁按索引取第 N 个元素,这种需求应该交给真正支持索引的结构,别让 ziplist 干它不擅长的事。

还有一个常见误用是把它当成“压缩数组”无脑用。ziplist 不压缩数据,它只是压缩“元数据”。如果 value 本身是一个大 JSON,该用压缩算法的地方还是要用压缩算法,ziplist 帮你省的那点 entry 头字节和大 JSON 的体积比可以忽略不计。

5.3 踩坑记录:内存突增、慢查询、崩溃

我踩过的一个比较典型的坑是 hash 阈值设置过大。当时图省内存,把 hash-max-ziplist-entries 调到了 4096,结果某条 hash 链路上误入了几千个短 field,一开始没问题。后来运营批量导入了一批超长 value,单条 hash 的 ziplist 直接膨胀到几 MB,每条写入都触发整体 realloc,Redis 瞬间出现大量慢查询,CPU 被打满。

排查的时候我是用 debug object 命令看具体 key 的编码和序列化长度,一下子就定位到了那个超大的 ziplist。解决方案是先把阈值调回默认值,再用脚本把超大 key 分拆成多个小 hash。那次之后我学到一个教训:调阈值不是拍脑袋,一定要结合数据分布和数据增长趋势估算峰值。

还有一个崩溃案例是某团队在 Redis 里直接修改了 ziplist 占用的内存字节,用外部工具绕过协议操作 RDB 文件,结果加载时解析越界,进程直接 abort。这属于操作层面的问题,但根源是没有理解 ziplist 是带结构的内存块,不能当成普通字符串随意改。任何时候想做底层优化,先确认你能完整解析这块内存,否则别碰。

6. 读源码过程中的个人体会

ziplist 是我读 Redis 源码以来获益最大的一个结构,倒不是说它多复杂,而是它展示了一个非常完整的“取舍”案例:为了内存效率,它可以接受 O(n) 查找、接受 realloc、接受级联更新的极端风险,同时用一堆阈值参数把这些风险圈在可控范围内。

读它的时候我最大的感受是,工程上的数据结构很少是纯课本理论的最优解,更多是在“业务特征”和“硬件成本”之间反复权衡出来的。ziplist 不适合大数据量,但在小数据量场景下,它硬是凭一己之力帮 Redis 省下了不计其数的内存。这种“在合适的地方用合适的结构”的思路,比单纯背一堆数据结构的复杂度结论有价值得多。

如果你正在学 Redis 源码,我建议从 ziplist 入手。它不依赖复杂的并发模型,不需要理解事件循环,就是一块连续内存上的字节操作,能把 C 语言功底和“内存即数据结构”的意识练得很扎实。读的时候把 t_zset.c、t_hash.c、t_list.c 里调用 ziplist 的部分也一起看,你就能看到一个数据结构是怎么被上层业务接进去的,那个串联感会让整个 Redis 的知识体系一下子清晰起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询