news 2026/10/21 6:16:33

Redis压缩列表与listpack:底层内存优化与连锁更新机制解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis压缩列表与listpack:底层内存优化与连锁更新机制解析

我们平时写业务代码,很少会去抠一个键在内存里的真实样子,但一旦碰上内存暴涨、响应抖动,又不得不回头去看底层结构。就拿 Redis 里最常见的列表和哈希来说,你在使用的时候可能根本没意识到,当元素个数很少、单个元素又很小时,它们在底层并不是你想象中的双向链表或者哈希表,而是一块连续内存里紧挨着的“压缩列表”(ziplist)。这类结构平时默默无闻,却在大量小键场景下替我们省下了非常可观的内存。这篇文章不聊用法,直接把压缩列表拆开,讲清楚它的内存布局、节点编码方式、连锁更新的成因,以及为什么到了新版 Redis 里它又被 listpack 接替了。

先说适用范围。如果你维护过 Redis,遇到过大 key 告警,或者见过OBJECT ENCODING输出过ziplist,那就需要认真看这篇。如果你只是想了解 Redis 内部是怎么节省内存的,这篇文章同样能把一条完整的链路讲透。

1. 压缩列表的出场逻辑与整体设计

1.1 什么样的场景会走到压缩列表

Redis 里的列表键、哈希键、以及有序集合键,都支持在“小数据量”条件下使用压缩列表作为底层实现。触发条件并不是玄学,而是有一套明确的阈值参数在控制。

拿哈希键来说,当某个 key 的 field 数量少于hash-max-listpack-entries(历史上叫hash-max-ziplist-entries),并且每个 field 和 value 的长度都小于hash-max-listpack-value(历史上叫hash-max-ziplist-value)时,这个哈希键会以压缩列表的形式存储。列表键也是一样,当元素数量少于list-max-listpack-size且每个元素都不超过阈值长度时,会走压缩列表。有序集合则是在成员数量不多、且每个成员和分数都比较短时启用。

之所以要设这一层“小数据量”门槛,是因为压缩列表的存在意义就是解决内存开销问题。在元素数量很小的时候,用常规的双向链表或者哈希表,光节点指针和分配元数据都可能比实际数据还占地方。比如你有一个只有 10 个字符的列表,如果用双向链表去存,每个节点除了数据本身还要背上 prev 指针、next 指针、长度字段,加上每次分配内存都要有额外开销,最终内存使用可能是数据的十几倍。压缩列表把元素打包在一起,就能把这些结构性开销降下来。

1.2 压缩列表的整体设计思路

压缩列表的设计核心可以总结成三句话:内存连续、元素紧凑、以时间换空间。

所谓内存连续,是指整个压缩列表占用的是一段连续的内存地址。这样做的好处不仅仅是省空间,更重要的是 CPU 缓存友好。顺序访问一个压缩列表里的各个元素时,因为在内存上是挨着的,缓存命中率会明显好于跳来跳去的链表结构。

元素紧凑体现在“没有多余指针”。每个元素之间通过编码信息来区分边界,而不是靠显式的指针指向下一个节点。读取时从前向后扫描,只要能解析出当前节点的长度,自然就能找到下一个节点。

以时间换空间也很好理解:因为元素紧密排列,插入和删除元素时往往需要把后续节点的数据整体挪动位置,也就是一次memmove操作。元素少的时候,这种挪动成本完全可接受,但一旦元素多了,性能就会明显下滑。这也是为什么压缩列表只适用于“小数据量”的关键原因。

一句话概括:压缩列表是用“写路径上的低效”换“存储空间上的高效”,所以 Redis 只在数据量小的场景启用它。

2. 从内存布局角度解剖压缩列表

2.1 压缩列表的总体结构

我们可以把压缩列表看成一段二进制块,Redis 对这个块的头部和尾部都定义好了固定格式。整体结构分四个部分:列表头、列表尾偏移量、元素数量、以及一串紧挨着的 entry 节点,最后以一个特殊的结束标志收尾。

