vault backup: 2026-05-25 20:51:57
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
---
|
||||
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 | `hash-max-ziplist-entries` | 512 | hashtable |
|
||||
| Hash | `hash-max-ziplist-value` | 64 字节 | hashtable |
|
||||
| ZSet | `zset-max-ziplist-entries` | 128 | skiplist+hashtable |
|
||||
| ZSet | `zset-max-ziplist-value` | 64 字节 | skiplist+hashtable |
|
||||
| List | `list-max-ziplist-size` | -2 (每个节点 8KB) | quicklist 新节点 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/Redis/02-核心数据类型]] — 五种数据类型的编码切换全景
|
||||
- [[hhs/Redis/02-核心数据类型/02-2-skiplist]] — ZSet 的另一种核心结构
|
||||
- [[hhs/Redis/08-SortedSet精解]] — ZSet 的高级用法与内存优化
|
||||
Reference in New Issue
Block a user