☰
Redis ziplist深度解析:内存布局、连锁更新与listpack演进
2026/10/22 9:06:09 网站建设 项目流程

先抛个结论:在Redis里,凡是看到“小数据量”这三个字,内存优化这事儿基本都绕不开ziplist。它是list、hash、zset底层编码里那个默默扛内存的家伙,也是很多人读源码时读得最“肉疼”的一个结构——肉疼不是因为难,而是因为它在每一字节上精打细算的程度,比你自己写业务代码抠内存要狠得多。

今天这篇就围绕Redis源码里的这个专属“链表”,把它的内存布局、节点编码、连锁更新、继任者listpack全部拆开讲。不说废话,按我实际读源码和排查问题时的路径来。

1. 为什么一个链表要做得这么“抠”

1.1 双向链表在小数据场景的浪费

在引出ziplist之前,先看看它要解决什么问题。普通双向链表一个节点长什么样?至少两个指针:一个prev、一个next。在64位系统上这就是16字节,还没算数据本身。假如列表里每个元素都是一个3字节的小整数,那光指针开销就是数据的5倍还多。更别提每个节点单独malloc带来的内存碎片,以及节点与节点在内存里东一块西一块导致的缓存不友好。

Redis早期确实用过简单链表来实现list,但这个方案在小list上太亏了。于是作者(这里泛指Redis源码设计者)想了另一条路:与其用指针把分散的内存串起来,不如把数据一股脑塞进一块连续内存,然后通过“长度”而不是“指针”来定位下一个元素。这就是ziplist的核心哲学。

1.2 ziplist解决的两大痛点

ziplist本质是一块连续字节数组,它解决了两个痛点:

第一个是内存开销。它不需要为每个节点单独分配内存,节点边界完全靠编码长度推算,零指针零对齐填充。一个5个元素的list,如果用双向链表,光节点指针就是80字节;用ziplist,整块内存可能只要四十多个字节。

第二个问题是遍历性能。连续内存天然对CPU缓存友好,从头到尾遍历时,Cache Line命中率远高于跳来跳去的链表节点。配合Redis本身“小数据用ziplist、大数据用常规结构”的阈值设计,大多数热点key都能吃到这个红利。

注意:ziplist解决的场景是“小”。一旦数据量变大,插入删除导致的memmove成本就会盖过收益,所以Redis后续又在外面套了一层quicklist来做折中,这个后面细说。

2. ziplist整体布局:连续内存上的双向遍历

2.1 10字节头部与结尾标记

ziplist的header固定占用10字节,分成三段。直接看源码里的定义:

#define ZIPLIST_BYTES 0 /* zlbytes */ #define ZIPLIST_TAIL 4 /* zltail */ #define ZIPLIST_LEN 8 /* zllen */ #define ZIPLIST_HEADER_SIZE 10

对应到字节块上是这张表:

字段大小作用
zlbytes4字节整个ziplist占用的总字节数,包括头部、所有节点和结尾标记
zltail4字节尾节点相对于ziplist起始地址的偏移量,O(1)拿到尾节点
zllen2字节节点数量。当节点数超过65534时,此值固定为65535,实际数量需要遍历
entry1..entryN不定长真正存储数据的节点
zlend1字节固定0xFF,标识ziplist结束

也就是说,一个空ziplist最小就是10字节header加1字节zlend,一共11字节。这个结构有几个有意思的地方值得展开。

zlbytes为什么重要?因为ziplist的内存是动态分配的,扩容缩容都要知道当前总长度。如果不存这个值,每次realloc之前都得先遍历算长度,成本太高。有了zlbytes,realloc、memmove这些操作都能以常数时间拿到边界信息。

zltail的设计思路也很典型:为了让rpop这种从尾部弹出的操作达到O(1),直接把尾节点偏移量存起来。拿到尾节点后,再通过尾节点的prevlen字段往前跳,就能在双向遍历中自由移动。

zllen只有2字节,也就是说最多只能表示65535个节点。如果超过了,就把zllen置为65535,然后任何需要节点数的操作都改成遍历统计。这在绝大多数场景下够用——真到那个规模,ziplist本身也不适合继续用了。

2.2 为什么靠偏移就能双向遍历