用表格列出各区域的作用:

区域长度作用
zlbytes4 字节记录整个压缩列表占用的总字节数,包括自身这 4 字节
zltail4 字节记录从列表起始位置到最后一个 entry 起始位置的偏移量
zllen2 字节记录 entry 节点数量,最大表示 65535,超出时需遍历统计
entry 1..N不定长实际存储每个元素
zlend1 字节固定为0xFF,标记列表末尾

这里有几个容易被忽略的细节。zltail的存在,是为了让“从尾部反向操作”变成可能。因为压缩列表本身是单向紧凑排列的,想从尾部往前操作,如果没有这个偏移量,就只能从头开始扫描,那倒序遍历的时间复杂度就会变成 O(N)。有了zltail后,定位最后一个元素是 O(1) 的,这也是压缩列表能高效支持RGET之类反向遍历的基础。

zllen只有 2 字节,这意味着它能表达的最大数量是 65535。那如果一个压缩列表里塞了超过 65535 个 entry 呢?此时zllen会被置成 65535,实际数量需要靠遍历统计。也就是说zllen字段在大多数场景下是可信的,但超过限制后它只是一个“超限标记”,不能再当真实数量用。

2.2 节点 entry 的内部构成

每个 entry 节点内部又分了三个子部分:prevlen、encoding、data。

prevlen记录的是前一个 entry 节点的总长度,作用是为了实现从后往前的遍历。因为当你站在某个 entry 上,知道前一个节点占了多少字节,就能用当前地址减去这个长度,精确回退到前一个节点的起始位置。

encoding记录的是当前节点数据类型和长度。它既告诉读取方“这段数据是字符串还是整数”,也告诉读取方“数据内容占了多少字节”。这是整个压缩列表解析的关键,因为数据区本身没有终结符,边界完全靠 encoding 来界定。

data则是真正的内容,可能是字符串的原始字节序列,也可能是整数的二进制补码表示。

所以入口处的结构非常像一条自制协议:每个节点自带“长度声明”,读的时候按长度跳跃,写的时候按长度对齐。这也是它能做到紧挨着存放、不需要额外指针的原因。

2.3 一个完整的例子:三个字符串怎么装进去

用一个最简单的例子来感受一下内存占用。假设一个列表里有三个元素,分别是字符串"a"、"bc"、"cdef",并且它们被打包进了一个压缩列表。

头部成本是固定的:zlbytes4 字节、zltail4 字节、zllen2 字节。尾部zlend固定 1 字节。

再看三个 entry:

第一个 entry 没有前驱,所以prevlen用 1 字节保存 0;字符串长度为 1,encoding用 1 字节即可表示;数据占 1 字节,总共 3 字节。

第二个 entry,前一个 entry 长度是 3,prevlen用 1 字节;字符串长度为 2,encoding1 字节;数据 2 字节,总共 4 字节。

第三个 entry,前一个 entry 长度是 4,prevlen用 1 字节;字符串长度为 4,encoding1 字节;数据 4 字节,总共 6 字节。

把头部 10 字节、三个 entry 的 3+4+6 字节、尾部 1 字节加起来,整个压缩列表一共 24 字节。如果换成双向链表,至少会再多个几十字节的结构开销。这也是为什么小数据量的键在 Redis 里能保持很低内存占用的原因。

3. entry 编码细节与读写原理

3.1 prevlen 的编码规则:253 是一个分界线

prevlen的编码规则值得单独拎出来讲,因为它有一个非常经典的分界点:253。

规则是这样的:如果前一个 entry 的总长度小于 254 字节,那么prevlen就只占用 1 字节,直接存长度值;如果前一个 entry 的总长度大于等于 254 字节,prevlen就占用 5 字节,其中第 1 个字节固定为0xFE,后面 4 字节以小端序存真实长度。

