我们平时写业务代码很少会去抠一个键在内存里的真实样子但一旦碰上内存暴涨、响应抖动又不得不回头去看底层结构。就拿 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字符串长度为 1encoding用 1 字节即可表示数据占 1 字节总共 3 字节。第二个 entry前一个 entry 长度是 3prevlen用 1 字节字符串长度为 2encoding1 字节数据 2 字节总共 4 字节。第三个 entry前一个 entry 长度是 4prevlen用 1 字节字符串长度为 4encoding1 字节数据 4 字节总共 6 字节。把头部 10 字节、三个 entry 的 346 字节、尾部 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就存一个字节0x61bc就存两个字节。读取时根据 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 环境里连续写入几个短字符串后通常能看到输出ziplistredis-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哈希使用压缩列表的最大字段数量128hash-max-ziplist-value哈希使用压缩列表的最大字段值长度64list-max-ziplist-size列表单节点最大长度与元素数量相关zset-max-ziplist-entries有序集合使用压缩列表的最大元素数128调参的核心原则是如果你的键长期处于写入频繁、偶发大元素的状态就适当调小阈值避免让压缩列表承担过大的更新压力。如果你的键是只读为主、追求极致内存占用可以适当调大阈值。需要特别注意的是压缩列表转换为常规结构是一次性的反过来却不能自动转回来所以阈值设置不宜过于激进。7. 常见问题与排查技巧实录7.1 问题速查表现象可能原因排查手段某列表写入慢元素量大时仍停留在压缩列表连锁更新被触发OBJECT ENCODING查看编码考虑调低list-max-*参数小键内存占用偏高结构已经升级为普通链表或哈希表DEBUG OBJECT查看serializedlength大量 key 执行RDB后体积偏大压缩列表元素过长压缩收益有限检查元素平均长度考虑拆分 key从库加载慢大压缩列表反序列化消耗 CPU评估是否需要提前转换或拆分大 key7.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 内存和性能问题会有很大的帮助。
阅读完成 · 觉得有帮助?