ziplist没有传统链表那种显式“指针”,但它照样能从后往前遍历,靠的是每个节点里都存了prevlen——前一个节点的长度。

假设我在内存偏移量为X的地方拿到了当前节点,通过当前节点的prevlen字段,就能算出前一个节点的起始位置:X - prevlen。同理,从头部出发,通过每个节点自身的编码长度累加,就能得到下一个节点的位置。有prevlen就能后退,有自身长度就能前进,双向遍历就这么实现了。

这套设计在内存上极其紧凑:前一个节点长度小于254字节时,prevlen只占1字节;大于等于254时占5字节。后面第3章会详细说这里面的规则。

3. 每个entry的编码逻辑:把比特抠到极致

如果说ziplist整体框架是“省内存”,那entry的编码就是“抠字节”。一个entry在内存里长这样:

[prevlen] [encoding + len] [value]

prevlen解决定位问题,encoding决定后面的value怎么解释。下面逐个拆。

3.1 prevlen:1字节还是5字节

prevlen存储的是“前一个entry占用的总字节数”。为什么需要它?因为反向遍历时必须知道前一个节点有多长,才能用尾指针往回跳。

编码规则:

  • 如果前一个entry长度小于254字节,用1字节直接存长度。
  • 如果前一个entry长度大于等于254字节,用5字节:第一字节固定为0xFE,后面4字节存真正的长度(小端序)。

这里有个细节值得多说一句:254这个阈值不是随便定的。因为1字节的表示范围是0-255,但255(0xFF)已经被房子给占了——0xFF被用作ziplist的结尾标记zlend。而0xFE得留出来作为“后面还有4字节长度”的扩展标记。所以1字节版本的安全范围只能是0-253。这就是为什么一个节点长度一旦达到254或以上,prevlen就必须切到5字节编码,哪怕254本身只需要一个字节就能存。

结果就是,双链表里的指针开销(8字节),在这里被压到了1字节甚至5字节。

3.2 encoding里的三档字符串编码

encoding字段的首个字节,其高位bit决定了数据类型。对于字符串,Redis准备了三种长度档位,按实际字符串长度选择最省的一种:

编码前缀长度范围encoding占用存储方式
00xxxxxx0到63字节1字节低6位直接存长度
01xxxxxx xxxxxxxx0到16383字节2字节低14位存长度
10000000 加4字节0到4294967295字节5字节后面4字节存长度

举个例子:一个“hello”字符串长度是5,首个字节用00 000101这个模式,encoding只占1字节,紧接着就是5字节的ASCII数据。如果字符串有1000字符,则用第二种,encoding占2字节。总之就是“按需分配”,绝不多浪费一个字节。

3.3 整数编码的多种花样

上面的三档字符串编码只管字符串。Redis还发现一个规律:很多时候用户存的就是整数。如果老老实实把数字转成字符串存,往往要白白浪费空间,比如123456转成字符串要6字节,而用int32只要4字节。于是ziplist为整数设计了好几种专门编码:

编码字节含义数据大小
11000000 (0xC0)int162字节
11010000 (0xD0)int324字节
11100000 (0xE0)int648字节
11110000 (0xF0)int243字节
11111110 (0xFE)int81字节
1111xxxx (0xF1-0xFD)立即数0字节,值直接存在编码里

这里最有意思的是int24。Redis明明有int16和int32,为什么还要专门搞一个3字节的int24?原因很简单:有些数字超过int16范围(超过32767),但又没大到需要int32,比如一个40万左右的用户ID。用int32浪费1字节,用int16会溢出。所以加了一个int24,把3字节的空间也榨干。

3.4 立即数编码的一个好设计

再看表格里的最后一行:1111xxxx。它表示整数直接存在编码的后4位,value字段完全省略,也就是说一个entry只占prevlen+encoding这两部分。

但这里有个约束:后4位理论上可以表示0到15,可其中0000这个组合已经被int24的编码0xF0占用了。于是源码里采用了“存值加1”的策略:编码值0xF1表示整数0,0xF2表示1,一直到0xFD表示12。这样既避开了0xF0与int24的冲突,也不浪费码位。

提示:这里有一个容易读错源码的地方。ZIP_INT_IMM_MIN是0xF1而不是0xF0,网上很多文章直接拿“0xF0代表整数0”来说,其实0xF0是int24标记,不是立即数。