为什么分界线是 254,而不是 255?因为字节0xFF已经被用作整个压缩列表的结束标志zlend了。同时0xFE被用作 5 字节扩展格式的标记。换句话说,0xFE这个字节值本身不能单独作为合法长度出现,所以实际的单字节容量上限被压缩到了 253。如果前一个 entry 长度正好是 254,也必须走 5 字节的扩展格式。

这个设计带来一个直接后果:当某个 entry 变大,跨过 254 字节的阈值时,它后面的那个 entry 的prevlen就需要从 1 字节扩展成 5 字节,这又可能让那个 entry 的总长度超过 254,从而引发下一个 entry 的prevlen继续扩展。这也就是后面要说的连锁更新问题。

3.2 encoding 的编码:字符串与整数两大家族

entry 的 encoding 字段是典型的“位模式编码”,高位决定了数据的类型,低位承载了长度信息。

字符串编码有三档:

高位模式数据长度说明
00xxxxxx长度 0-63 字节6 位直接保存长度
01xxxxxx xxxxxxxx长度 0-16383 字节14 位保存长度
10000000+ 4 字节长度 0-2^32-132 位长度写在后 4 字节

整数编码也有多种:

编码值表示方式
11000000int16_t,数据区 2 字节
11010000int32_t,数据区 4 字节
11100000int64_t,数据区 8 字节
11110000int24_t,数据区 3 字节
11111110int8_t,数据区 1 字节
1111xxxx4 位无符号整数,表示 0 到 12

这里有个容易误解的点:1111xxxx一档是真正的“极致压缩”。因为 0 到 12 这些很小的整数在日常业务里出现频率很高,Redis 索性不额外存数据区,直接把数值编码进 encoding 本身。读取时拿到0xF1到0xFD之间的 encoding 值,取低 4 位再减 1 就能还原出数值。

那么为什么是 0 到 12 而不是 0 到 15?因为0xF0被用作 24 位整数的编码开头,0xFE是 8 位整数编码,0xFF又是列表结束符。所以1111这个前缀里,只剩0xF1到0xFD这 13 个值可用,对应 0 到 12。

3.3 数据区到底存的是什么

很多人以为压缩列表里的字符串就是普通字节数组,整数就是十进制文本,其实不完全是。

字符串类型的数据区就是实际的字节序列。比如"a"就存一个字节0x61,"bc"就存两个字节。读取时根据 encoding 里的长度信息,直接从数据区拷出对应字节即可。

整数类型的数据区则存的是二进制的补码表示。比如存一个 int16_t 的值,读取端拿到 2 字节后要按有符号整数解释。这里要特别注意大小端问题,Redis 在读取和写入时都显式地使用小端序,保证不同 CPU 架构下行为一致。

还有一种情况很有意思:当向压缩列表里写入一个可以被解析为整数的字符串时,Redis 会尝试“优化”。比如写入字符串"123",在满足条件时底层会把它以整数的形式存进去,而不是保存那三个 ASCII 字符。这样既省了空间,也加快了对该元素的比较操作。这个优化策略叫做“字符串转整数尝试”,是压缩列表内存经济型的重要一环。

4. 连锁更新:压缩列表最危险的一个问题

4.1 连锁更新是怎么发生的

连锁更新是压缩列表里最容易让开发者头疼的问题。官方注释里直接给它取了一个很形象的名字:cascade update,翻译过来就是级联更新或者连锁更新。

触发条件很隐蔽,但逻辑其实很直白。假设压缩列表里有连续的多个 entry,每一个 entry 的总长度都刚好小于 254 字节,那么它们的后继 entry 的prevlen都以 1 字节存储。这时候如果是开头插入一个特别大的 entry,长度跨过了 254 字节阈值,那第二个 entry 要记录的prevlen就需要从 1 字节扩成 5 字节,于是第二个 entry 的长度也跟着增加了 4 字节。如果第二个 entry 原本的长度在 250 到 253 之间,这一增加,它的总长度也跨过了 254,第三个 entry 的prevlen又得跟着扩展。这个效应就像多米诺骨牌一样传下去,直到某个 entry 扩展后仍然小于 254 才停止,或者一直传到最后。

