mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1545 字
4 分钟
为什么 Redis 使用跳表而不是红黑树
2024-01-28

Redis 的有序集合(Sorted Set,ZSET)使用跳表(Skip List)作为核心数据结构。这个选择初看反直觉——红黑树作为经典的平衡二叉搜索树,被广泛应用于 Java 的 TreeMap、C++ 的 std::map 等核心库,为什么 Redis 却选择了相对冷门的跳表?

一、跳表数据结构原理#

1.1 什么是跳表?#

跳表由 William Pugh 在 1989 年提出,是一种概率平衡的数据结构,通过多层索引实现快速查找。

flowchart TB subgraph L4["第 4 层(最高层)"] N4_1["3"] end subgraph L3["第 3 层"] N3_1["3"] --> N3_2["7"] end subgraph L2["第 2 层"] N2_1["3"] --> N2_2["7"] --> N2_3["11"] end subgraph L1["第 1 层(底层)"] N1_1["1"] --> N1_2["3"] --> N1_3["5"] --> N1_4["7"] --> N1_5["9"] --> N1_6["11"] --> N1_7["13"] end N4_1 -.-> N3_1 N3_1 -.-> N2_1 N3_2 -.-> N2_2 N2_1 -.-> N1_2 N2_2 -.-> N1_4 N2_3 -.-> N1_6 style N4_1 fill:#f96 style N3_1 fill:#9cf style N3_2 fill:#9cf style N2_1 fill:#9f9 style N2_2 fill:#9f9 style N2_3 fill:#9f9 style N1_1 fill:#ff9 style N1_2 fill:#ff9 style N1_3 fill:#ff9 style N1_4 fill:#ff9 style N1_5 fill:#ff9 style N1_6 fill:#ff9 style N1_7 fill:#ff9

核心思想:底层是一个有序链表,上层建立稀疏索引,形成”快速通道”。

1.2 跳表的查找过程#

查找元素 9 的过程:

sequenceDiagram participant L4 as 第4层 participant L3 as 第3层 participant L2 as 第2层 participant L1 as 第1层 Note over L4,L1: 从最高层开始 L4->>L4: 当前节点 3,下一节点 nil L4->>L3: 下沉到第3层 L3->>L3: 当前节点 3,下一节点 7 L3->>L3: 7 < 9,前进到 7 L3->>L3: 下一节点 nil L3->>L2: 下沉到第2层 L2->>L2: 当前节点 7,下一节点 11 L2->>L2: 11 > 9,下沉 L2->>L1: 下沉到第1层 L1->>L1: 当前节点 7,下一节点 9 L1->>L1: 9 == 9,找到!

时间复杂度:平均 O(log n),最坏 O(n)。

1.3 跳表的插入过程#

插入时,通过随机算法决定节点的层数:

// Redis 跳表层数计算(简化版)
int zslRandomLevel(void) {
int level = 1;
// 以 25% 概率继续增加层数
while ((random() & 0xFFFF) < (0.25 * 0xFFFF)) {
level++;
}
return level;
}

为什么是 25%?

flowchart LR subgraph 概率分布 L1["第1层: 100%"] L2["第2层: 25%"] L3["第3层: 6.25%"] L4["第4层: 1.56%"] L5["第5层: 0.39%"] end Note: 每层节点数约为下一层的 1/4<br/>保证了索引的稀疏性

1.4 跳表 vs 有序链表#

操作有序链表跳表红黑树
查找O(n)O(log n)O(log n)
插入O(n)O(log n)O(log n)
删除O(n)O(log n)O(log n)
范围查询O(n)O(log n+k)O(log n+k)
实现复杂度简单简单复杂
内存开销中等中等

二、Redis 有序集合的使用场景#

2.1 ZSET 的典型应用#

mindmap root((ZSET 应用场景)) 排行榜 游戏积分榜 热门话题榜 销量排行 带权重的集合 标签系统 用户偏好 推荐系统 延时队列 定时任务 消息延迟 订单超时 范围查询 时间范围 分数范围 地理位置

2.2 ZSET 的核心操作#

