11 KiB
tags, create time, author
| tags | create time | author | ||||
|---|---|---|---|---|---|---|
|
2026-05-25 10:30 | hhs |
ziplist 与 listpack
概述
ziplist 是 Redis 为节省内存设计的一种紧凑型线性结构——把多个元素连续排列在一块内存里,省掉链表的指针开销。它是 Hash、List(quicklist 节点)、ZSet 在小数据量时的默认编码。
但 ziplist 有一个臭名昭著的缺陷:级联更新(cascade update)。Redis 7.2 引入 listpack 彻底解决了这个问题。
一、ziplist 的内存布局
[!question] 为什么要连续内存? 传统链表每个节点独立分配内存,额外的
next/prev指针各占 8 字节(64 位系统)。存 100 个短字符串,指针开销可能比数据本身还大。ziplist 的思路是:把所有数据塞进一块连续内存,用紧凑的 header 描述每个元素的位置。
一个 ziplist 在内存中长这样:
flowchart LR
ZB["zlbytes, 4B, 整体字节数"] --> ZT["zltail, 4B, 尾节点偏移"]
ZT --> ZL["zllen, 2B, 节点数量"]
ZL --> E1["entry 1"]
E1 --> E2["entry 2"]
E2 --> EN["entry N"]
EN --> ZE["zlend, 0xFF, 结束标记"]
classDef header fill:#e1f5fe,stroke:#2196f3
classDef entry fill:#fff3e0,stroke:#ff9800
classDef tail fill:#fce4ec,stroke:#e91e63
class ZB,ZT,ZL header
class E1,E2,EN entry
class ZE tail
| 字段 | 大小 | 作用 |
|---|---|---|
zlbytes |
4 字节 | 整个 ziplist 占用的总字节数(含自身) |
zltail |
4 字节 | 最后一个 entry 的偏移量(支持从尾部快速遍历) |
zllen |
2 字节 | entry 节点数量(超过 65535 时需遍历计数) |
entry |
变长 | 实际数据,逐个紧密排列 |
zlend |
1 字节 | 固定值 0xFF,标记结束 |
二、entry 的内部结构
[!question] 既然没有指针,怎么知道每个 entry 的边界? 答案是:每个 entry 用
prevlen+encoding告诉 Redis "上一个元素多长"和"我自己是什么类型、多长"。解析时从头往后依次读,就知道每个 entry 在哪里。
每个 entry 由三部分组成:
flowchart LR
PL["prevlen, 1 或 5B"] --> EN["encoding, 1, 2 或 5B"]
EN --> DA["data, 实际内容"]
classDef field fill:#e8f5e9,stroke:#4caf50
class PL,EN,DA field
| 字段 | 说明 |
|---|---|
prevlen |
前一个 entry 的长度。≤ 253 字节用 1 字节存;> 253 字节用 5 字节存(第 1 字节标记 0xFE,后 4 字节存长度) |
encoding |
标识 data 的类型和长度——整数还是字符串?多长? |
data |
实际存储的内容 |
encoding 的 bit 编码
[!question] encoding 字段是怎么用 1~5 个字节同时表达"类型"和"长度"的? 核心思路:用高位 bit 组合区分类型,剩余 bit 存长度或数值。这让 Redis 解析时只需读第一个字节就能判断后续格式。
字符串类型——高 2 位为 00/01/10:
| 编码格式 | 长度 bit 数 | 最大长度 |
|---|---|---|
00xxxxxx |
6 bit | 63 字节 |
01xxxxxx xxxxxxxx |
14 bit | 16383 字节 |
10xxxxxx + 4 字节 |
32 bit | 2³²-1 字节 |
整数类型——高 4 位为 11xx:
| 编码 | 含义 |
|---|---|
11000000 |
int16,后跟 2 字节有符号整数 |
11010000 |
int32,后跟 4 字节有符号整数 |
11010001 |
int64,后跟 8 字节有符号整数 |
11110001 |
int24,后跟 3 字节有符号整数 |
1111xxxx (0001~1101) |
立即数 0~12,data 部分为空 |
[!tip] 为什么存整数不直接用字符串? 把
"12345"当字符串存需要 5 字节 + encoding;转成 int16 只要 2 字节 + 1 字节 encoding。小整数的紧凑编码是 ziplist 省内存的另一大功臣。
[!warning] prevlen 是级联更新的根源 注意到没有?
prevlen只有 1 字节(≤ 253)或 5 字节(> 253)两种取值。当一个 entry 从 ≤ 253 变成 > 253 时,它的下一个 entry 的prevlen必须从 1 字节扩展到 5 字节——而这可能导致更后面的 entry 也跟着扩展,形成连锁反应。
三、级联更新(Cascade Update)
这是 ziplist 最大的性能隐患。我们用一个例子说明:
触发条件
- 插入一个新 entry(或现有 entry 增长),导致下一个 entry 的
prevlen从 1 字节扩展到 5 字节 - 这 4 字节的膨胀可能让该 entry 总长超过 253,从而导致再下一个 entry 的
prevlen也要扩展 - 如此链式传播,最坏情况下所有后续 entry 都要重新分配
最坏情况复杂度
[!danger] O(N²) 的代价 假设 ziplist 有 N 个 entry,每个都刚好在 253 字节边界。一次插入触发 N 次
memmove,每次 O(N)——总计 O(N²)。实际中很少触发最坏情况(需要连续的 253 边界 entry),但只要 ziplist 够大,风险就不可忽视。这就是为什么 Redis 给 ziplist 设了阈值——超过就切到更安全的结构。
代码验证
rdb.Del(ctx, "test:ziplist")
// 1. 小 Hash → ziplist/listpack 编码,内存紧凑
for i := 0; i < 100; i++ {
rdb.HSet(ctx, "test:ziplist", fmt.Sprintf("f%d", i), "short")
}
enc, _ := rdb.ObjectEncoding(ctx, "test:ziplist").Result()
mem1, _ := rdb.MemoryUsage(ctx, "test:ziplist").Result()
fmt.Printf("编码: %s, 内存: %d bytes\n", enc, mem1)
// Redis 6: encoding=ziplist, Redis 7+: encoding=listpack
// 2. 加入一个超长 value → 触发编码切换到 hashtable
rdb.HSet(ctx, "test:ziplist", "big_field", strings.Repeat("x", 65))
enc2, _ := rdb.ObjectEncoding(ctx, "test:ziplist").Result()
mem2, _ := rdb.MemoryUsage(ctx, "test:ziplist").Result()
fmt.Printf("编码: %s, 内存: %d bytes\n", enc2, mem2)
// enc2=hashtable, 内存明显增大——65 > hash-max-ziplist-value(64)
[!question] 如何观察级联更新的延迟影响? 级联更新是内部实现,客户端无法直接观察。但你可以间接感知:当 ziplist 编码的 Hash/ZSet 接近阈值时,
HSET/ZADD的 p99 延迟可能出现毛刺——因为某次写入恰好触发了多级prevlen扩展。这也是 Redis 设置阈值提前切换的原因之一。在 Redis 7+ 使用 listpack 后,这类毛刺基本消失。
四、listpack:ziplist 的继任者
[!question] 如果没有 prevlen,怎么知道前一个 entry 的边界? listpack 的答案是:不需要知道前一个 entry 的长度。每个 entry 末尾存一个
element-total-len(简称 backlen),记录自己的总长度。正向遍历时,读完当前 entry 的 encoding + data,再读 backlen 跳到下一个 entry;修改任意 entry 只影响它自己的 backlen,不波及邻居。
listpack 的整体布局
flowchart LR
LT["total-bytes, 4B"] --> LN["num-elements, 2B"]
LN --> E1["entry 1"]
E1 --> E2["entry 2"]
E2 --> EN["entry N"]
EN --> EOL["EOF, 0xFF"]
classDef header fill:#e1f5fe,stroke:#2196f3
classDef entry fill:#e8f5e9,stroke:#4caf50
classDef tail fill:#fce4ec,stroke:#e91e63
class LT,LN header
class E1,E2,EN entry
class EOL tail
和 ziplist 的整体结构几乎一致(total-bytes / num-elements / EOF),差别全在 entry 内部。
entry 内部:backlen 的变长编码
flowchart LR
EN["encoding"] --> DA["data"]
DA --> BL["backlen, 1~5B, 自己的总长度"]
classDef field fill:#e8f5e9,stroke:#4caf50
class EN,DA,BL field
backlen 采用类似 protobuf varint 的变长编码——高位 bit 为 1 表示后续字节仍属于 backlen,为 0 则结束。大多数 entry 总长 ≤ 127 字节,backlen 只需 1 字节,和 ziplist 的 1 字节 prevlen 一样紧凑。
[!tip] backlen 为什么能消灭级联更新? ziplist 中 A 增长 → B 的
prevlen膨胀 → B 总长变化 → C 的prevlen也得改……链式传播。listpack 中 A 增长 → A 的
backlen可能变大,但 B 的backlen只记录 B 自己的长度,和 A 无关。B 不需要做任何修改,C 也不需要。传播链在 A 处就断了。
Redis 7.0+,Hash、ZSet、Stream、List(quicklist 节点)的紧凑编码全部切换为 listpack。
核心区别
flowchart TB
subgraph ziplist_entry["ziplist entry"]
direction LR
ZP["prevlen, 记录前一个 entry 长度"]
ZE["encoding"]
ZD["data"]
end
subgraph listpack_entry["listpack entry"]
direction LR
LP["encoding"]
LD["data"]
LE["element-total-len, 记录自己总长"]
end
classDef old fill:#ffebee,stroke:#f44336
classDef new fill:#e8f5e9,stroke:#4caf50
class ZP,ZE,ZD old
class LP,LD,LE new
| 对比 | ziplist | listpack |
|---|---|---|
| entry 间依赖 | prevlen 引用前一个 entry |
无,各 entry 独立 |
| 级联更新 | 会触发 | 不会触发 |
| 空间开销 | prevlen 1~5 字节 |
element-total-len 1~5 字节(相当) |
| 倒序遍历 | 依赖 prevlen 逐个跳 |
从尾部向前,读 element-total-len 跳 |
| 引入版本 | Redis 早期 | Redis 3.2 (实验), 7.2 (正式替代) |
[!tip] 你现在不需要关心版本差异 理解"listpack = 无级联更新的 ziplist"就够了。Redis 的配置参数名(如
hash-max-ziplist-entries)在 7.2+ 仍然生效,只是底层实现换成了 listpack。
五、为什么 Redis 还保留阈值切换?
[!insight] 紧凑结构不是万能的 ziplist/listpack 的查找是 O(N)——要从头遍历到目标 entry。当元素少时,这块连续内存对 CPU cache 非常友好,遍历也很快。但元素一多,O(N) 的代价就压不住了,必须切到 hashtable(O(1))或 skiplist(O(logN))。
各类型的切换阈值——阈值内用紧凑编码省内存,超过阈值切到通用结构保性能:
| 数据类型 | 阈值内编码 | 配置项 | 默认值 | 超过阈值切到 |
|---|---|---|---|---|
| Hash | listpack ¹ | hash-max-ziplist-entries |
512 个 field | dict(hashtable) |
| Hash | listpack ¹ | hash-max-ziplist-value |
64 字节 | dict(hashtable) |
| ZSet | listpack ¹ | zset-max-ziplist-entries |
128 个 member | skiplist + dict |
| ZSet | listpack ¹ | zset-max-ziplist-value |
64 字节 | skiplist + dict |
| List | quicklist 节点内的 listpack | list-max-ziplist-size ² |
-2(每节点 8KB) | quicklist 新节点 |
¹ Redis 6 为 ziplist,7.0+ 为 listpack。配置参数名不变。
² Redis 7.2+ 配置项更名为 list-max-listpack-size。
[!question] 切换后各自长什么样?
- Hash:从一块连续内存(listpack)变成 dict——两张 hashtable 交替 rehash,查找从 O(N) → O(1)
- ZSet:从一块连续内存(listpack)变成 skiplist + dict 的双结构——skiplist 负责有序遍历和范围查询 O(logN),dict 负责按 member 查 score O(1)
- List:不是"丢弃 listpack",而是 quicklist 继续以 listpack 为节点内容,只是单个节点的容量上限受此阈值控制;超限时 quicklist 不会转换编码,而是把新元素放进一个新的 listpack 节点
关联笔记
- hhs/Redis/02-核心数据类型 — 五种数据类型的编码切换全景
- hhs/Redis/02-核心数据类型/02-2-skiplist — ZSet 的另一种核心结构
- hhs/Redis/08-SortedSet精解 — ZSet 的高级用法与内存优化