Redis 的有序集合(Sorted Set,ZSET)使用跳表(Skip List)作为核心数据结构。这个选择初看反直觉——红黑树作为经典的平衡二叉搜索树,被广泛应用于 Java 的 TreeMap、C++ 的 std::map 等核心库,为什么 Redis 却选择了相对冷门的跳表?
一、跳表数据结构原理
1.1 什么是跳表?
跳表由 William Pugh 在 1989 年提出,是一种概率平衡的数据结构,通过多层索引实现快速查找。
核心思想:底层是一个有序链表,上层建立稀疏索引,形成”快速通道”。
1.2 跳表的查找过程
查找元素 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%?
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 的典型应用
2.2 ZSET 的核心操作
# 添加成员(带分数)ZADD leaderboard 100 "player1"ZADD leaderboard 200 "player2"ZADD leaderboard 150 "player3"
# 范围查询:获取排名前 10ZRANGE leaderboard 0 9 WITHSCORES
# 分数范围查询ZRANGEBYSCORE leaderboard 100 200
# 获取成员排名ZRANK leaderboard "player1"2.3 ZSET 的底层实现
Redis 7.0 之前,ZSET 使用跳表 + 哈希表的组合:
Redis 7.0 的变化:使用 listpack 替代 ziplist,小规模集合更节省内存。
三、为什么选择跳表而非红黑树?
3.1 实现复杂度对比
红黑树的复杂规则:
红黑树插入需要处理的场景:
| 场景 | 条件 | 操作 |
|---|---|---|
| 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 选择跳表的核心原因。
跳表的范围查询:
红黑树的范围查询:
性能对比:
# 范围查询性能测试(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 操作:
跳表的无锁插入:
// 跳表的无锁插入(简化版)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)}红黑树的并发困难:
并发性能对比:
| 操作 | 跳表 | 红黑树 |
|---|---|---|
| 无锁读 | 简单 | 可能 |
| 无锁写 | 可能 | 困难 |
| 细粒度锁 | 节点级 | 需要路径锁 |
| 实现复杂度 | 低 | 高 |
3.5 antirez 的解释
Redis 作者 antirez(Salvatore Sanfilippo)在邮件列表中解释:
“跳表在 Redis 中使用的主要原因:
- 实现简单
- 范围查询效率高
- 内存占用合理
- 与哈希表配合完美”
四、为什么不用 B+ 树?
4.1 B+ 树的特点
B+ 树专为磁盘存储设计:
4.2 Redis 不用 B+ 树的原因
| 因素 | B+ 树优势 | Redis 的考量 |
|---|---|---|
| 存储介质 | 磁盘友好 | Redis 是内存数据库 |
| 缓存行 | 64 字节对齐 | 内存中跳表更灵活 |
| 范围查询 | 叶子链表 | 跳表底层链表同样高效 |
| 实现复杂度 | 中等 | 跳表更简单 |
| 节点大小 | 固定页大小 | 内存中可变大小更灵活 |
B+ 树适合磁盘,跳表适合内存。
五、其他数据库的选择
5.1 LevelDB/RocksDB
Google 的 LevelDB 和 Facebook 的 RocksDB 都使用跳表作为 MemTable:
为什么 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 使用 ConcurrentSkipListMapprivate final ConcurrentSkipListMap<Cell, Cell> cellSet;5.3 对比总结
| 数据库 | 使用跳表的结构 | 原因 |
|---|---|---|
| Redis | ZSET (有序集合) | 范围查询 + 简单性 |
| LevelDB | MemTable | 写入性能 + 范围扫描 |
| RocksDB | MemTable | 同 LevelDB |
| HBase | MemStore | 并发写入 + 有序性 |
| Java NIO | ConcurrentSkipListMap | 并发安全 |
六、跳表的缺点与适用场景
6.1 跳表的缺点
最坏情况分析:
虽然跳表平均 O(log n),但理论上可能退化:
# 极端情况:所有节点只有 1 层# 概率:(0.75)^n,当 n=100 时约为 10^-12# 实际中几乎不可能发生6.2 适用场景
跳表适合:
红黑树适合:
6.3 选择决策树
七、Redis 7.0 的优化
7.1 listpack 替代 ziplist
Redis 7.0 对小规模 ZSET 的优化:
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 的选择体现了 “简单即好” 的设计哲学:
8.3 关键结论
- 没有绝对最优的数据结构,只有最适合场景的选择
- 跳表在内存 + 范围查询场景下表现优异
- 实现简单是重要的工程考量
- Redis 的选择经过深思熟虑,而非随意决定
理解 Redis 选择跳表的原因,可以看到:数据结构的选择需要结合具体场景、访问模式、存储介质和工程实践综合考量。
参考引用
- Skip Lists: A Probabilistic Alternative to Balanced Trees — William Pugh 原始论文
- Redis Sorted Set Source Code — Redis 官方实现
- Redis ZSET Commands — Redis 官方文档
- LevelDB SkipList Implementation — LevelDB 源码
- Why Redis uses Skip List — antirez 在 HN 的讨论
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






