Files
cs-note/hhs/Redis/02-核心数据类型/02-1-ziplist与listpack.md
T

249 lines
11 KiB
Markdown
Raw Normal View History

2026-05-25 20:51:57 +08:00
---
tags: [Redis, 底层数据结构, ziplist, listpack]
create time: 2026-05-25 10:30
author: 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 在内存中长这样:
```mermaid
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 由三部分组成:
```mermaid
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 最大的性能隐患。我们用一个例子说明:
### 触发条件
1. 插入一个新 entry(或现有 entry 增长),导致下一个 entry 的 `prevlen` 从 1 字节扩展到 5 字节
2. 这 4 字节的膨胀可能让该 entry 总长超过 253,从而导致**再下一个** entry 的 `prevlen` 也要扩展
3. 如此链式传播,最坏情况下**所有后续 entry 都要重新分配**
### 最坏情况复杂度
> [!danger] O(N²) 的代价
> 假设 ziplist 有 N 个 entry,每个都刚好在 253 字节边界。一次插入触发 N 次 `memmove`,每次 O(N)——总计 O(N²)。
>
> 实际中很少触发最坏情况(需要连续的 253 边界 entry),但只要 ziplist 够大,风险就不可忽视。这就是为什么 Redis 给 ziplist 设了阈值——超过就切到更安全的结构。
### 代码验证
```go
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 的整体布局
```mermaid
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 的变长编码
```mermaid
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。
### 核心区别
```mermaid
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))。
2026-05-25 22:24:47 +08:00
各类型的切换阈值——**阈值内**用紧凑编码省内存,**超过阈值**切到通用结构保性能:
| 数据类型 | 阈值内编码 | 配置项 | 默认值 | 超过阈值切到 |
|---------|-----------|--------|--------|------------|
| 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 节点**
2026-05-25 20:51:57 +08:00
## 关联笔记
- [[hhs/Redis/02-核心数据类型]] — 五种数据类型的编码切换全景
- [[hhs/Redis/02-核心数据类型/02-2-skiplist]] — ZSet 的另一种核心结构
- [[hhs/Redis/08-SortedSet精解]] — ZSet 的高级用法与内存优化