删除节点也会触发同样的问题。如果删掉一个大节点,导致后面 entry 要记录的prevlen从 5 字节缩成 1 字节,前面依赖这个空间的长度信息也会变化,可能引发一连串的长度收缩。

4.2 触发条件与最坏情况

最坏情况下,连锁更新的时间复杂度是 O(N^2),因为每更新一个 entry 都可能触发内存重分配。但触发它需要满足非常苛刻的条件:连续多个 entry 的长度都落在 250 到 253 字节这个极窄区间里,且插入或删除的节点足够大,刚好让第一跳跨过阈值。

我在实际压测中模拟过类似场景:构造一批长度恰好为 253 字节的字符串,逐个塞进一个列表,然后在头部插入一个 1024 字节的大字符串。那一刻 Redis 确实需要连续扩展几十个 entry 的prevlen,单次插入的耗时比正常情况高一截。但从统计来看,这种结构在真实业务流量里很少天然出现,因为大部分业务写入的元素长度是杂乱的,连续几个节点都刚好卡在 250-253 这个区间里的概率极低。

注意:概率低不代表可以无视。如果业务里有批量写入长度相似的字符串,且元素数量特别大,压缩列表就会被拖累。这时候应该评估是否让这个 key 提前升级为常规结构。

4.3 现实中的规避策略

面对连锁更新,最直接的策略不是去修补它,而是“不让压缩列表承担大流量写入”。

list-max-ziplist-size这类参数的意义就在这里:当列表元素数量超过阈值时,Redis 会自动把底层结构从压缩列表转换为双向链表或另一种更合适的结构,从而避免长链表上频繁插入大元素带来的级联扩展代价。

还有一个常用的运维策略:限制单个元素的长度。如果你的应用写入列表的元素普遍比较长,建议直接调低相关阈值,让 Redis 尽早切换底层结构。这相当于用一点点内存换取稳定的写入性能,实际上很划算。

5. 压缩列表、listpack 与版本演进

5.1 为什么最后被 listpack 替代

压缩列表不是不好,但它有一个绕不开的心病:prevlen长度依赖前一个节点,导致连锁更新难以根除。虽然连锁更新在大多数场景下发生概率低,但从工程维护角度,它让代码复杂度和心智负担都很高。

所以 Redis 后续引入了 listpack,并在 7.0 版本开始逐步替代压缩列表的默认地位。listpack 的核心改进是“每个节点只保存自己的长度,不再保存前一个节点的长度”。区别就出在这里:没有prevlen,就从根本上杜绝了级联更新。因为任何节点的内容变化都不会影响其他节点的长度记录。

这个设计取舍非常典型:牺牲了一点反向遍历的能力,换取了写入路径的稳定性。压缩列表能够通过prevlen快速回退,而 listpack 把回退能力简化掉,换来更可预测的性能。

5.2 listpack 改了哪些核心设计

listpack 的头部比压缩列表更简单,只有一个总长度字段和一个节点计数字段,不再保存指向尾节点的偏移量。每个节点内部则变成了三个部分:encoding、data、backlen。

backlen 是本节点的总长度回退记录,它是用来支撑从后往前遍历的。与prevlen不同,backlen 只描述当前节点自身长度,不依赖任何前驱节点。这样即使某个节点长度发生变化,最多只影响自身,不会产生链式反应。

在版本演进上,从 Redis 7.0 开始,小列表、小哈希的新写入默认使用 listpack,老数据在修改时也会逐步转换。到了 7.2 以后,OBJECT ENCODING输出里你基本看不到ziplist了,取而代之的都是listpack。如果你还在维护 6.x 或更早版本,看到ziplist是正常的,理解压缩列表依然有用。

6. 实操观测:怎么确认和验证压缩列表