# 添加成员(带分数)
ZADD leaderboard 100 "player1"
ZADD leaderboard 200 "player2"
ZADD leaderboard 150 "player3"
# 范围查询:获取排名前 10
ZRANGE leaderboard 0 9 WITHSCORES
# 分数范围查询
ZRANGEBYSCORE leaderboard 100 200
# 获取成员排名
ZRANK leaderboard "player1"

2.3 ZSET 的底层实现#

Redis 7.0 之前,ZSET 使用跳表 + 哈希表的组合:

flowchart TB subgraph ZSET subgraph 哈希表 H1["player1 → 100"] H2["player2 → 200"] H3["player3 → 150"] end subgraph 跳表 S1["第2层: player2"] S2["第1层: player1 → player3 → player2"] end end Note: 哈希表用于 O(1) 分数查询<br/>跳表用于排序和范围查询

Redis 7.0 的变化:使用 listpack 替代 ziplist,小规模集合更节省内存。

三、为什么选择跳表而非红黑树?#

3.1 实现复杂度对比#

红黑树的复杂规则

flowchart TB subgraph 红黑树规则 R1["1. 节点是红色或黑色"] R2["2. 根节点是黑色"] R3["3. 叶子节点(nil)是黑色"] R4["4. 红色节点的子节点必须是黑色"] R5["5. 任意节点到叶子的路径包含相同数量黑节点"] end subgraph 平衡操作 L["左旋"] R["右旋"] RC["重新着色"] end R4 --> L R4 --> R R5 --> RC style R4 fill:#f66 style R5 fill:#f66

红黑树插入需要处理的场景

场景条件操作
Case 1叔叔节点是红色重新着色
Case 2叔叔节点是黑色,当前是右孩子左旋父节点
Case 3叔叔节点是黑色,当前是左孩子右旋祖父节点

跳表的简单规则

// 跳表插入核心代码(简化版)
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
// 1. 找到插入位置
// 2. 随机决定层数
// 3. 创建节点
// 4. 更新各层指针
// 完成!无需复杂的旋转和着色
}

代码量对比

数据结构核心代码行数复杂度评分
跳表~500 行
红黑树~1000+ 行
AVL 树~800 行

3.2 范围查询性能#

这是 Redis 选择跳表的核心原因

跳表的范围查询

flowchart LR subgraph 跳表范围查询 score: 5-11 S["起点"] --> N1["找到 >= 5 的起点"] N1 --> N2["沿底层链表遍历"] N2 --> N3["遍历 5, 7, 9, 11"] N3 --> E["遇到 > 11 停止"] end style N2 fill:#9f9 style N3 fill:#9f9

红黑树的范围查询

flowchart TB subgraph 红黑树范围查询 R["根节点"] --> L1["找到下界 5"] L1 --> L2["中序遍历"] L2 --> L3["需要维护栈或递归"] L3 --> L4["处理 5, 7, 9, 11"] end style L3 fill:#f66

性能对比

~0.5ms
# 范围查询性能测试(100万节点,查询1000个元素)
# 跳表
ZRANGEBYSCORE key 500000 500999
# 红黑树(模拟)
range_query(500000, 500999)
# 时间: ~0.8ms(包含额外的指针跳转)

跳表优势

  • 找到起点后,沿底层链表顺序遍历
  • 内存访问模式缓存友好
  • 无需回溯父节点,减少指针跳转

3.3 内存占用分析#

跳表的内存结构

// Redis 跳表节点定义
typedef struct zskiplistNode {
sds ele; // 成员对象
double score; // 分值
struct zskiplistNode *backward; // 后退指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 前进指针
unsigned long span; // 跨度
} level[]; // 柔性数组,层数动态
} zskiplistNode;

内存开销计算

假设:

  • 指针大小:8 字节
  • double 大小:8 字节
  • 平均层数:1.33 层(p = 0.25 时)
跳表节点大小 ≈
8 (ele指针) +
8 (score) +
8 (backward) +
1.33 * (8 + 8) (level数组)
= 42.64 字节/节点

红黑树的内存结构

