Files
cs-note/hhs/Redis/02-核心数据类型/02-1-ziplist与listpack.md
T
2026-05-25 22:24:47 +08:00

249 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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))。
各类型的切换阈值——**阈值内**用紧凑编码省内存,**超过阈值**切到通用结构保性能:
| 数据类型 | 阈值内编码 | 配置项 | 默认值 | 超过阈值切到 |
|---------|-----------|--------|--------|------------|
| 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 的高级用法与内存优化