20 KiB
tags, create time, author
| tags | create time | author | ||||
|---|---|---|---|---|---|---|
|
2026-05-25 10:30 | hhs |
skiplist(跳表)
概述
skiplist(跳表)是 Redis ZSet 的核心排序结构。它用多层索引把有序链表的查找从 O(N) 优化到 O(logN),实现简单、性能稳定——是 Redis 选择它而不是红黑树的原因。
一、从有序链表到跳表
[!question] 为什么不直接用有序链表? 有序链表查找要从头逐个遍历,O(N)。100 万个元素,最坏要走 100 万步。
加一层索引
如果我们每两个节点提取一个"索引指针",查找时先在索引层跳着走,跳过目标了再降下去——步数直接减半。
再多加几层索引呢?每层跳过上一层的节点,查找路径像"跳台阶"一样逐层逼近:
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 的过程
- Level 3:从 header 到 1,下一个是 NIL(跳过头了)
- 降到 Level 2:从 1 到 4,下一个 7 超了
- 降到 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 节点比教科书版多了几个字段:
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):
// 单层索引
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。
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 的配合示意:
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)
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。
简化搜索实现
把上面的文字描述翻译成代码,核心逻辑只有几行:
// 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] 读代码的两个关键点
- 外层循环:逐层下降,每一层只做"尽量往前跳"这一个动作——这就是"跳台阶"。
- rank 累加:跳过一个节点就加一次
span,降到 Level 1 时 rank 已经是精确排名,不需要再逐个数。
随机层数生成(randomLevel)
[!question] 每个节点的层数怎么来的? 不是预先算好的,而是插入时随机决定。Redis 的策略很直白:从 Level 1 开始,每次有
p=0.25的概率升一层,直到 32 层上限。
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.33vs 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] 插入一个新节点,要改哪些指针? 核心思路:找到每一层的"前驱节点",然后逐层缝入新节点。这就像拉链——先定位每一层的缺口,再把新节点的指针串进去。
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 计算细节,聚焦指针操作):
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,不影响其他层
- 不需要旋转:对比红黑树插入后可能触发的多次旋转+重着色,跳表的插入操作"一气呵成"
删除过程
删除和插入是对称操作:同样先找前驱,然后反向拆链。
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就要全量扫描。
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 |
// 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) 精确查找 ZSCORERedis 跳表的晋升概率是多少? 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 的高级用法与性能优化