6.1 通过命令直接判断当前编码

想验证一个 key 当下是不是压缩列表,最简单的命令就是OBJECT ENCODING。

在 Redis 5.x/6.x 环境里,连续写入几个短字符串后,通常能看到输出ziplist:

redis-cli > RPUSH testkey a bc cdef (integer) 3 > OBJECT ENCODING testkey "ziplist"

在 Redis 7.x 及以上版本,同样的操作返回的是listpack:

> RPUSH testkey a bc cdef (integer) 3 > OBJECT ENCODING testkey "listpack"

如果想看这个 key 在内存里到底占了多少字节,可以用DEBUG OBJECT testkey,输出里的serializedlength字段就是序列化后的长度。注意serializedlength并不等于真实内存占用,但能反映内部结构的紧凑程度。当你发现一个小键的serializedlength明显大于数据本身时,通常说明它已经不在压缩结构里了。

6.2 源码视角:关键函数读一遍就懂

从源码角度,压缩列表的核心入口集中在src/ziplist.c中,虽然长,但主线函数并不多。

创建新列表走ziplistNew,插入节点时走ziplistInsert,追加节点时走ziplistPush。这两个写操作内部都会先计算新 entry 的长度和当前指针位置,然后调用memmove把后续数据整体后移,再更新头部元数据。

更新过程中有一个很关键的内部函数叫__ziplistCascadeUpdate,它就是处理连锁更新的核心。它从插入点开始向后扫描,判断每个 entry 的prevlen是否需要从 1 字节扩展成 5 字节,必要时重新分配内存并继续。当你读源码时,只要看到这个函数,就能确认连锁更新不是传说,而是实打实的工程处理逻辑。

ziplistNext、ziplistPrev是遍历函数,分别负责从前往后和从后往前移动。ziplistPrev之所以能工作,靠的就是 entry 里保存的prevlen。这也是为什么压缩列表虽然紧凑,但依然支持双向遍历的原因。

6.3 参数怎么调才是合理的

对于想在线上调优的同学,建议先看这几个参数:

参数名(以低版本为例)作用常见设置
hash-max-ziplist-entries哈希使用压缩列表的最大字段数量128
hash-max-ziplist-value哈希使用压缩列表的最大字段值长度64
list-max-ziplist-size列表单节点最大长度与元素数量相关
zset-max-ziplist-entries有序集合使用压缩列表的最大元素数128

调参的核心原则是:如果你的键长期处于写入频繁、偶发大元素的状态,就适当调小阈值,避免让压缩列表承担过大的更新压力。如果你的键是只读为主、追求极致内存占用,可以适当调大阈值。需要特别注意的是,压缩列表转换为常规结构是一次性的,反过来却不能自动转回来,所以阈值设置不宜过于激进。

7. 常见问题与排查技巧实录

7.1 问题速查表

现象可能原因排查手段
某列表写入慢元素量大时仍停留在压缩列表,连锁更新被触发OBJECT ENCODING查看编码,考虑调低list-max-*参数
小键内存占用偏高结构已经升级为普通链表或哈希表DEBUG OBJECT查看serializedlength
大量 key 执行RDB后体积偏大压缩列表元素过长,压缩收益有限检查元素平均长度,考虑拆分 key
从库加载慢大压缩列表反序列化消耗 CPU评估是否需要提前转换或拆分大 key

7.2 调参过程中的一个典型坑

有一次我在某个项目里调大hash-max-ziplist-entries,目的是让更多小哈希键继续保持压缩编码,节省内存。结果上线后发现部分高频写入的哈希键出现了偶发延迟。查下来发现这些 key 的 field 数量虽然没有超过阈值,但其中个别 value 的长度超过了hash-max-ziplist-value,导致部分节点需要频繁扩展prevlen,每次扩展都触发内存重分配。