// 红黑树节点定义
typedef struct RBNode {
void *key; // 8 字节
void *value; // 8 字节
struct RBNode *left; // 8 字节
struct RBNode *right; // 8 字节
struct RBNode *parent; // 8 字节
int color; // 4 字节 + 4 字节对齐
} RBNode;
红黑树节点大小 ≈ 48 字节/节点

对比结果

数据结构节点大小每节点指针数内存局部性
跳表~43 字节~2.66 个较好
红黑树~48 字节3 个较差

3.4 并发友好性#

跳表在并发场景下有天然优势。

跳表的 CAS 操作

sequenceDiagram participant T1 as 线程1 participant T2 as 线程2 participant N as 节点 T1->>N: 读取 next 指针 T2->>N: 读取 next 指针 T1->>N: CAS(next, old, new1) Note over N: 成功! T2->>N: CAS(next, old, new2) Note over N: 失败,重试 T2->>N: 重新读取 next T2->>N: CAS(next, new1, new2) Note over N: 成功!

跳表的无锁插入

// 跳表的无锁插入(简化版)
void concurrent_insert(skiplist *list, int key, int value) {
// 1. 找到插入位置(不需要锁)
node *prev = find_position(list, key);
// 2. 创建新节点
node *new_node = create_node(key, value);
// 3. CAS 设置 next 指针
do {
new_node->next = prev->next;
} while (!CAS(&prev->next, new_node->next, new_node));
// 4. 更新上层索引(同样可用 CAS)
}

红黑树的并发困难

flowchart TB subgraph 红黑树插入 I["插入节点"] --> R["可能触发旋转"] R --> C["可能触发重新着色"] C --> P["影响路径上多个节点"] P --> L["需要锁住整条路径"] end style R fill:#f66 style C fill:#f66 style L fill:#f66

并发性能对比

操作跳表红黑树
无锁读简单可能
无锁写可能困难
细粒度锁节点级需要路径锁
实现复杂度

3.5 antirez 的解释#

Redis 作者 antirez(Salvatore Sanfilippo)在邮件列表中解释:

“跳表在 Redis 中使用的主要原因:

  1. 实现简单
  2. 范围查询效率高
  3. 内存占用合理
  4. 与哈希表配合完美”

四、为什么不用 B+ 树?#

4.1 B+ 树的特点#

B+ 树专为磁盘存储设计:

flowchart TB subgraph B+ 树 R["根节点<br/>[20, 50]"] R --> L1["叶子节点<br/>[10, 15, 20]"] R --> M1["叶子节点<br/>[30, 40, 50]"] R --> H1["叶子节点<br/>[60, 70, 80]"] L1 <--> M1 <--> H1 end style R fill:#f96 style L1 fill:#9f9 style M1 fill:#9f9 style H1 fill:#9f9

4.2 Redis 不用 B+ 树的原因#

因素B+ 树优势Redis 的考量
存储介质磁盘友好Redis 是内存数据库
缓存行64 字节对齐内存中跳表更灵活
范围查询叶子链表跳表底层链表同样高效
实现复杂度中等跳表更简单
节点大小固定页大小内存中可变大小更灵活

B+ 树适合磁盘,跳表适合内存

五、其他数据库的选择#

5.1 LevelDB/RocksDB#

Google 的 LevelDB 和 Facebook 的 RocksDB 都使用跳表作为 MemTable:

flowchart LR subgraph LSM Tree W["写入"] --> MT["MemTable<br/>(跳表)"] MT -->|满| IM["Immutable MemTable<br/>(跳表)"] IM -->|Compaction| SST["SSTable<br/>(磁盘)"] end style MT fill:#9f9 style IM fill:#9f9

为什么 LevelDB 选择跳表?

// LevelDB 跳表注释
// Thread safety
// -------------
//
// Writes require external synchronization.
// Reads require a guarantee that the SkipList will not be deleted
// while the read is in progress.
//
// In addition, reads require an iterator to be created.
// The iterator is not thread-safe.

关键原因

  • MemTable 需要快速插入
  • 范围扫描效率高
  • 实现简单,便于维护

5.2 HBase#

HBase 同样使用跳表实现 MemStore:

// HBase MemStore 使用 ConcurrentSkipListMap
private final ConcurrentSkipListMap<Cell, Cell> cellSet;