只是可惜0xFE被int8占用,0xFF是结束标记,所以立即数最多只能覆盖0到12这个区间。如果你往一个hash里塞了13个字段值,字段本身很小的话,第13个字段很可能就是用int8来存了。

4. 连锁更新:ziplist最著名的坑

4.1 一次插入引发的“多米诺”

每个entry的prevlen长度不是固定的,它取决于前一个entry的长度。这引出一个问题:如果在ziplist中间插入一个特别长的entry,导致它后面的那个entry,其prevlen需要从1字节变成5字节,那后面这个entry的整体长度就增加了4字节。

它一变长,更后面的entry的prevlen如果本来也卡在253字节附近,也会跟着从1字节变5字节。于是变化一路向后传导,像多米诺骨牌一样。这个过程在源码里由__ziplistCascadeUpdate函数处理,每次级联都涉及一次memmove搬移内存。最坏情况下,一次插入会引发N次搬移,整体复杂度是O(N^2)。

4.2 触发条件与概率

要触发连锁更新,需要满足一个很苛刻的前提:连续多个entry的长度都在253字节左右(准确的说是prevlen从1字节变5字节后,新长度依然会导致后续entry的prevlen膨胀)。

实际业务里,一个list或hash的节点如果纷纷卡在253字节这个诡异数值附近,概率非常低。在经典数据分布下,多数entry长度要么远小于253,要么远大于253,很少整齐划一地踩在临界点上。正因如此,Redis作者在设计时选择容忍这个风险:链表结构绝对不会有连锁更新问题,但ziplist用“大概率没事、小概率最坏O(N^2)”换来了极大的内存节省。

这也是一个典型的工程取舍:内存收益是普遍的,复杂度风险是罕见的。

4.3 源码中如何做级联更新

简单看一下__ziplistCascadeUpdate的处理逻辑。它从指定位置开始,循环做三件事:

  1. 解码当前entry,得到它的prevlen以及当前存储的前节点长度prevlensize。
  2. 判断prevlen需要占用的字节数prevlensize,如果和现在存储的prevlensize一致,直接退出循环(说明后面都不受影响)。
  3. 如果不同,则调整当前entry的大小,必要时realloc整块ziplist,然后memmove腾出空间,再写新的prevlen,继续处理下一个entry。

整个过程前后都有大量指针偏移计算,是最容易出现“差一个字节”bug的地方。读这段代码时建议配合纸上画内存图,否则容易绕晕。

注意:不要试图手动构造一个连锁更新的case去压测线上Redis。即使能触发,其代价也是大量CPU消耗在内存搬移上。真要研究,建议在本地用长度在253字节附近的字符串反复push构造复现,而不是在正式环境冒险。

5. 线上验证:用OBJECT ENCODING观察编码与切换

5.1 查看key当前的实际编码

源码层面的东西,不能光看不练。最简单的方式就是通过OBJECT ENCODING命令,直接查一个key当下用的什么底层编码:

redis-cli 127.0.0.1:6379> RPUSH mylist a b c (integer) 3 127.0.0.1:6379> OBJECT ENCODING mylist "quicklist" # 3.2以后list的底层统一是quicklist,内部节点可能是ziplist

hash上更明显,小hash直接就是ziplist:

127.0.0.1:6379> HSET user:1001 name alice age 30 (integer) 2 127.0.0.1:6379> OBJECT ENCODING user:1001 "ziplist" # 7.0之前 "listpack" # 7.0之后

如果你用的是Redis 7.0以上,会发现ziplist的名字正在被listpack取代。关于两者的区别,第6部分会专门说。但在6.0及以前,看到ziplist就说明这个hash是紧凑结构存储的。

5.2 配置阈值与升迁测试

每个底层编码都有切换阈值。以hash为例,在Redis 6.0里相关配置是:

127.0.0.1:6379> CONFIG GET hash-max-ziplist-entries 1) "hash-max-ziplist-entries" 2) "128" 127.0.0.1:6379> CONFIG GET hash-max-ziplist-value 1) "hash-max-ziplist-value" 2) "64"

含义是:hash的字段数不超过128,且每个字段名和值都不超过64字节时,用ziplist;一旦超过任一阈值,整个hash升级为hashtable。

