【技术干货】Redis 跳表实现原理深度剖析 | 为什么 Redis 选择了跳表?
在 Redis 的有序集合(zset)背后,藏着一个精妙的数据结构------跳表。它用"随机"的智慧,实现了 O(log n) 的高效查找。今天,让我们深入理解跳表的实现原理,以及 Redis 为什么选择它而不是红黑树。
📑 目录
- 一、从链表到跳表的演进
- 二、跳表的核心原理
- [三、Redis 中跳表的实现](#三、Redis 中跳表的实现 "#%E4%B8%89redis-%E4%B8%AD%E8%B7%B3%E8%A1%A8%E7%9A%84%E5%AE%9E%E7%8E%B0")
- [四、跳表 vs 平衡树](#四、跳表 vs 平衡树 "#%E5%9B%9B%E8%B7%B3%E8%A1%A8-vs-%E5%B9%B3%E8%A1%A1%E6%A0%91")
- [五、Python 实现跳表](#五、Python 实现跳表 "#%E4%BA%94python-%E5%AE%9E%E7%8E%B0%E8%B7%B3%E8%A1%A8")
- [六、Redis 跳表命令实战](#六、Redis 跳表命令实战 "#%E5%85%ADredis-%E8%B7%B3%E8%A1%A8%E5%91%BD%E4%BB%A4%E5%AE%9E%E6%88%98")
- 七、常见误区与避坑指南
- 八、总结
一、从链表到跳表的演进
1.1 普通链表的问题
想象一下,你有一个有序链表:
1 → 3 → 7 → 11 → 15 → 23 → 35 → 40 → 50
如果要查找 23,你需要从头开始遍历:
- 1 → 3 → 7 → 11 → 15 → 23
- 遍历了 6 次!
这是因为链表不支持随机访问,只能顺序遍历。
1.2 跳表的灵感:索引
让我们给链表加一层"索引":
makefile
一级索引: 1 → 7 → 15 → 35 → 50
底层链表: 1 → 3 → 7 → 11 → 15 → 23 → 35 → 40 → 50
现在找 23:
- 从一级索引的 1 开始
- 7 < 23,继续往右 → 15
- 15 < 23,继续往右 → 35
- 35 > 23,下沉到一层
- 从 15 开始往下找 → 23
只需要 4 次遍历!
1.3 更多层的索引
再加入二级索引:
现在找 23:
- 从二级索引的 15 开始
- 15 < 23,继续往右 → 50
- 50 > 23,下沉到一级索引的 35
- 35 > 23,下沉到链表的 15
- 从 15 开始往右找 → 23
只需要 3 次遍历!
这就是跳表的核心思想:用空间换时间,通过多层索引实现快速查找。
二、跳表的核心原理
2.1 随机层数:跳表的灵魂
跳表之所以高效,关键在于随机层数算法。
为什么需要随机?
如果每两个元素建一个索引:
makefile
索引: 1 → 7 → 15 → 35 → 50
底层: 1 → 3 → 7 → 11 → 15 → 23 → 35 → 40 → 50
这样确实能加快查找,但会导致:
- 索引层数固定,插入/删除时要重建大量索引
- 最坏情况还是 O(n)
Redis 的随机层数算法:
c
// Redis 源码 t_zset.c
#define ZSKIPLIST_MAXLEVEL 32 // 最大层数
#define ZSKIPLIST_P 0.25 // 每层被选中的概率
int zslRandomLevel(void) {
int level = 1;
// 每次有 25% 的概率升一层
while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
概率分析:
- 1 层:100%
- 2 层:25%
- 3 层:6.25%
- 4 层:1.56%
期望层数 = 1 / (1 - P) = 1 / 0.75 ≈ 1.33 层
这意味着平均每个节点只有 1.33 个前向指针,内存开销可控!
2.2 时间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 查找 | O(log n) | 从顶层开始,逐层下沉 |
| 插入 | O(log n) | 查找位置 + 插入节点 |
| 删除 | O(log n) | 查找节点 + 删除链接 |
| 范围查询 | O(log n + m) | m 为返回元素个数 |
为什么是 O(log n)?
假设有 n 个元素,平均层数为 L = 1.33。
- 每层最多遍历约 n/2 个元素
- 需要遍历 L 层
- 总复杂度 ≈ (n/2) * (1 + 1/4 + 1/16 + ...) ≈ n/2 * 4/3 = O(log n)
三、Redis 中跳表的实现
3.1 节点结构
c
// Redis 源码 server.h
typedef struct zskiplistNode {
// member:成员,SDS 字符串对象
robj *obj;
// score:分值,用于排序
double score;
// 后退指针,用于逆序遍历
struct zskiplistNode *backward;
// 每层的结构
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度:当前节点到下一个节点跨越的节点数
unsigned int span;
} level[];
} zskiplistNode;
3.2 跳表结构
c
typedef struct zskiplist {
// 头节点和尾节点
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 当前最大层数
int level;
} zskiplist;
3.3 结构图示
ini
跳表结构:
┌─────────────────────────────────────────────────────────┐
│ zskiplist │
│ ┌──────────────────────────────────────────────────┐ │
│ │ header ──────────► NULL │ │
│ │ tail ◄────────────────────────────────────────│ │
│ │ length = 5 │ │
│ │ level = 3 │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
节点示例:
┌────────────┬────────────┬────────────┬────────────┐
│ Level │ Forward │ Span │ │
├────────────┼────────────┼────────────┼────────────┤
│ 3 │ NULL │ 0 │ │
├────────────┼────────────┼────────────┼────────────┤
│ 2 │ ●──────┼───────────►│ span=2 │
├────────────┼────────────┼────────────┼────────────┤
│ 1 │ ●──────┼───────────►│ span=1 │
├────────────┼────────────┼────────────┼────────────┤
│ 0 │ ●──────┼──────►────►│ span=1 │
└────────────┴────────────┴────────────┴────────────┘
backward ◄────────────────────────────────────
3.4 zset 的双重结构
重要 :Redis 的 zset 实际上使用哈希表 + 跳表的组合:
c
typedef struct zset {
dict *dict; // 哈希表:O(1) 查找 member
zskiplist *zsl; // 跳表:O(log n) 按 score 排序
} zset;
这样设计的好处:
ZADD/ZREM:同时更新哈希表和跳表ZSCORE:从哈希表 O(1) 获取分数ZRANGE:从跳表 O(log n) 获取有序结果
四、跳表 vs 平衡树
4.1 核心对比
| 特性 | 跳表(Skip List) | 平衡树(AVL/红黑树) |
|---|---|---|
| 查找复杂度 | O(log n) | O(log n) |
| 插入/删除 | O(log n),局部调整 | O(log n),可能需要旋转 |
| 范围查询 | ✅ 简单,链表特性 | ❌ 需要中序遍历 |
| 实现复杂度 | ✅ 简单,约 200 行 | ❌ 复杂,平衡条件繁琐 |
| 内存占用 | 略高(多层指针) | 较低(固定节点结构) |
| 有序性 | 按 score 排序 | 按 key 排序 |
| 遍历方向 | ✅ 双向 | 单向 |
| 并发友好 | ✅ 锁粒度可更细 | 较差 |
4.2 Redis 为什么选择跳表?
1. 实现简单
红黑树需要维护平衡条件,代码量约 500+ 行;跳表只需随机层数,约 200 行。
2. 范围查询友好
ZRANGE leaderboard 90 100
跳表:找到 90 的位置,往右遍历即可 红黑树:需要中序遍历,效率较低
3. 有序性保证
- 跳表:按 score 排序,score 可以重复
- 红黑树:按 key 排序,key 必须唯一
zset 的 member 可以重复,score 可以重复,跳表更合适。
4. 并发友好
插入跳表:只需要锁定局部节点 插入红黑树:可能需要锁定从根到叶子节点的路径
五、Python 实现跳表
5.1 完整实现
python
import random
from typing import Optional, List
class SkipListNode:
"""跳表节点"""
def __init__(self, val: int, level: int = 1):
self.val = val
# 每层的下一个节点
self.forward: List[Optional[SkipListNode]] = [None] * level
# 每层的跨度
self.span: List[int] = [0] * level
class SkipList:
"""跳表实现"""
MAX_LEVEL = 16 # 最大层数
P = 0.25 # 每层被选中的概率
def __init__(self):
# 头节点,值为负无穷
self.header = SkipListNode(-float('inf'), self.MAX_LEVEL)
self.level = 1 # 当前最大层数
self.length = 0 # 节点数量
def random_level(self) -> int:
"""生成随机层数"""
level = 1
while random.random() < self.P and level < self.MAX_LEVEL:
level += 1
return level
def search(self, target: int) -> bool:
"""查找目标值"""
node = self.header
# 从最高层开始,逐层下沉
for i in range(self.level - 1, -1, -1):
while node.forward[i] and node.forward[i].val < target:
node = node.forward[i]
node = node.forward[0]
return node is not None and node.val == target
def insert(self, val: int) -> None:
"""插入值"""
update = [None] * self.MAX_LEVEL
rank = [0] * self.MAX_LEVEL
# 记录每层要更新的节点和累计跨度
node = self.header
for i in range(self.level - 1, -1, -1):
if i == self.level - 1:
rank[i] = 0
else:
rank[i] = rank[i + 1]
while node.forward[i] and node.forward[i].val < val:
rank[i] += node.span[i]
node = node.forward[i]
update[i] = node
# 生成新节点的层数
new_level = self.random_level()
if new_level > self.level:
for i in range(self.level, new_level):
rank[i] = 0
update[i] = self.header
self.level = new_level
# 创建新节点
new_node = SkipListNode(val, new_level)
for i in range(new_level):
new_node.forward[i] = update[i].forward[i]
update[i].forward[i] = new_node
new_node.span[i] = rank[0] - rank[i] + 1
self.length += 1
def delete(self, val: int) -> bool:
"""删除值"""
update = [None] * self.MAX_LEVEL
node = self.header
# 找到每层的前驱节点
for i in range(self.level - 1, -1, -1):
while node.forward[i] and node.forward[i].val < val:
node = node.forward[i]
update[i] = node
node = node.forward[0]
if node is None or node.val != val:
return False
# 删除节点
for i in range(self.level):
if update[i].forward[i] == node:
update[i].forward[i] = node.forward[i]
update[i].span[i] += node.span[i] - 1
else:
update[i].span[i] -= 1
self.length -= 1
return True
def range(self, start: int, end: int) -> List[int]:
"""范围查询"""
result = []
node = self.header
# 找到起始位置
for i in range(self.level - 1, -1, -1):
while node.forward[i] and node.forward[i].val < start:
node = node.forward[i]
node = node.forward[0]
while node and node.val <= end:
result.append(node.val)
node = node.forward[0]
return result
# 测试
if __name__ == "__main__":
sl = SkipList()
# 插入测试
for v in [3, 6, 7, 9, 12, 15, 18, 20]:
sl.insert(v)
print(f"节点数量: {sl.length}")
print(f"当前层数: {sl.level}")
# 查找测试
print(f"查找 9: {sl.search(9)}") # True
print(f"查找 10: {sl.search(10)}") # False
# 范围查询
print(f"范围查询 [7, 15]: {sl.range(7, 15)}") # [7, 9, 12, 15]
# 删除测试
sl.delete(9)
print(f"删除后查找 9: {sl.search(9)}") # False
5.2 运行结果
yaml
节点数量: 8
当前层数: 4
查找 9: True
查找 10: False
范围查询 [7, 15]: [7, 9, 12, 15]
删除后查找 9: False
六、Redis 跳表命令实战
6.1 基本操作
bash
# 添加元素(分数在前,成员在后)
ZADD leaderboard 100 "Alice"
ZADD leaderboard 95 "Bob"
ZADD leaderboard 98 "Charlie"
ZADD leaderboard 92 "David"
# 获取排名(从小到大)
ZRANK leaderboard "Alice" # 3(Alice 排第 4 名)
# 获取排名(从大到小)
ZREVRANK leaderboard "Alice" # 0(Alice 排第 1 名)
# 获取分数
ZSCORE leaderboard "Alice" # "100"
# 获取指定范围的元素(从小到大)
ZRANGE leaderboard 0 2 WITHSCORES
# 输出:
# 1) "David"
# 2) "92"
# 3) "Bob"
# 4) "95"
# 5) "Charlie"
# 6) "98"
# 获取指定范围的元素(从大到小)
ZREVRANGE leaderboard 0 2 WITHSCORES
# 输出:
# 1) "Alice"
# 2) "100"
# ...
6.2 场景应用
bash
# 排行榜:获取前 10 名
ZREVRANGE leaderboard 0 9 WITHSCORES
# 获取指定分数段的玩家
ZRANGEBYSCORE leaderboard 90 100
# 获取分数在 90-100 之间的玩家数量
ZCOUNT leaderboard 90 100
# 更新玩家分数
ZINCRBY leaderboard 5 "Alice" # Alice 的分数 +5
# 删除玩家
ZREM leaderboard "Alice"
七、常见误区与避坑指南
7.1 误区一:跳表层数越高越好
错误理解:既然层数越高查找越快,那就让所有节点都有 32 层。
真相:
- Redis 限制最大层数为 32
- 平均层数约 1.33(由 P=0.25 决定)
- 层数太高会增加内存开销和维护成本
7.2 误区二:Redis 总是用跳表实现 zset
错误理解:zset 底层就是跳表。
真相:
- 当元素数量 ≤
zset-max-ziplist-entries(默认 128)时,使用压缩列表(ziplist) - 元素超过阈值才切换到跳表
- 这是 Redis 的内存优化策略
7.3 误区三:跳表一定比红黑树好
错误理解:Redis 用了跳表,说明跳表全面优于红黑树。
真相:
- 纯查找性能:两者相当
- 内存占用:红黑树更低
- 实现复杂度:跳表更简单
- 范围查询:跳表更友好
7.4 误区四:忽略 zset 的双重结构
错误理解:zset 就是跳表。
真相:
- zset = 哈希表 + 跳表
- 哈希表:O(1) 查找 member
- 跳表:O(log n) 按 score 排序
7.5 误区五:并发场景下随意使用跳表
错误理解:跳表并发友好,所以在多线程环境下直接用。
真相:
- Redis 单线程不存在并发问题
- 其他语言/系统使用跳表时,需要自行处理并发控制
- 跳表的局部加锁确实比红黑树容易
八、总结
8.1 核心要点回顾
| 要点 | 说明 |
|---|---|
| 数据结构 | 多层索引的链表,每层是有序链表的"快速通道" |
| 核心原理 | 随机层数 + 逐层下沉查找 |
| 时间复杂度 | O(log n),与平衡树相当 |
| 空间复杂度 | O(n),平均每节点 1.33 个前向指针 |
| Redis 应用 | zset 的底层实现之一(元素多时使用) |
| 为什么选跳表 | 实现简单、范围查询友好、并发友好 |
8.2 一句话总结
跳表通过"随机层数"构建多层索引,实现 O(log n) 的高效查找;Redis 选择它是因为:实现比红黑树简单、范围查询比红黑树友好、并发放肆比红黑树容易!
如果这篇文章对你有帮助,欢迎点赞、评论、转发!