Redis 的压缩列表:像快递驿站 “紧凑货架“ 一样的内存优化结构
目录
一、先懂核心:为什么需要压缩列表?(对比之前的结构)
二、压缩列表的结构:像 \"贴标签的紧凑货架\"
2.1、关键:每个节点(entry)的三个 \"标签\"
1. previous_entry_length:前一个包裹的 \"长度标签\"(核心定位字段)
2. encoding:包裹的 \"内容类型 + 大小标签\"
3. content:包裹里的 \"实际货物\"
三、核心操作:从表尾往表头遍历(zltail+previous_entry_length)
四、重点难点:连锁更新
场景铺垫:一排 \"小个子货架\"
触发连锁更新:加一个 \"大个子货架\"
为什么不用怕连锁更新?(Redis 的权衡)
五、压缩列表的适用场景:什么时候用它?(对比其他结构)
六、总结:压缩列表的设计逻辑(衔接 Redis 整体思路)
如果说 SDS 是 \"智能价签\"、链表是 \"普通货架\"、跳跃表是 \"多层导航货架\",那压缩列表(ziplist) 就是 Redis 为 \"小批量货物\" 设计的紧凑折叠货架—— 专门用来存少量、小个子的商品(短字符串、小整数),目的是最大限度节省内存。它既是列表键的底层实现(比如少量元素的LPUSH
列表),也是哈希键的底层实现(比如少量键值对的HSET
哈希),核心优势就是 \"挤一挤,省空间\"。
一、先懂核心:为什么需要压缩列表?(对比之前的结构)
之前学的链表(list) 虽然灵活,但有个 \"内存浪费\" 的问题:每个链表节点(listNode)要存 prev、next 两个指针(至少 16 字节,64 位系统),哪怕节点只存一个 1 字节的字符,指针开销也比数据本身大。比如存 5 个 \"a\"、\"b\" 这样的短字符串,链表的指针总开销会是数据的好几倍。
而压缩列表的思路是:把所有节点挤在一块连续内存里,不存指针,改用 \"前节点长度\" 来定位—— 就像快递驿站的折叠货架,所有货物紧挨着放,不用每个货架单独装轮子(指针),而是靠 \"前一个货架多长\" 来找到下一个,省出大量空间。
二、压缩列表的结构:像 \"贴标签的紧凑货架\"
压缩列表是连续内存块组成的顺序结构,每个 \"货物\"(节点 entry)都带三个 \"标签\",整体结构和快递驿站的紧凑货架对应如下:
2.1、关键:每个节点(entry)的三个 \"标签\"
每个节点就像 \"贴了三张标签的小包裹\",结构是 previous_entry_length + encoding + content
,我们逐个对应到 \"包裹\" 上:
1. previous_entry_length:前一个包裹的 \"长度标签\"(核心定位字段)
- 作用:记录前一个节点的总长度(字节数),用来实现 \"从后往前找\"(表尾→表头遍历)。比如想从最后一个节点找倒数第二个,就用 \"当前节点起始地址 - 前节点长度\",就能准确定位。
- 空间优化:长度不是固定的,分两种情况(为了省内存):
- 前节点长度<254 字节:这个标签只占 1 字节(比如前节点长 10 字节,标签就存 0x0A)。
- 前节点长度≥254 字节:这个标签占 5 字节(第一字节固定为 0xFE,后面 4 字节存实际长度,比如前节点长 300 字节,标签就是 0xFE + 0x0000012C)。
类比:快递包裹的 \"前包裹长度\" 标签,小包裹用 1 格纸写长度,大包裹用 5 格纸,不浪费标签纸。
2. encoding:包裹的 \"内容类型 + 大小标签\"
- 作用:告诉 Redis 这个节点存的是 \"短字符串\" 还是 \"小整数\",以及内容的长度。
- 编码规则(简单记):
- 最高位是
00
、01
、10
:存的是字节数组(字符串,比如 \"abc\"),后面的位记录字符串长度。 - 最高位是
11
:存的是整数(比如 123),后面的位记录整数类型(16 位、32 位等)。
- 最高位是
类比:包裹的 \"内容说明\" 标签,写着 \"字符串・3 字节\" 或 \"整数・16 位\",方便快递员快速识别内容类型。
3. content:包裹里的 \"实际货物\"
- 作用:存储真实数据,内容由 encoding 决定:
- 如果是字符串:存的是 SDS 的底层字节数组(比如 \"abc\" 就存
0x61 0x62 0x63
,和 SDS 的 buf 字段一致,衔接之前的知识)。 - 如果是整数:存的是二进制整数(比如 123 就存
0x7B
,直接用二进制节省空间)。
- 如果是字符串:存的是 SDS 的底层字节数组(比如 \"abc\" 就存
类比:包裹里的实际物品,可能是小文件(字符串)或小零件(整数)。
三、核心操作:从表尾往表头遍历(zltail+previous_entry_length)
压缩列表支持双向遍历,其中 \"从后往前找\" 的逻辑特别依赖zltail
和previous_entry_length
,我们用快递驿站的场景拆解:
假设驿站有 3 个紧凑货架(节点 e1、e2、e3),要从最后一个 e3 找到第一个 e1:
- 定位最后一个货架:看总标签
zltail
,它记录了 e3 的起始地址(比如内存地址 0x100),直接找到 e3。 - 找倒数第二个 e2:看 e3 的
previous_entry_length
标签(假设是 5 字节,说明 e2 长 5 字节),用 e3 的起始地址(0x100)减去 5,得到 e2 的起始地址(0x0FB)。 - 找第一个 e1:看 e2 的
previous_entry_length
标签(假设是 3 字节),用 e2 的起始地址(0x0FB)减去 3,得到 e1 的起始地址(0x0F8)。 - 停止:e1 的
previous_entry_length
是 0(因为是第一个节点),遍历结束。
这个过程不用从头遍历所有节点,效率比链表的反向遍历(靠 prev 指针,其实效率差不多,但压缩列表省内存)更高,核心是zltail
和previous_entry_length
的配合。
四、重点难点:连锁更新
压缩列表为了省内存,把previous_entry_length
设计成 \"1 字节或 5 字节\",但这会带来一个极端情况 ——连锁更新,我们用 \"驿站加新货架\" 的场景讲明白:
场景铺垫:一排 \"小个子货架\"
假设驿站有 5 个连续的小货架 e1~e5,每个货架的长度都是 252 字节(<254),所以每个货架的previous_entry_length
标签都只占 1 字节(因为前货架长度<254)。此时每个货架的总长度 = 1(prev 标签)+ encoding(假设 1 字节)+ content(250 字节)=252 字节,完美符合 \"小个子\" 条件。
触发连锁更新:加一个 \"大个子货架\"
现在来了个新货架new
,长度 255 字节(≥254),要放在最前面(成为 e1 的前节点)。这时候问题来了:
-
第一步:e1 的标签不够用了
原本 e1 的previous_entry_length
是 1 字节(记录前节点长度<254),但现在前节点是new
(255 字节,≥254),1 字节存不下了,必须改成 5 字节的标签。
这会导致 e1 的总长度从 252 字节变成:5(新 prev 标签)+1(encoding)+250(content)=256 字节(≥254)。 -
第二步:e2 的标签也不够用了
e2 的previous_entry_length
原本是 1 字节(记录 e1 的 252 字节),现在 e1 变成 256 字节(≥254),1 字节也存不下了,必须改成 5 字节标签。
e2 的总长度也从 252 变成 256 字节(≥254)。 -
第三步:e3、e4、e5 连锁反应
同理,e3 的前节点 e2 变成 256 字节,e3 的标签也要改;e4 的前节点 e3 变,e4 改;直到 e5 改完后,下一个节点(如果有的话)的前节点长度还<254,连锁才会停止。
这个 \"一个改→个个改\" 的过程,就是连锁更新—— 像推倒多米诺骨牌,因为前一个节点的长度变化,导致后面所有节点的 \"前长度标签\" 都要调整。
为什么不用怕连锁更新?(Redis 的权衡)
虽然连锁更新听起来吓人,但实际中几乎不用担心里程碑,原因有二:
- 触发条件极端:必须满足 \"连续多个节点长度在 250~253 字节(改完标签后超 254)+ 加一个≥254 字节的头节点\",这种情况在实际业务中很少见(大部分场景下,压缩列表存的是更小的元素,比如几字节的字符串、两位数的整数)。
- 影响范围有限:就算触发,压缩列表本身存的是 \"少量元素\"(Redis 只用它存小数据量),最多改十几个节点,不会像字典 rehash 那样动则几万、几十万数据,对性能影响微乎其微。
类比:驿站的多米诺骨牌只有 5 张,就算倒了,捡起来也快;如果有 1000 张,才会麻烦,但驿站根本不会摆 1000 张紧凑货架(会换成普通货架,对应 Redis 会把压缩列表转成链表)。
五、压缩列表的适用场景:什么时候用它?(对比其他结构)
Redis 选择用压缩列表,遵循 \"小数据量用紧凑结构,大数据量用灵活结构\" 的原则,具体场景:
- 列表键:当列表元素数量少(默认<512 个),且每个元素是短字符串(默认<64 字节)或小整数时,用压缩列表;否则转成链表。
- 哈希键:当哈希键值对数量少(默认<512 个),且每个键和值都是短字符串(默认<64 字节)或小整数时,用压缩列表;否则转成字典。
类比:驿站的紧凑货架只放少量、小个子的快递;如果快递多了、大了,就换成普通货架(链表)或多层导航货架(跳跃表)。
六、总结:压缩列表的设计逻辑(衔接 Redis 整体思路)
压缩列表的所有设计,都是 Redis\"根据场景做取舍\" 的体现,和之前学的结构一脉相承:
- SDS:用 \"len+free\" 平衡字符串扩展和内存(取舍:预分配少量空间换扩展效率);
- 字典:用 \"双哈希表 + 渐进式 rehash\" 平衡查找效率和扩容性能(取舍:分多次迁移换服务不中断);
- 跳跃表:用 \"多层索引\" 平衡有序查找和实现复杂度(取舍:多存几层索引换比平衡树简单);
- 压缩列表:用 \"连续内存 + 可变长度标签\" 平衡内存占用和极端情况(取舍:接受罕见的连锁更新换极致省内存)。
简单说,压缩列表就是 Redis 为 \"小数据量、省内存\" 场景定制的 \"紧凑货架\"—— 它不追求万能,但在自己的专属场景里,内存效率远超链表和字典,是 Redis\"因地制宜\" 设计哲学的又一个典型例子。