可以做个小实验:造一个超过128个字段的hash,再查一下编码:

127.0.0.1:6379> EVAL "for i=1,200 do redis.call('HSET','big:hash','field'..i,'value'..i) end" 0 (nil) 127.0.0.1:6379> OBJECT ENCODING big:hash "hashtable"

从ziplist变成hashtable,内存会明显上涨。这也是很多老业务明明数据不多,内存却高得离谱的原因之一——可能你的某个hash字段数刚好卡在阈值边缘,反复横跳。

5.3 内存账怎么算

想量化ziplist省了多少内存,可以用DEBUG OBJECT看serializedlength字段:

127.0.0.1:6379> DEBUG OBJECT big:hash Value at:0x7f8b2d044020 refcount:1 encoding:hashtable serializedlength:1836 127.0.0.1:6379> DEBUG OBJECT user:1001 Value at:0x7f8b2d0440a0 refcount:1 encoding:ziplist serializedlength:30

serializedlength是对象做RDB序列化后的字节数,ziplist本身内存布局和序列化格式高度一致,所以它基本反映了真实内存占用。拿来横向对比同一份数据在ziplist和hashtable下的体积差异,非常直观。

提示:DEBUG OBJECT在Redis 7.0以后部分版本需要先打开debug模式,否则会报错。如果遇到DEBUG OBJECT不可用,直接用MEMORY USAGE key也能看,但MEMORY USAGE包含的是整体分配内存,粗粒度一点,对比趋势也够用。

6. 从ziplist到quicklist再到listpack

6.1 quicklist:把ziplist挂进双向链表

ziplist在小数据上很强,但一旦数据变大,中间插入的memmove成本太大。Redis 3.2之后,list的底层不再直接用ziplist,而是换成了quicklist。

quicklist的思路很直白:外层还是一个双向链表,但链表每个节点内部装的是一个小ziplist。这样既保留了ziplist的紧凑内存,又避免了一整条大链表频繁扩容搬移的问题。每个节点内的ziplist大小可以通过配置控制,比如list-max-ziplist-size限制每个节点最多放多少个entry。两端的数据读写频繁,还可以通过list-compress-depth做压缩,进一步省内存。

所以你在老版本Redis里看到某个list的OBJECT ENCODING是quicklist,它其实是一个“ziplist组成的双向链表”。

6.2 listpack:根除连锁更新

连锁更新问题的根源,是entry的布局里存了“前一个entry的长度”(prevlen),导致彼此强依赖。要根除,就得把这个依赖砍掉。

Redis 7.0引入的listpack(紧凑列表)就是干这个的。每个entry的布局变成了:

[encoding + data] [backlen]

entry末尾的backlen存的是“当前entry自身总长度”,而不是前一个entry的长度。反向遍历时,当前位置减去自己的backlen就能跳到上一个entry。因为backlen的大小只由自己的长度决定,别人的变化跟自己一点关系都没有,连锁更新从根上被消灭了。

代价是每个entry尾部都要多存backlen字段,但换来的是插入删除时的复杂度稳定性,这笔账在工程上很划算。7.0开始,hash和zset的小数据场景全面改用listpack,配置项也变成了hash-max-listpack-entries、zset-max-listpack-entries这类。

6.3 这些演进对日常开发意味着什么

如果业务还在用Redis 6.x,记住hash和zset的小数据用ziplist,遇到字段数接近阈值时密切关注内存。如果已经升到7.0以上,不用再担心中间插入带来的连锁更新,但依然要记住“紧凑编码只对小数据友好”这个原则。

还有一个旧配置兼容的问题值得注意:升级到7.0后,老的hash-max-ziplist-entries配置不会生效,而是被hash-max-listpack-entries取代。很多同学升级后发现自己原来调好的阈值失效了,内存飙高,多半就是没同步迁移这些配置项。

我个人在实际排查中遇到过几次类似的情况,最后都是用OBJECT ENCODING逐个key扫出来的。所以建议你在每次Redis大版本升级后,都把线上主要数据类型的OBJECT ENCODING分布统计一下,再对照内存变化,这样能第一时间发现底层编码切换带来的异常。读完源码再回来看线上问题,很多原来“玄学”一样的现象,其实都能落到字节级的设计上找到答案。

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

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

立即咨询