Files
cs-note/hhs/Redis/02-核心数据类型/02-2-skiplist.md
T
2026-05-25 20:51:57 +08:00

521 lines
20 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, 底层数据结构, skiplist, ZSet]
create time: 2026-05-25 10:30
author: hhs
---
# skiplist(跳表)
## 概述
skiplist(跳表)是 Redis ZSet 的核心排序结构。它用**多层索引**把有序链表的查找从 O(N) 优化到 O(logN),实现简单、性能稳定——是 Redis 选择它而不是红黑树的原因。
## 一、从有序链表到跳表
> [!question] 为什么不直接用有序链表?
> 有序链表查找要从头逐个遍历,O(N)。100 万个元素,最坏要走 100 万步。
### 加一层索引
如果我们每两个节点提取一个"索引指针",查找时先在索引层跳着走,跳过目标了再降下去——**步数直接减半**。
再多加几层索引呢?每层跳过上一层的节点,查找路径像"跳台阶"一样逐层逼近:
```mermaid
flowchart LR
subgraph Level3["Level 3"]
direction LR
H3["header"] --> A3["1"]
A3 --> N3["NIL"]
end
subgraph Level2["Level 2"]
direction LR
H2["header"] --> A2["1"]
A2 --> D2["4"]
D2 --> G2["7"]
G2 --> N2["NIL"]
end
subgraph Level1["Level 1"]
direction LR
H1["header"] --> A1["1"]
A1 --> B1["2"]
B1 --> D1["4"]
D1 --> E1["5"]
E1 --> G1["7"]
G1 --> N1["NIL"]
end
classDef node fill:#e1f5fe,stroke:#2196f3
classDef nil fill:#fafafa,stroke:#ccc
class A3,D2,G2,A1,B1,D1,E1,G1 node
class N3,N2,N1 nil
```
> [!tip] 查找 5 的过程
> 1. **Level 3**:从 header 到 1,下一个是 NIL(跳过头了)
> 2. 降到 **Level 2**:从 1 到 4,下一个 7 超了
> 3. 降到 **Level 1**:从 4 到 5,**找到了**!
>
> 总共只走了 3 步,而不是遍历全部 7 个节点。
### 层数怎么定?
> [!question] 如果每层固定间隔,不就退化成多级索引的数组了吗?
> 跳表的精髓在于**随机化**。每个节点插入时,抛硬币决定要不要"长高一层"——概率各 50%。这样既不会出现极端退化,又不需要像平衡树那样做复杂的旋转操作。
Redis 的实现中,节点最大层数限制为 **32 层**,晋升概率为 **0.25**(每 4 次有 1 次升一层)。数学期望上,一个 N 个元素的跳表平均层数约为 `log_{1/p}(N)`——对百万级数据,约 5~6 层。
> [!info] 为什么是 O(logN)?
> 每一层大约是上一层节点数的 `p` 倍(p=0.25),所以从高到低逐层下降时,每一层平均淘汰掉 `(1-p)` 比例的候选节点。
>
> 搜索路径长度 ≈ 每层检查 1 个节点 × 层数 = `log_{1/p}(N)`。以 p=0.25 为例:
> - N = 1,000 → 约 5 层
> - N = 1,000,000 → 约 10 层
>
> 与平衡二叉树的 O(log₂N) 同阶,但跳表不需要旋转,缓存局部性也更好(底层链表是连续遍历的)。
## 二、Redis 中 skiplist 的节点结构
Redis 的 skiplist 节点比教科书版多了几个字段:
```mermaid
flowchart TB
subgraph Node["skiplist node"]
direction TB
EL["ele, SDS 字符串, 成员名"]
SC["score, float64, 分数"]
BW["backward, 指向前一个节点"]
subgraph Levels["level[], 可变长度数组"]
direction LR
L1["forward + span, Level 1"]
L2["forward + span, Level 2"]
L3["forward + span, Level N"]
end
end
classDef field fill:#e8f5e9,stroke:#4caf50
classDef level fill:#fff3e0,stroke:#ff9800
class EL,SC,BW field
class L1,L2,L3 level
```
| 字段 | 类型 | 作用 |
|------|------|------|
| `ele` | SDS (简单动态字符串) | 成员名称,如 `"player:42"` |
| `score` | `double` | 排序分数 |
| `backward` | 指针 | **上一个节点**的指针(支持 `ZREVRANGE` 反向遍历) |
| `level[i].forward` | 指针 | 第 i 层指向的下一个节点 |
| `level[i].span` | `uint32` | 该层的 forward 跨越了多少个节点(用于计算 rank) |
对应的 Redis 源码定义(`server.h`):
```c
// 单层索引
typedef struct zskiplistLevel {
struct zskiplistNode *forward; // 指向下一个节点
unsigned long span; // 跨越的节点数,用于计算 rank
} zskiplistLevel;
// 跳表节点
typedef struct zskiplistNode {
sds ele; // 成员名(SDS 字符串)
double score; // 排序分数
struct zskiplistNode *backward;// 指向前一个节点(反向遍历)
zskiplistLevel level[]; // 柔性数组,层数随机分配
} zskiplistNode;
// 跳表本身
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 头尾哨兵节点
unsigned long length; // 节点总数
int level; // 当前最高层数
} zskiplist;
```
### 三个结构体的层级关系
> [!question] 这三个结构体分别对应什么?
> 它们是严格的**包含关系**——`zskiplist` 管理整个跳表,包含很多 `zskiplistNode`,每个节点又包含若干 `zskiplistLevel`。
```mermaid
flowchart TB
ZS["zskiplist - 跳表本身 - 全局管理者"]
ZS -->|"header"| N1["zskiplistNode - 哨兵头节点"]
ZS -->|"tail"| N2["zskiplistNode - 最后一个节点"]
ZS -->|"length"| L["节点总数"]
ZS -->|"level"| ML["当前最高层数"]
N1 --> L3["zskiplistLevel - Level 3 的指针槽"]
N1 --> L2["zskiplistLevel - Level 2 的指针槽"]
N1 --> L1["zskiplistLevel - Level 1 的指针槽"]
classDef table fill:#fff3e0,stroke:#ff9800
classDef node fill:#e1f5fe,stroke:#2196f3
classDef lev fill:#e8f5e9,stroke:#4caf50
class ZS table
class N1,N2 node
class L1,L2,L3,L,ML lev
```
| 结构体 | 类比 | 职责 |
|--------|------|------|
| `zskiplistLevel` | 公交站牌上"下一站 XX,距离 3 站" | 最小积木,一个指针槽:**去哪 + 跨多远**,必须依附在节点上 |
| `zskiplistNode` | 多层立交桥的一个出口 | 一个节点纵向跨越多层,每层有一个 `zskiplistLevel`;存储数据(`ele`、`score`) |
| `zskiplist` | 整条公交线路的管理站 | 全局管理:头尾哨兵、节点总数、当前最高层数;不存数据,只做调度 |
> [!tip] 一句话记住
> **`zskiplist`** 管理跳表 → 包含 **N 个 `zskiplistNode`**(节点)→ 每个节点包含 **N 个 `zskiplistLevel`**(层指针槽)
> [!question] 柔性数组 `level[]` 是什么?
> 这是 C 语言的"柔性数组"写法——节点分配时按实际层数动态分配内存,不会为每个节点都预留 32 层的空间。一个只有 1 层的节点,`level[]` 只占 1 个 `zskiplistLevel` 的内存。这就是跳表比"固定多层数组"省空间的关键。
> [!question] `forward` 和 `span` 分别是什么?
> - **`forward`**:当前层的"跳转指针",告诉你**从这个节点出发,在这一层往后走,下一个节点是谁**。每一层都是一个独立的链表,`forward` 就是链表的 `next` 指针——只不过高层的 `forward` 跳得远(跳过中间节点),低层的 `forward` 跳得近。
> - **`span`**:记录这个 `forward` 指针**跳过了多少个底层节点**。当你执行 `ZRANK` 查某个成员的排名时,只需沿着查找路径把沿途的 `span` 加起来——**不需要遍历整个链表**。
>
> 教科书跳表一般没有 `span` 和 `backward`,它们是 Redis 为支持排名和反向遍历而加的。
`forward` 和 `span` 的配合示意:
```mermaid
flowchart LR
subgraph L2["Level 2"]
direction LR
H2["header"] -->|"span=3"| C2["score=3.0"]
C2 -->|"span=2"| E2["score=5.0"]
E2 -->|"span=1"| NIL2["NIL"]
end
subgraph L1["Level 1"]
direction LR
H1["header"] -->|"span=1"| A1["score=1.0"]
A1 -->|"span=1"| B1["score=2.0"]
B1 -->|"span=1"| C1["score=3.0"]
C1 -->|"span=1"| D1["score=4.0"]
D1 -->|"span=1"| E1["score=5.0"]
E1 -->|"span=1"| NIL1["NIL"]
end
classDef node fill:#e1f5fe,stroke:#2196f3
classDef nil fill:#fafafa,stroke:#ccc
class A1,B1,C1,D1,E1,C2,E2 node
class H1,H2,NIL1,NIL2 nil
```
> [!tip] 用上图举例
> - Level 2 的 header → score=3.0:`forward` 指向 score=3.0 的节点,`span=3` 表示**跳过了底层的 3 个节点**(1.0、2.0、3.0)
> - 查找 score=5.0 的排名时:Level 2 跳到 score=3.0(`rank += 3`),再降到 Level 1 走两步到 score=5.0(`rank += 2`),**总排名 = 5**,全程只走了 3 步
>
> 简单类比:**`forward` = 出口指示牌(下一站去哪),`span` = 里程数(跨过几站)**
### 查找过程(带 span)
```mermaid
flowchart LR
H["header"] -->|"span=1"| N1["score=1.0, rank 计算起点"]
N1 -->|"span=3"| N4["score=4.0"]
N4 -->|"span=2"| N6["score=6.0"]
N6 -->|"span=1"| NIL["NIL"]
classDef node fill:#e1f5fe,stroke:#2196f3
classDef nil fill:#fafafa,stroke:#ccc
class N1,N4,N6 node
class H,NIL nil
```
查找 `score=4.0` 的节点:从 header 出发,Level 2 的 `span=1`(到 score=1.0),Level 1 的 `span=3`(到 score=4.0),`rank = 1 + 3 = 4`。
### 简化搜索实现
把上面的文字描述翻译成代码,核心逻辑只有几行:
```go
// rank 记录沿途经过的节点数,用于计算排名
func (zsl *skiplist) getRank(score float64, ele string) uint64 {
var rank uint64
node := zsl.header
// 从最高层往最低层走
for i := zsl.level - 1; i >= 0; i-- {
// 在当前层尽量往前跳,直到下一个节点的 score 超过目标
for node.level[i].forward != nil &&
(node.level[i].forward.score < score ||
(node.level[i].forward.score == score &&
node.level[i].forward.ele < ele)) {
rank += node.level[i].span
node = node.level[i].forward
}
}
node = node.level[0].forward // 降到 Level 1,检查是否命中
if node != nil && node.score == score && node.ele == ele {
return rank
}
return 0 // 未找到
}
```
> [!tip] 读代码的两个关键点
> 1. **外层循环**:逐层下降,每一层只做"尽量往前跳"这一个动作——这就是"跳台阶"。
> 2. **rank 累加**:跳过一个节点就加一次 `span`,降到 Level 1 时 rank 已经是精确排名,**不需要再逐个数**。
### 随机层数生成(randomLevel)
> [!question] 每个节点的层数怎么来的?
> 不是预先算好的,而是**插入时随机决定**。Redis 的策略很直白:从 Level 1 开始,每次有 `p=0.25` 的概率升一层,直到 32 层上限。
```go
const (
ZSKIPLIST_MAXLEVEL = 32
ZSKIPLIST_P = 0.25
)
func randomLevel() int {
level := 1
// 每次以 25% 概率升一层,直到触顶
for level < ZSKIPLIST_MAXLEVEL && rand.Float64() < ZSKIPLIST_P {
level++
}
return level
}
```
> [!info] 为什么是 0.25 而不是 0.5?
> p=0.5 是教科书标准值,但 p=0.25 意味着**平均每 4 次才有 1 次升层**,高层数的节点更稀疏。好处是:
> - 每个节点的平均指针数更少(`1/(1-p) = 1.33` vs p=0.5 的 2),**更省内存**
> - 高层索引更"跨距"更大,虽然每层能排除的候选少一点,但层数也更少
> - 总体搜索效率差异很小,Redis 选择了**内存更优**的方案
层数分布(概率):
| 层级 | 概率 | 含义 |
|------|------|------|
| 1 | 75% | 大多数节点只有 1 层 |
| 2 | 18.75% | 约 1/5 的节点有 2 层 |
| 3 | ~4.7% | 约 1/20 的节点有 3 层 |
| k | `0.75 × 0.25^(k-1)` | 指数衰减 |
### 插入过程
> [!question] 插入一个新节点,要改哪些指针?
> 核心思路:**找到每一层的"前驱节点",然后逐层缝入新节点**。这就像拉链——先定位每一层的缺口,再把新节点的指针串进去。
```mermaid
flowchart TB
subgraph Before["插入前: 寻找每层前驱"]
direction LR
UL3["update Level 3, header"] --> UL2["update Level 2, node1"]
UL2 --> UL1["update Level 1, node4"]
end
subgraph After["插入后: 逐层缝入"]
direction LR
NL3["new node, Level 3"] -->|"forward"| FL3["header.forward"]
NL2["new node, Level 2"] -->|"forward"| FL2["node1.forward"]
NL1["new node, Level 1"] -->|"forward"| FL1["node4.forward"]
end
Before -->|"逐层修改 forward 指针"| After
classDef update fill:#fff3e0,stroke:#ff9800
classDef newnode fill:#e8f5e9,stroke:#4caf50
class UL3,UL2,UL1 update
class NL3,NL2,NL1 newnode
```
核心代码(省略 span 计算细节,聚焦指针操作):
```go
func (zsl *skiplist) insert(score float64, ele string) {
// 1. 从最高层往下搜索,记录每层"最后一个比新节点小的节点"
update := make([]*zskiplistNode, ZSKIPLIST_MAXLEVEL)
node := zsl.header
for i := zsl.level - 1; i >= 0; i-- {
for node.level[i].forward != nil &&
node.level[i].forward.score < score {
node = node.level[i].forward
}
update[i] = node // 第 i 层的"前驱"
}
// 2. 随机生成新节点的层数
level := randomLevel()
if level > zsl.level {
// 新层数超过了当前最高层,初始化 header 的高层指针
for i := zsl.level; i < level; i++ {
update[i] = zsl.header
}
zsl.level = level
}
// 3. 创建新节点
newNode := newZskiplistNode(level, score, ele)
// 4. 逐层缝入:修改 forward 指针,像拉链一样
for i := 0; i < level; i++ {
newNode.level[i].forward = update[i].level[i].forward
update[i].level[i].forward = newNode
}
// 5. 设置 backward(双向链表的前驱指针)
newNode.backward = update[0]
if newNode.level[0].forward != nil {
newNode.level[0].forward.backward = newNode
}
zsl.length++
}
```
> [!tip] 插入的核心逻辑
> - **时间复杂度**:O(logN)——和查找一样,大部分时间花在"找前驱"上
> - **指针修改**:只有新节点涉及的那几层需要改 forward,**不影响其他层**
> - **不需要旋转**:对比红黑树插入后可能触发的多次旋转+重着色,跳表的插入操作"一气呵成"
### 删除过程
删除和插入是对称操作:同样先找前驱,然后**反向拆链**。
```go
func (zsl *skiplist) delete(score float64, ele string) {
// 1. 同样记录每层前驱
update := make([]*zskiplistNode, ZSKIPLIST_MAXLEVEL)
node := zsl.header
for i := zsl.level - 1; i >= 0; i-- {
for node.level[i].forward != nil &&
(node.level[i].forward.score < score ||
(node.level[i].forward.score == score &&
node.level[i].forward.ele < ele)) {
node = node.level[i].forward
}
update[i] = node
}
// 2. 定位到目标节点(Level 1 的下一个)
target := update[0].level[0].forward
if target == nil || target.score != score || target.ele != ele {
return // 未找到
}
// 3. 逐层拆链:把 target 从每一层的链表中摘除
for i := 0; i < zsl.level; i++ {
if update[i].level[i].forward != target {
break // 这层没有 target(层数高于 target 的实际层数)
}
update[i].level[i].forward = target.level[i].forward
}
// 4. 更新 backward 指针
if target.level[0].forward != nil {
target.level[0].forward.backward = target.backward
} else {
zsl.tail = target.backward
}
// 5. 如果删除后最高层变空,降低跳表层数
for zsl.level > 1 && zsl.header.level[zsl.level-1].forward == nil {
zsl.level--
}
zsl.length--
}
```
> [!summary] 增删查的复杂度
> | 操作 | 时间复杂度 | 核心步骤 |
> |------|-----------|----------|
> | 查找 | O(logN) | 逐层下降 + 当层前跳 |
> | 插入 | O(logN) | 找前驱 + 逐层缝入 |
> | 删除 | O(logN) | 找前驱 + 逐层拆链 |
>
> 三者的"骨架"完全一样——都是**先定位前驱节点**,差别只在最后一步:查找是比对,插入是接链,删除是断链。
## 三、为什么 Redis 选跳表而不是红黑树?
这是经典面试题,也是理解 Redis 设计哲学的关键:
| 维度 | skiplist | 红黑树 |
|------|----------|--------|
| 实现复杂度 | 简单,插入只需调整指针 | 复杂,需要旋转 + 重着色 |
| 范围查询 | **天然支持**:从起点沿 Level 1 链表走到终点 | 需要中序遍历,实现复杂 |
| 并发友好 | 局部调整,锁粒度小 | 旋转影响大范围节点 |
| 内存布局 | 每个节点独立分配 + 级联式指针 | 同样,但多了颜色位和父指针 |
| 查找性能 | O(logN) **期望值** | O(logN) **最坏保证** |
> [!insight] 关键差异在范围查询
> ZSet 最高频的操作是 `ZRANGE`——"给我 score 在 100~200 之间的所有成员"。跳表只需定位到 100,然后沿 Level 1 链表往右走直到 200,**天然有序、天然支持**。红黑树做范围查询要写额外的中序遍历代码,还要处理边界条件。
>
> 这就是 Redis 作者 antirez 说的:"跳表足够好,而且实现简单。"
## 四、ZSet 为什么需要 skiplist + hashtable 两套结构?
> [!question] 一个数据类型配两套索引,不浪费吗?
> 它们分工明确:skiplist 负责**按 score 排序和范围查询**,hashtable 负责**按 member 名字 O(1) 定位**。没有 hashtable,`ZSCORE` 命令就要在 skiplist 上 O(logN) 查找;没有 skiplist,`ZRANGE` 就要全量扫描。
```mermaid
flowchart LR
K["ZSet key"] --> SL["skiplist, 按 score 排序, 范围查询 O(logN)"]
K --> HT["dict, 按 member 查找, O(1)"]
SL --> N1["member A, score 100"]
SL --> N2["member B, score 200"]
SL --> N3["member C, score 300"]
HT --> H1["member A -> score 100"]
HT --> H2["member B -> score 200"]
HT --> H3["member C -> score 300"]
classDef struct fill:#fff3e0,stroke:#ff9800
classDef data fill:#e8f5e9,stroke:#4caf50
class SL,HT struct
class N1,N2,N3,H1,H2,H3 data
```
**代价**:每个元素存了两份索引(skiplist 节点 + hashtable entry),内存开销比单一结构大。但这是**用空间换时间**的经典取舍——ZSet 的操作种类太多(排序、排名、范围查询、精确查找),一套结构很难同时满足。
### 内存估算
> [!tip] 生产环境参考
> 一个 ZSet 元素的大致内存占用:
> - skiplist 节点:约 24 字节(forward 指针数组) + member SDS + score(8B)
> - hashtable entry:约 24 字节(dictEntry) + member 指针 + value(score)
> - 平均下来每个元素约 **500 字节**(取决于 member 名长度)
>
> 100 万个元素 ≈ 500MB。大 ZSet 要考虑分片。
## 五、编码切换阈值
ZSet 同样有 ziplist/listpack → skiplist 的自动切换:
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `zset-max-ziplist-entries` | 128 | 元素数超过 128 → 切 skiplist |
| `zset-max-ziplist-value` | 64 字节 | 任意 member 长度超过 64B → 切 skiplist |
```go
// 1. 小 ZSet → ziplist/listpack 编码
for i := 0; i < 100; i++ {
rdb.ZAdd(ctx, "test:zset", redis.Z{Score: float64(i), Member: fmt.Sprintf("m%d", i)})
}
// OBJECT ENCODING test:zset → "ziplist" (Redis 7.2+ 显示 "listpack")
// 2. 加入超长 member → 触发编码切换
rdb.ZAdd(ctx, "test:zset", redis.Z{Score: 9999, Member: strings.Repeat("x", 100)})
// OBJECT ENCODING test:zset → "skiplist"
```
## 六、面试速记
> [!summary] 高频问答速查
> | 问题 | 关键回答 |
> |------|----------|
> | 跳表和红黑树怎么选? | 跳表实现简单,范围查询天然支持(沿底层链表走),并发锁粒度更小 |
> | ZSet 为什么同时用跳表和 hashtable? | 跳表管排序/范围查询,hashtable 管 O(1) 精确查找 `ZSCORE` |
> | Redis 跳表的晋升概率是多少? | p=0.25(1/4 概率升一层),最大 32 层 |
> | `span` 字段的作用? | 记录指针跨越的节点数,支持 O(logN) 计算排名 `ZRANK` |
> | 插入/删除的复杂度? | O(logN),核心都是"先找每层前驱",然后缝入/拆链 |
> | ZSet 什么时候从 ziplist 切到 skiplist? | 元素数 > 128 或任意 member 长度 > 64 字节 |
> | 跳表的空间复杂度? | O(N),每个元素约 1/(1-p) 个指针,p=0.25 时平均 1.33 个 |
## 关联笔记
- [[hhs/Redis/02-核心数据类型]] — 五种数据类型的编码切换全景
- [[hhs/Redis/02-核心数据类型/02-1-ziplist与listpack]] — skiplist 的"前任"ziplist 详解
- [[hhs/Redis/08-SortedSet精解]] — ZSet 的高级用法与性能优化