资讯详情

资讯详情

Redis ziplist 源码解析:内存紧凑链表设计与级联更新

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 的数量。这里有个细节如果元素数量超过 65535zllen 会被设为 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 字节第一个字节固定为 0xFE254后续 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 位是 11encoding 的剩余位用来区分整数的位宽和类型11000000 表示 int1611010000 表示 int3211100000 表示 int6411110000 表示 int2411111110 表示 int81111xxxx 表示 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前面只有 headerencoding 是 0xF51111 前缀加 0101data 不存在。所以这 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 是1prevlen、2encoding、97 98a 和 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 存所有元素后来引入了 quicklistquicklist 本质是“多个 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 最多 8KB2 的 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 和 memmoveCPU 飙升时延抖动剧烈。第二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每条写入都触发整体 reallocRedis 瞬间出现大量慢查询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 的知识体系一下子清晰起来。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →