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)都带三个 "标签",整体结构和快递驿站的紧凑货架对应如下:
压缩列表整体结构 | 快递驿站对应场景 | 作用 |
---|---|---|
zlbytes(4 字节) | 货架总长度标签 | 记录整个压缩列表的内存长度,方便快速计算总空间 |
zltail(4 字节) | 最后一个货架的位置标签 | 记录最后一个节点的起始地址,支持从表尾往表头遍历(不用从头找) |
zllen(2 字节) | 货架总数标签 | 记录节点数量,快速知道有多少个元素(元素少的时候准确,多了会标记为 0xFF) |
entry1(节点 1) | 第一个货物 + 标签 | 实际存储的数据(短字符串 / 小整数) |
entry2(节点 2) | 第二个货物 + 标签 | 同上,紧挨着第一个节点 |
... | ... | ... |
zlend(1 字节) | 货架结束标记 | 标记压缩列表的末尾(固定为 0xFF) |
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"因地制宜" 设计哲学的又一个典型例子。