第六篇:Redis 为什么使用跳表,而不是红黑树?

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,还会选择红黑树吗?欢迎在评论区聊聊你的想法。