5.3 对比总结#

数据库使用跳表的结构原因
RedisZSET (有序集合)范围查询 + 简单性
LevelDBMemTable写入性能 + 范围扫描
RocksDBMemTable同 LevelDB
HBaseMemStore并发写入 + 有序性
Java NIOConcurrentSkipListMap并发安全

六、跳表的缺点与适用场景#

6.1 跳表的缺点#

flowchart TB subgraph 跳表缺点 D1["内存开销较大<br/>多层指针"] D2["最坏情况 O(n)<br/>虽然概率极低"] D3["非确定性结构<br/>层数随机"] D4["不保证完美平衡<br/>依赖概率"] end style D2 fill:#f66 style D4 fill:#f66

最坏情况分析

虽然跳表平均 O(log n),但理论上可能退化:

# 极端情况:所有节点只有 1 层
# 概率:(0.75)^n,当 n=100 时约为 10^-12
# 实际中几乎不可能发生

6.2 适用场景#

跳表适合

flowchart LR subgraph 适合场景 S1["内存数据结构"] S2["频繁范围查询"] S3["需要并发访问"] S4["追求实现简单"] end style S1 fill:#9f9 style S2 fill:#9f9

红黑树适合

flowchart LR subgraph 适合场景 R1["需要严格 O(log n)"] R2["单点查询为主"] R3["内存敏感场景"] R4["成熟库支持"] end style R3 fill:#9f9 style R4 fill:#9f9

6.3 选择决策树#

flowchart TB Q1{"是否需要范围查询?"} Q2{"存储介质是内存还是磁盘?"} Q3{"是否需要并发写入?"} Q4{"实现复杂度是否敏感?"} Q1 -->|是| Q2 Q1 -->|否| A1["红黑树/哈希表"] Q2 -->|内存| Q3 Q2 -->|磁盘| A2["B+ 树"] Q3 -->|是| A3["跳表"] Q3 -->|否| Q4 Q4 -->|是| A3 Q4 -->|否| A4["红黑树"] style A3 fill:#9f9

七、Redis 7.0 的优化#

7.1 listpack 替代 ziplist#

Redis 7.0 对小规模 ZSET 的优化:

flowchart LR subgraph "元素数 <= 128 且元素大小 <= 64 字节" L["listpack<br/>紧凑存储"] end subgraph "超过阈值" S["跳表 + 哈希表<br/>高效查询"] end style L fill:#9f9 style S fill:#9cf

7.2 内部编码切换#

ZSET 编码选择:
- ziplist/listpack:小规模,紧凑存储
- skiplist + dict:大规模,高效查询
编码切换阈值:
- zset-max-ziplist-entries: 128
- zset-max-ziplist-value: 64

八、总结与设计权衡#

8.1 Redis 选择跳表的核心原因#

原因权重说明
范围查询性能ZSET 的核心操作
实现简单代码量少,易维护
内存局部性链表遍历缓存友好
并发友好无锁实现更容易
与哈希表配合O(1) 分数查询

8.2 设计哲学#

Redis 的选择体现了 “简单即好” 的设计哲学:

flowchart TB subgraph 设计原则 P1["够用就好<br/>不追求极致优化"] P2["简单优先<br/>减少 bug 风险"] P3["针对场景<br/>内存 + 范围查询"] P4["工程权衡<br/>综合考虑"] end P1 --> R["选择跳表"] P2 --> R P3 --> R P4 --> R style R fill:#f96

8.3 关键结论#

  1. 没有绝对最优的数据结构,只有最适合场景的选择
  2. 跳表在内存 + 范围查询场景下表现优异
  3. 实现简单是重要的工程考量
  4. Redis 的选择经过深思熟虑,而非随意决定

理解 Redis 选择跳表的原因,可以看到:数据结构的选择需要结合具体场景、访问模式、存储介质和工程实践综合考量

参考引用#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

为什么 Redis 使用跳表而不是红黑树
https://blog.souloss.com/posts/why-the-design/why-redis-uses-skiplist-instead-of-red-black-tree/
作者
Souloss
发布于
2024-01-28
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时