Files
cs-note/hhs/Redis/02-核心数据类型/02-2-skiplist.md
T

521 lines
20 KiB
Markdown
Raw Normal View History

2026-05-25 20:51:57 +08:00
---
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 的高级用法与性能优化