一、前言:为了省下每一字节

在 Redis 的世界里,“内存就是金钱”。对于存储大量小对象的场景(如用户标签、会话信息),即使是微小的内存浪费,乘以海量数据后也会成为天文数字。

为此,Redis 在早期版本中引入了一种名为 ZipList(压缩列表) 的精巧数据结构。它并非使用传统压缩算法,而是通过极致紧凑的内存布局,将空间利用率推向了极致。

💡 核心价值
ZipList 是 Redis 在“小而美”场景下的内存杀手锏,可节省高达 50% 以上的内存

本文将带你:

  • 拆解 ZipList 的连续内存布局
  • 揭秘其如何同时拥有数组和链表的优点
  • 警示“连锁更新”这一致命缺陷

二、ZipList 是什么?连续内存中的双向链表

2.1 核心思想

ZipList 本质上是一个特殊编码的双向链表,但它不使用指针,而是将所有数据存储在一块连续的内存区域中。

✅ 关键特性

  • 内存连续:像数组一样,无内存碎片。
  • 双向遍历:像链表一样,支持从头到尾和从尾到头。
  • 变长节点:每个节点(entry)可以存储不同长度的字符串或整数。

2.2 内存布局详解

一个完整的 ZipList 在内存中的布局如下:

+---------+--------+--------+------------------+------------------+------------------+-------+
| zlbytes | zltail | zllen  | entry1           | entry2           | ...              | zlend |
+---------+--------+--------+------------------+------------------+------------------+-------+
<-- 4B --> <-- 4B -> <-- 2B -> <---- 变长 -----> <---- 变长 -----> <---- 变长 -----> <--1B-->
头部元数据(Header)
  • zlbytes (4字节):整个 ZipList 占用的总字节数(包括 header 和 zlend)。
  • zltail (4字节):最后一个 entry 距离起始地址的偏移量。这使得 ZLTAIL(获取尾部元素)操作能在 O(1) 时间内完成。
  • zllen (2字节):ZipList 中包含的 entry 数量
    • 注意:由于只有 2 字节,当元素数量超过 UINT16_MAX (65535) 时,此字段不再准确,需要遍历计算。
Entry 节点(变长)

每个 entry 由三部分组成:

+----------+----------+---------+
| prevlen  | encoding | content |
+----------+----------+---------+
  • prevlen前一个 entry 的长度(单位:字节)。
    • 若前一个 entry 长度 < 254 字节,则用 1 字节 存储。
    • 否则,用 5 字节 存储(第1字节为 0xFE,后4字节为真实长度)。
  • encoding当前 entry 的内容类型和长度
    • 可以表示字符串(不同长度前缀)或整数(多种编码,如 int16, int32 等)。
  • content实际存储的数据
结束标记
  • zlend (1字节):固定值 0xFF,标识 ZipList 结束。

三、ZipList 的双面性:优势与致命缺陷

3.1 优势:博采众长

  • 继承数组的优点
    • 内存局部性好:连续内存,CPU 缓存命中率高。
    • 极致的空间效率:无指针开销,元数据极小。
  • 继承链表的优点
    • 双向遍历:通过 prevlen 可以轻松向前回溯。
    • 变长存储:灵活适应不同大小的数据。

3.2 致命缺陷:连锁更新(Cascade Update)

这是 ZipList 最著名的痛点

问题根源prevlen 字段的变长设计

场景

  1. 假设有一系列 entry,每个 entry 的长度都是 253 字节
  2. 此时,每个 entry 的 prevlen 字段都只需要 1 字节(因为 253 < 254)。
  3. 现在,在列表头部插入一个 长度为 254 字节 的新 entry。
  4. 第二个 entry 的 prevlen 需要从 1 字节 扩展到 5 字节
  5. 这导致第二个 entry 自身的总长度变成了 253 + 4 = 257 字节。
  6. 于是,第三个 entry 的 prevlen 也需要从 1 字节扩展到 5 字节...
  7. 雪崩效应:整个列表的后续所有 entry 都需要更新 prevlen 字段!

⚠️ 后果:一次 O(1) 的插入操作,可能退化为 O(n^2) 的灾难!虽然平均情况很少发生,但一旦触发,对性能是毁灭性的。


四、ZipList 在 Redis 中的应用与配置

4.1 应用场景(Redis 7.0 之前)

ZipList 曾是以下数据类型的默认底层编码(当数据量小时):

  • Listlist-max-ziplist-size(默认 -2,表示最多 8KB)
  • Hashhash-max-ziplist-entries(默认 512)和 hash-max-ziplist-value(默认 64 字节)
  • ZSetzset-max-ziplist-entries(默认 128)和 zset-max-ziplist-value(默认 64 字节)

4.2 版本演进:从 ZipList 到 ListPack

由于“连锁更新”问题无法根治,Redis 社区最终决定废弃 ZipList

  • Redis 3.2:引入 quicklist 作为 List 的底层,它是一个 ZipList 的双向链表,限制了单个 ZipList 的大小,从而将连锁更新的影响控制在局部。
  • Redis 7.0彻底移除 ZipList,引入了全新的 listpack 结构。
    • ListPack 的改进:每个 entry 只记录自身长度,不再记录前一个 entry 的长度,从根本上杜绝了连锁更新!

📌 现状:如果你使用的是 Redis 7.0+,那么 Hash 和 ZSet 的紧凑编码已经变成了 listpack,而 List 的底层是 quicklist(内部节点是 listpack)。


五、动手实验:观察 ZipList 的行为(Redis < 7.0)

5.1 查看 Hash 的底层编码

# 创建一个小 Hash
> HSET smallhash name "Alice" age "25"
(integer) 2

# 查看编码(应为 ziplist)
> OBJECT ENCODING smallhash
"ziplist"

# 插入一个超长的 value,使其转为 Dict
> HSET smallhash long_field "A...A" # (65个'A')
(integer) 1

# 再次查看(应变为 hashtable)
> OBJECT ENCODING smallhash
"hashtable"

六、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