那次经验给我的教训是:调这类参数不能只看 entries,还要结合 value 长度分布。必须两个条件同时满足,压缩列表才会被启用;只要有一个条件不满足,Redis 就会退而求其次,选择常规结构。你单独调大某一个阈值,可能根本没有达到预期效果。

7.3 排查时容易被忽略的细节

排查压缩列表问题,有一个容易被忽略的点:它并不只出现在 list 类型里。哈希类型和有序集合类型在低版本 Redis 里同样可能使用压缩列表,但很多人排查内存问题时只盯着 list 键看,漏掉了 hash 和 zset 类型的大头。

另外要注意版本差异。如果你看到群里分享的案例还在讨论ziplist,先确认对方是不是 7.0 之前的老版本。在 7.0 以后讨论默认编码时,很多场景已经换成了listpack。虽然两者的设计思路一脉相承,但排查思路不能混着用。尤其是在线上升级的场景,老版本里压缩列表造成的连锁更新问题,在新版本里可能已经不存在了。

根据我个人的排查经验,压缩列表相关的性能问题大多数不是结构本身造成的,而是“阈值配置与实际写入模式不匹配”造成的。最值得做的事,其实是定期抽查线上 key 的编码类型和元素长度分布,在问题发生前就让阈值与业务特征对齐。这个结构看着不起眼,但把它理解透了,对排查 Redis 内存和性能问题会有很大的帮助。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 6:16:22

Windows性能数据采集:PDH API从入门到实战

做 Windows 上的性能采集,绕不开 PDH 这个词。我最早认真研究 PDH(Performance Data Helper)是在写一个轻量监控客户端的时候:每隔一两秒要从本机甚至远程机器上拿 CPU、内存、进程级 CPU 占用,然后推给中心端汇总。一…

作者头像 李华
网站建设 2026/10/21 6:15:51

Fiddler Script 实战:拦截修改手游 WebSocket 封包与二进制协议

做移动端网络联调的时候,HTTP层面的抓包几乎算不上难题:给手机挂一个代理,打开Fiddler,所有请求响应的结构、参数、状态码都能看得一清二楚。可一旦游戏切换到了WebSocket长连接,很多人的工具链就当场失灵。原因其实不…

作者头像 李华
网站建设 2026/10/21 6:15:43

OpenClaw 5分钟极简安装指南:从环境准备到实战应用

OpenClaw 这个名字,最近在不少开发者群里出现的频率明显高了不少。我最初注意到它,是因为一个朋友团队的自动化工作流项目,跑得比预期顺,问了一圈才发现底层调度用的就是 OpenClaw。自己动手装了一遍之后,说实话&#…

作者头像 李华
网站建设 2026/10/21 6:15:39

《驾驭你的AI同事:WorkBuddy深度精通》046:连接器架构专题

《驾驭你的AI同事:WorkBuddy深度精通》046:连接器(Connector)架构专题 本文是《唤醒你的 AI 同事:WorkBuddy 全栈指南》系列 卷二(深度精通) 的第 46 篇。卷一讲过"连接器怎么用",本篇换个角度:连接器在架构里到底处于哪一层、它和 MCP 与技能的分工是什么、…

作者头像 李华
网站建设 2026/10/21 6:14:52

广义回归神经网络GRNN:小样本疾病诊断分类的实用利器

我最早接触广义回归神经网络(GRNN)这个模型,是有一次在做医学检查数据的疾病分类任务时,用BP神经网络反复调参调到怀疑人生。隐藏层节点数怎么定、学习率设多少、迭代多少次,各种组合试了一圈,效果始终不够…

作者头像 李华
网站建设 2026/10/21 6:14:30

MySQL 8.0 JDBC驱动Jar包实战:下载、配置与排错指南

“MySQL 8.0版本JDBC驱动Jar包”,这听起来像是一个非常小的技术问题,但实际情况是,我亲眼见过不止一个项目在数据库从5.7升级到8.0之后,应用层迟迟起不来,最后定位到的原因就是这个驱动Jar包没有换对。更夸张的是&…

作者头像 李华