343 lines
14 KiB
Markdown
343 lines
14 KiB
Markdown
|
|
---
|
|||
|
|
tags: [Redis, 底层数据结构, quicklist, List, 链表, ziplist]
|
|||
|
|
create time: 2026-05-25 19:11
|
|||
|
|
author: hhs
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# quicklist(快速列表)
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
quicklist 是 Redis List 类型的**统一底层编码**——它是一个**双向链表**,但每个节点不是一个单独的元素,而是一个**ziplist(或 listpack)**。
|
|||
|
|
|
|||
|
|
简单理解:quicklist = 链表的骨架 + 紧凑块的填充。它既保留了链表的插入/删除灵活性,又通过打包多个元素到连续内存中,大幅降低了指针开销。
|
|||
|
|
|
|||
|
|
> [!tip] 快速定位
|
|||
|
|
> - 如果你是 **List 类型用户**:底层就是 quicklist,无需关心——但了解它能帮你做出更好的配置调优。
|
|||
|
|
> - 如果你在做**技术面试准备**:重点掌握"为什么需要 quicklist"(链表 vs 数组的折中)和"listpack 如何消除级联更新"。
|
|||
|
|
> - 如果你在做**性能优化**:重点关注 `list-max-ziplist-size` 和 `list-compress-depth` 两个配置参数。
|
|||
|
|
|
|||
|
|
## 一、为什么需要 quicklist?
|
|||
|
|
|
|||
|
|
> [!question] 普通链表有什么问题?
|
|||
|
|
> 考虑存 10000 个元素:普通双向链表需要 10000 个节点,每个节点有 `prev` 和 `next` 两个指针(64 位系统各占 8 字节),光是指针就吃掉 **160KB**——而数据本身可能才几十 KB。
|
|||
|
|
|
|||
|
|
Redis 设计者想要的是一种"中间态":
|
|||
|
|
|
|||
|
|
| 结构 | 内存效率 | 随机访问 | 插入/删除 |
|
|||
|
|
|------|:-------:|:-------:|:--------:|
|
|||
|
|
| 普通双向链表 | ❌ 指针开销大 | ❌ O(N) | ✅ O(1) |
|
|||
|
|
| ziplist(纯数组) | ✅ 极紧凑 | ❌ O(N) | ❌ 可能级联更新 |
|
|||
|
|
| **quicklist** | ✅ 折中方案 | ❌ O(N) | ✅ 局部影响 |
|
|||
|
|
|
|||
|
|
quicklist 的核心思想:**把"散装"的链表节点打包成"整箱"的 ziplist**——既省掉了大部分指针,又让每个 ziplist 足够小,即使触发级联更新,影响范围也有限。
|
|||
|
|
|
|||
|
|
> [!insight] 一个形象的比喻
|
|||
|
|
> 普通链表像一排散装零食,每个都要单独包装;quicklist 像把零食分装进几个密封袋,袋与袋之间用链子连起来。袋子太大不好管理(级联更新风险),袋子太小又失去了打包的意义。quicklist 的配置参数就是帮你找到"合适的袋子大小"。
|
|||
|
|
|
|||
|
|
## 二、整体结构
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart TB
|
|||
|
|
QL["quicklist"] --> HEAD["head, 指向第一个节点"]
|
|||
|
|
QL --> TAIL["tail, 指向最后一个节点"]
|
|||
|
|
QL --> LEN["len, 节点数量"]
|
|||
|
|
QL --> FILL["fill, 每个节点的最大元素数"]
|
|||
|
|
|
|||
|
|
HEAD --> N1["quicklistNode 1"]
|
|||
|
|
N1 --> ZP1["ziplist / listpack"]
|
|||
|
|
N1 --> NEXT1["next"]
|
|||
|
|
|
|||
|
|
N1 --> N2["quicklistNode 2"]
|
|||
|
|
N2 --> ZP2["ziplist / listpack"]
|
|||
|
|
N2 --> NEXT2["next"]
|
|||
|
|
|
|||
|
|
N2 --> N3["quicklistNode N"]
|
|||
|
|
N3 --> ZP3["ziplist / listpack"]
|
|||
|
|
|
|||
|
|
classDef ql fill:#e1f5fe,stroke:#2196f3
|
|||
|
|
classDef node fill:#fff3e0,stroke:#ff9800
|
|||
|
|
classDef zp fill:#e8f5e9,stroke:#4caf50
|
|||
|
|
class QL,HEAD,TAIL,LEN,FILL ql
|
|||
|
|
class N1,N2,N3,NEXT1,NEXT2 node
|
|||
|
|
class ZP1,ZP2,ZP3 zp
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 核心结构体
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// quicklist 整体
|
|||
|
|
type quicklist struct {
|
|||
|
|
head *quicklistNode // 头节点
|
|||
|
|
tail *quicklistNode // 尾节点
|
|||
|
|
len uint64 // 节点数量(不是元素数量!)
|
|||
|
|
fill int32 // 每个节点的元素上限(来自 list-max-ziplist-size)
|
|||
|
|
compress uint16 // LZF 压缩深度(来自 list-compress-depth)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 每个节点
|
|||
|
|
type quicklistNode struct {
|
|||
|
|
prev *quicklistNode
|
|||
|
|
next *quicklistNode
|
|||
|
|
entry *ziplist // Redis 7.2+ 改为 listpack
|
|||
|
|
sz uint32 // entry 指向的 ziplist 总字节大小
|
|||
|
|
count uint16 // 当前 ziplist 中的元素数量
|
|||
|
|
encoding uint16 // 编码方式:原生 or LZF 压缩
|
|||
|
|
container uint16 // 存储容器:ziplist or listpack
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!question] `len` 是节点数还是元素数?
|
|||
|
|
> `quicklist.len` 是**节点数**(即 ziplist 块的数量),不是 List 中元素的总数。要获取元素总数,需要把所有节点的 `count` 加起来。
|
|||
|
|
|
|||
|
|
## 三、配置参数详解
|
|||
|
|
|
|||
|
|
### list-max-ziplist-size
|
|||
|
|
|
|||
|
|
控制每个 quicklistNode 中 ziplist 的大小。注意:这个参数**同时支持正数和负数**,含义不同:
|
|||
|
|
|
|||
|
|
| 值 | 含义 | 示例 |
|
|||
|
|
|----|------|------|
|
|||
|
|
| `-5` | 每个 ziplist 最大 **64 KB** | 大元素场景 |
|
|||
|
|
| `-4` | 每个 ziplist 最大 **32 KB** | 默认推荐 |
|
|||
|
|
| `-3` | 每个 ziplist 最大 **16 KB** | — |
|
|||
|
|
| `-2` | 每个 ziplist 最大 **8 KB** | 默认值 |
|
|||
|
|
| `-1` | 每个 ziplist 最大 **4 KB** | — |
|
|||
|
|
| `正数 N` | 每个 ziplist 最多 **N 个元素** | 精确控制元素数 |
|
|||
|
|
|
|||
|
|
> [!question] 为什么有正数和负数两种模式?
|
|||
|
|
> 正数按**元素个数**限制,适合元素大小相对均匀的场景;负数按**字节数**限制,适合元素大小差异大的场景。默认 `-2`(8KB)是一个平衡点——每个节点占用的空间不大,内存分配友好,且元素数量足够多来摊薄指针开销。
|
|||
|
|
|
|||
|
|
### list-compress-depth
|
|||
|
|
|
|||
|
|
控制从头尾开始的**多少个节点不压缩**,中间的节点使用 **LZF 算法压缩**:
|
|||
|
|
|
|||
|
|
| 值 | 含义 |
|
|||
|
|
|----|------|
|
|||
|
|
| `0` | 默认,不压缩任何节点 |
|
|||
|
|
| `1` | 头尾各 1 个节点不压缩,其余压缩 |
|
|||
|
|
| `2` | 头尾各 2 个节点不压缩,其余压缩 |
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart LR
|
|||
|
|
subgraph D0["compress-depth = 0"]
|
|||
|
|
direction LR
|
|||
|
|
A0["节点1, 未压缩"] --> B0["节点2, 未压缩"] --> C0["节点3, 未压缩"] --> D0N["节点4, 未压缩"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph D1["compress-depth = 1"]
|
|||
|
|
direction LR
|
|||
|
|
A1["节点1, 未压缩"] --> B1["节点2, LZF 压缩"] --> C1["节点3, LZF 压缩"] --> D1N["节点4, 未压缩"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
classDef plain fill:#e8f5e9,stroke:#4caf50
|
|||
|
|
classDef compressed fill:#ffebee,stroke:#f44336
|
|||
|
|
class A0,B0,C0,D0N,A1,D1N plain
|
|||
|
|
class B1,C1 compressed
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!tip] 什么时候开启压缩?
|
|||
|
|
> List 元素较大(如存储 JSON 消息体)且**访问集中在头尾**(典型的队列场景)时,开启 `list-compress-depth 1` 或 `2` 可以显著节省内存。中间的节点被 LZF 压缩后,取到时再解压,对冷数据的访问多一次解压开销,但对热数据(头尾)完全无影响。
|
|||
|
|
>
|
|||
|
|
> 如果你的 List 经常做 `LRANGE` 遍历中间部分,就**不要开压缩**——解压开销会抵消收益。
|
|||
|
|
|
|||
|
|
### 两个参数如何配合?
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// Redis 启动时的默认配置
|
|||
|
|
list-max-ziplist-size -2 // 每个节点最大 8KB
|
|||
|
|
list-compress-depth 0 // 不压缩
|
|||
|
|
|
|||
|
|
// 消息队列场景(大消息、头尾操作为主)
|
|||
|
|
list-max-ziplist-size -5 // 每个节点最大 64KB(消息体大)
|
|||
|
|
list-compress-depth 1 // 压缩中间节点,节省内存
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 四、核心操作
|
|||
|
|
|
|||
|
|
### 插入(LPUSH / RPUSH)
|
|||
|
|
|
|||
|
|
以 `LPUSH` 从头部插入为例:
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart TB
|
|||
|
|
S1["LPUSH key value"] --> S2{"head 节点的 ziplist 未满?"}
|
|||
|
|
S2 -->|"是"| S3["直接在 head 的 ziplist 头部插入元素"]
|
|||
|
|
S2 -->|"否"| S4["新建 quicklistNode, 创建新 ziplist, 插入元素, 设为新 head"]
|
|||
|
|
|
|||
|
|
classDef step fill:#e1f5fe,stroke:#2196f3
|
|||
|
|
classDef check fill:#fff3e0,stroke:#ff9800
|
|||
|
|
class S1,S3,S4 step
|
|||
|
|
class S2 check
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!question] 插入会导致级联更新吗?
|
|||
|
|
> 会,但**影响范围被限制在单个 ziplist 内部**。即使 ziplist 的级联更新把一个节点"撑大"了,它最多导致这一个节点重新分配内存,不会波及其他节点。这就是 quicklist 比纯 ziplist 更适合大数据量的根本原因。
|
|||
|
|
|
|||
|
|
### 随机访问(LINDEX)
|
|||
|
|
|
|||
|
|
`LINDEX key 500` 需要找到 List 中第 500 个元素:
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart TB
|
|||
|
|
S1["LINDEX key 500"] --> S2["从 head 开始遍历 quicklistNode"]
|
|||
|
|
S2 --> S3{"目标元素在当前节点内?"}
|
|||
|
|
S3 -->|"否"| S4["跳过整个节点, count 累加"]
|
|||
|
|
S4 --> S3
|
|||
|
|
S3 -->|"是"| S5["在节点的 ziplist 内做偏移定位, 取出元素"]
|
|||
|
|
|
|||
|
|
classDef step fill:#e1f5fe,stroke:#2196f3
|
|||
|
|
classDef check fill:#fff3e0,stroke:#ff9800
|
|||
|
|
class S1,S2,S4,S5 step
|
|||
|
|
class S3 check
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!caution] LINDEX 是 O(N)
|
|||
|
|
> 即使 quicklistNode 的数量不多,定位到具体节点后还需要在 ziplist 内做 O(K) 的线性偏移(K 是节点内的元素数)。所以 `LINDEX` 整体仍然是 O(N)。如果你需要频繁按索引随机访问 List,说明你可能**选错了数据结构**——考虑用 Hash 或 ZSet。
|
|||
|
|
|
|||
|
|
### 范围查询(LRANGE)
|
|||
|
|
|
|||
|
|
`LRANGE key 0 99` 取前 100 个元素:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 伪代码:LRANGE 的执行逻辑
|
|||
|
|
func (ql *quicklist) lrange(start, stop int) []any {
|
|||
|
|
node := ql.head
|
|||
|
|
offset := start
|
|||
|
|
result := make([]any, 0, stop-start+1)
|
|||
|
|
|
|||
|
|
// 1. 跳到包含 start 的节点
|
|||
|
|
for node != nil && offset >= node.count {
|
|||
|
|
offset -= node.count
|
|||
|
|
node = node.next
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 从该节点开始,逐节点取元素直到满足 stop
|
|||
|
|
for node != nil && len(result) <= stop-start {
|
|||
|
|
// 在当前节点的 ziplist 中从 offset 开始取
|
|||
|
|
fetched := ziplistRange(node.entry, offset, ...)
|
|||
|
|
result = append(result, fetched...)
|
|||
|
|
offset = 0 // 后续节点从头开始取
|
|||
|
|
node = node.next
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!tip] LRANGE 的效率优化
|
|||
|
|
> 虽然理论复杂度是 O(N),但 quicklist 的连续内存布局对 **CPU 缓存友好**——遍历单个 ziplist 时,数据在内存中连续排列,cache miss 很少。相比普通链表的"跳来跳去",实际速度快得多。
|
|||
|
|
|
|||
|
|
### 从两端弹出(LPOP / RPOP)
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart TB
|
|||
|
|
S1["LPOP key"] --> S2{"head 的 ziplist 有多个元素?"}
|
|||
|
|
S2 -->|"是"| S3["移除并返回 ziplist 的第一个元素"]
|
|||
|
|
S2 -->|"否, 只剩一个"| S4["返回元素, 释放整个 quicklistNode, head 指向 next"]
|
|||
|
|
|
|||
|
|
classDef step fill:#e1f5fe,stroke:#2196f3
|
|||
|
|
classDef check fill:#fff3e0,stroke:#ff9800
|
|||
|
|
class S1,S3,S4 step
|
|||
|
|
class S2 check
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!question] 为什么不直接用 ziplist 做 List?
|
|||
|
|
> 如果整个 List 只用一个 ziplist,插入/删除导致的**级联更新**会影响整个列表。而且当 List 很大时,ziplist 需要大块连续内存,**内存分配失败的概率增大**。quicklist 把大块拆成小块,每块独立分配,既降低了碎片化风险,又把级联更新限制在单个节点内。
|
|||
|
|
|
|||
|
|
## 五、操作复杂度速查
|
|||
|
|
|
|||
|
|
| 操作 | 命令 | 时间复杂度 | 说明 |
|
|||
|
|
|------|------|:---------:|------|
|
|||
|
|
| 头部插入 | `LPUSH` | O(1) | ziplist 未满时直接插入;满则新建节点 |
|
|||
|
|
| 尾部插入 | `RPUSH` | O(1) | 同上 |
|
|||
|
|
| 头部弹出 | `LPOP` | O(1) | ziplist 未空时直接移除;空则释放节点 |
|
|||
|
|
| 尾部弹出 | `RPOP` | O(1) | 同上 |
|
|||
|
|
| 按索引访问 | `LINDEX` | O(N) | 需遍历节点 + 节点内偏移 |
|
|||
|
|
| 范围查询 | `LRANGE` | O(S+N) | S 为偏移跳过量,N 为返回数量 |
|
|||
|
|
| 获取长度 | `LLEN` | O(1) | 直接读取 quicklist 的长度元数据 |
|
|||
|
|
|
|||
|
|
> [!caution] 避免滥用 LINDEX / LRANGE 大范围扫描
|
|||
|
|
> quicklist 的强项是**队列语义**(头尾操作 O(1))。如果你发现 `LINDEX` 或大范围 `LRANGE` 成为瓶颈,大概率是数据结构选型的问题——考虑换用 Hash(按 key 直接 O(1) 查)或 ZSet(按 score 范围 O(logN) 查)。
|
|||
|
|
|
|||
|
|
## 六、LZF 压缩机制
|
|||
|
|
|
|||
|
|
当 `list-compress-depth > 0` 时,中间节点的 ziplist 会被 LZF 压缩:
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart LR
|
|||
|
|
subgraph READ["访问压缩节点"]
|
|||
|
|
direction TB
|
|||
|
|
R1["读取压缩节点"] --> R2{"encoding == LZF?"}
|
|||
|
|
R2 -->|"是"| R3["解压 ziplist, 还原到内存"]
|
|||
|
|
R3 --> R4["在解压后的 ziplist 中定位元素"]
|
|||
|
|
R2 -->|"否"| R4
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph WRITE["写入压缩节点"]
|
|||
|
|
direction TB
|
|||
|
|
W1["写入压缩节点"] --> W2["先解压, 修改 ziplist"]
|
|||
|
|
W2 --> W3{"节点不在首尾, 且 compress-depth 允许?"}
|
|||
|
|
W3 -->|"是"| W4["重新 LZF 压缩"]
|
|||
|
|
W3 -->|"否"| W5["保持原样"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
classDef check fill:#fff3e0,stroke:#ff9800
|
|||
|
|
classDef step fill:#e1f5fe,stroke:#2196f3
|
|||
|
|
class R2,W3 check
|
|||
|
|
class R1,R3,R4,W1,W2,W4,W5 step
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!insight] 压缩的收益有多大?
|
|||
|
|
> LZF 对文本型数据(如 JSON 消息)的压缩率通常在 **50%~70%**。一个存储 1000 条消息的 List,中间节点压缩后内存占用可以降低一半以上。代价是访问中间节点时的解压开销——但对于队列场景(只操作头尾),这个开销为零。
|
|||
|
|
|
|||
|
|
## 七、Redis 7.0+ 的演进
|
|||
|
|
|
|||
|
|
Redis 7.2 开始,quicklist 的节点内部从 **ziplist** 迁移到 **listpack**。结构体中的 `container` 字段标识当前使用的是哪种:
|
|||
|
|
|
|||
|
|
| container 值 | 存储格式 | 说明 |
|
|||
|
|
|:-----------:|---------|------|
|
|||
|
|
| 2 | ziplist | 旧格式,仍兼容 |
|
|||
|
|
| 3 | listpack | 新格式,去掉了 `prevlen`,彻底消除级联更新 |
|
|||
|
|
|
|||
|
|
> [!question] 迁移是自动的吗?
|
|||
|
|
> 是的。当一个 quicklistNode 被修改(插入/删除元素)时,Redis 会检查并将其转换为 listpack 格式。未修改的节点保持原样。整个过程对用户完全透明。
|
|||
|
|
|
|||
|
|
### ziplist vs listpack 节点对比
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart LR
|
|||
|
|
subgraph ZP["ziplist 节点"]
|
|||
|
|
direction TB
|
|||
|
|
Z1["prevlen, 1 或 5 字节"]
|
|||
|
|
Z1 --> Z2["encoding + len"]
|
|||
|
|
Z2 --> Z3["data"]
|
|||
|
|
Z3 --> Z4["prevlen, 1 或 5 字节"]
|
|||
|
|
Z4 --> Z5["..."]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph LP["listpack 节点"]
|
|||
|
|
direction TB
|
|||
|
|
L1["encoding + len"]
|
|||
|
|
L1 --> L2["data"]
|
|||
|
|
L2 --> L3["backlen, 只记录自身长度"]
|
|||
|
|
L3 --> L4["encoding + len"]
|
|||
|
|
L4 --> L5["..."]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
classDef zpStyle fill:#ffebee,stroke:#f44336
|
|||
|
|
classDef lpStyle fill:#e8f5e9,stroke:#4caf50
|
|||
|
|
class Z1,Z2,Z3,Z4,Z5 zpStyle
|
|||
|
|
class L1,L2,L3,L4,L5 lpStyle
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!tip] 级联更新是怎么消失的?
|
|||
|
|
> ziplist 的 `prevlen` 记录前一个节点的长度——前一个节点变大时,`prevlen` 可能从 1 字节变 5 字节,引发后续节点连锁反应。listpack 用 `backlen` 只记录**自身**的长度,修改任何节点都不会影响其他节点,级联更新从结构上被消除了。
|
|||
|
|
>
|
|||
|
|
> 详见 [[02-1-ziplist与listpack]]
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[02-核心数据类型]] — 五种数据类型的编码切换全景
|
|||
|
|
- [[02-1-ziplist与listpack]] — quicklist 节点内部的紧凑存储结构
|
|||
|
|
- [[02-3-hashtable]] — Redis 最常用的 O(1) 查找结构
|