Redis 为什么使用跳表,而不是红黑树?
如果让你设计一个排行榜,你会怎么做?
假设现在有这样一份数据:
text
张三 98、李四 100,王五 95
业务提出了几个需求:
- 根据用户,快速查询他的分数。
- 根据分数,快速找到他的排名。
- 查询排行榜前 100 名。
- 用户分数变化后,能够快速重新排序。
这几个需求看起来很普通,但真正实现起来却没那么简单。
上一篇我们介绍了 Redis List 的演进过程,从 LinkedList 到 QuickList,本质上都是为了在不同数据结构之间寻找最佳平衡。今天要介绍的 ZSet,其实也是一样,它真正解决的问题不是"如何排序",而是如何在排序和查找之间取得平衡。
如果只用 Hash 表,会怎么样?
很多人的第一反应都是 HashMap。确实,根据用户查分数非常快。
例如:
text
Tom -> 98
Jack -> 100
Lucy -> 95
查询:
redis
ZSCORE rank Tom
几乎可以在 O(1) 时间内完成。但是,如果现在要查询:排行榜前十名
Hash 表就彻底没办法了,因为它根本没有顺序,你只能把所有数据取出来,再重新排序。
如果排行榜有一百万条数据,每次查询都重新排序,这个代价显然无法接受。
那直接用有序数组呢?
既然 Hash 表不能排序,那很多人自然会想到数组。
例如:
text
100
98
95
92
90
这样查询前十名非常简单,只需要从头遍历即可。但是问题又来了。
假设现在有人更新了分数:
text
95
↓
99
为了保持有序,后面的元素都需要移动。数据量越大,移动的数据越多。
排行榜这种场景,每天都有大量用户更新积分,如果每一次更新都移动大量数据,性能同样会越来越差。
红黑树不是最好的选择吗?
学过数据结构的人,很容易想到另外一种方案。
红黑树
它既能保持有序,又能保证:
- 查找 O(logN)
- 插入 O(logN)
- 删除 O(logN)
从时间复杂度来看,它几乎满足排行榜所有需求。
既然如此,Redis 为什么没有选择红黑树?
答案其实并不是因为红黑树性能差,而是工程实现太复杂。
红黑树最大的特点,是"严格平衡"
每一次插入节点,都有可能触发旋转。
例如:
text
5
/
3
/
2
为了继续保持平衡,红黑树需要:
- 左旋
- 右旋
- 染色
- 调整父子节点关系
这些操作最终都是为了维持红黑树的各种规则。
也就是说,每一次插入,都可能伴随着一系列调整。对于数据库来说,这当然没有问题。但 Redis 的目标,从来都不是实现一棵最标准的树,而是在足够快的前提下,让实现尽可能简单。
Redis 选择了一种更简单的方案
Redis 最终选择的是:
text
SkipList(跳表)
第一次看到这个名字,很多人都会觉得很陌生。其实,它的思想一点都不复杂。先来看最普通的链表。
text
1 → 2 → 3 → 4 → 5 → 6 → 7
如果查找数字 7,只能一个一个向后遍历。最坏情况下,需要走完整条链表。于是有人想到,为什么不增加一层"快速通道"?
text
1 --------> 4 --------> 7
│ │ │
▼ ▼ ▼
1 → 2 → 3 → 4 → 5 → 6 → 7
现在查找 7,就可以先走上面这一层,再进入下面的链表。如果数据继续增加,还可以继续增加第三层、第四层。最终形成这样一种结构:
text
Level3 1 -----------------------> 9
│ │
Level2 1 --------> 5 -----------> 9
│ │ │
Level1 1 ->2->3->4->5->6->7->8->9
这就是跳表。它本质上仍然是链表,只不过增加了多层索引。
跳表为什么这么快?
假设现在查找数字 8。如果普通链表,需要依次访问:
text
1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
而跳表可以这样走:
text
最高层
↓
快速定位到 5
↓
下降一层
↓
继续定位到 8
每一层都会排除掉大量节点,因此平均查找效率可以达到 O(logN),和红黑树几乎一样。也就是说,在时间复杂度上,跳表并没有输给红黑树。
那为什么偏偏是跳表?
真正让 Redis 选择跳表的,不是时间复杂度,而是工程实现。相比红黑树,跳表有几个明显优势。
第一,实现更加简单。
跳表没有各种旋转、染色和平衡规则,只需要维护不同层级的指针即可,源码远比红黑树容易理解和维护。
第二,范围查询更加自然。
排行榜最常见的操作,就是:
redis
ZRANGE rank 0 99
或者:
redis
ZRANGEBYSCORE rank 90 100
跳表本身就是有序链表,只要找到起点,就可以一直向后遍历,不需要频繁回到父节点,因此范围查询效率非常高。
第三,更容易支持并发修改。
虽然 Redis 命令执行是单线程的,但跳表插入、删除时,只需要修改少量节点关系;如果未来迁移到并发场景,它也比红黑树更容易实现细粒度控制,这也是很多数据库、中间件采用跳表的重要原因。
那 Hash 表去哪了?
看到这里,你可能会想到另一个问题。
上一篇我们介绍过 RedisObject,每种数据类型都可能对应多种底层结构。
ZSet 也是一样,Redis 并不是只使用跳表。真正的实现其实是:
text
HashTable
+
SkipList
Hash 表负责:
text
根据成员快速找到分数
跳表负责:
text
按照分数排序
两者各司其职。
查询成员时,不需要遍历跳表;查询排行榜时,也不用扫描 Hash 表。
正因为两种数据结构配合,ZSet 才同时具备了高效查找和高效排序的能力。
总结
很多人第一次学习 Redis,只记住了一句话:
ZSet 底层使用跳表。
真正值得思考的问题其实是:
Redis 为什么没有选择红黑树?
答案就在于 Redis 一直坚持的设计思想。
它追求的,从来不是理论上最完美的数据结构,而是在性能、实现复杂度和维护成本之间,找到一个最合适的平衡点。
跳表的时间复杂度和红黑树几乎一致,实现却简单得多,范围查询也更加友好,因此最终成为 Redis ZSet 的选择。
对于 Redis 来说,这不是一次算法竞赛,而是一次工程设计。
上一篇:《Redis 为什么不用链表保存 List?QuickList 到底是什么?》
下一篇:《Redis 为什么要同时支持 RDB 和 AOF?》
如果这篇文章让你真正理解了 Redis 为什么选择跳表,而不是红黑树,欢迎点赞、收藏。
你觉得如果让你重新设计 ZSet,还会选择红黑树吗?欢迎在评论区聊聊你的想法。