缘起

Redis 相信大家都不陌生,由于它是基于内存的,所以它相比 MySQL 等数据库在处理速度上,要快上 N 个数量级。 

基于此,Redis 已经是现在面试中非问不可的知识点之一了。刚好前面两天写了一篇 Redis 文章,被群里网友讨论了起来。掀起了一股学习 Redis 源码的热潮,所以今天就趁热打铁解读一个面试必问的 Redis 实时排行榜。

Redis 实现排行榜有多火呢?你只要在百度搜索 "Redis 实现排行榜", 一大波文章会跳出来。基本都是围绕 Redis的有序集合—— zset 的 ZRANK 、ZADD 等命令展开。

redislabs上市 redis top10_redislabs上市

如上代码所示,更多的命令用法这里就不再展开了,我们重点说说这些命令背后的原理是什么?

说到这里,你在网上接着搜索,关于底层原理几乎没有文章谈起。所以我这里打算从 Redis 4.0.9 的源码入手,针对 如何在O(logN)时间内获取一个 Redis 有序集合中的元素的排名 这一问题进行分析,并且最后手撸一个简化版的排行榜的轮子以加深理解。

分析

Redis 的 zset 的获取元素排名的核心命令为 ZRANK,官方给出的复杂度是 O(log(N))。不懂时间复杂度的可以看我的这篇文章:常见算法的时间复杂度 Ο(1)<Ο(log2n)<Ο(n)<Ο(nlog2n)<Ο(n2)<Ο(n3)<…

redislabs上市 redis top10_数据结构_02

除了内存快于硬盘这个原因外,O(log(N)) 的时间复杂度也是根本上优于 MySQL 的 order by 进行排行的原因之二。那么我们自然好奇 Redis 是如何实现这一算法的。

在开始解毒源码之前,我们先来了解一下跳表。

跳表是神犇William Pugh于1990年发明的,这里会用最形象的语言讲清楚这玩意。

学过数据结构的都知道,数组和链表是最基本的线性数据结构,增删改查中,数组查快,增删改慢;链表恰好相反。 而且分别都是快到极致,数组 O(1) 时间完成查,链表 O(1) 时间完成增删改。他俩就是有得必有失的典型代表。但是有些问题需要增删改查都有不错的性能,于是聪明的人类发明了半线性的数据结构,就是一大票的树结构。例如最典型的平衡树,它能在均摊 O(logN) 时间内完成增删改查。针对特定问题,树结构成为不错的选择。但是 William Pugh 这哥们不信邪,非要使用链表这种线性数据结构搞出 O(logN) 的查询效果出来。于是办法就是空间换时间——多搞几层出来。 所以,典型的跳表长下面的样子(你可以将下面圆圈中的数字就视作 zset 中的分数)。

redislabs上市 redis top10_Redis_03

这里必须要指出跳表的特点

  1. 由若干层链表构成,每层都是有序的, 例如单调递增。
  2. 上层拥有的元素,下层一定都有,最底层拥有全部元素。

首先,跳表这玩意依旧是链表,所以完美的继承了链表的增删改的 O(1) 优良传统。其次,跳表查询的时候非常牛掰。

比如在上图中查询 117, 查询的轨迹如下图红线。

redislabs上市 redis top10_redislabs上市_04

具体说要经过如下几个步骤

(1) 比较 21,比 21 大,往后面找

(2) 比较 37,比 37大,比链表最大值小,从 37 的下面一层开始找

(3) 比较 71,比 71 大,比链表最大值小,从 71 的下面一层开始找

(4) 比较 85, 比 85 大,从后面找

(5) 比较 117, 等于 117, 找到了节点。

我们知道,链表哪怕有序,也得老老实实一个一个遍历的顺序遍历去找一个元素,花费时间是 O(N),而不能像数组那样二分查找,花费时间是O(logN)。

但是跳表呢? 如上图所示,我们跳着、跳着就把元素找到了。所以跳表的名字取的还是很到位的。所谓跳着跳着,意思就是不像链表一个一个元素的去遍历,而是大步流星的前进,找到一个元素。所以跳表查询的性能远胜于普通链表。

那么, 回到获取排名的话题, 我们怎么 O(logN) 时间获取一个元素的排名呢?  还是贴图,贴图秒懂~

redislabs上市 redis top10_数据结构_05

看到了吗? 我们想定位 13 这个元素在 zset 中的排名,只需要在跳表数据结构中额外维护一个叫 span 的域,它表示它在本层链表中到达下一个节点还需要走几步。上图中节点后面的圆括号里面的数字就是该节点的 span 域的值。例如第三层的节点 9 需要经过 9->10、10->13、13->14、14->15 走这 4 步才能来到它在本层链表的下一个节点 15,所以 9 的 span 是4。

有了 span 域之后,只需要在搜索 13 的过程中累加 span 即可得到。就像上图演示的那样,13 的排名是 4+2=6。

有了这些了解,我们再来回看 Redis 的源码就好懂很多了。

Redis 官网上显示,ZRANK 这一命令在 Redis2.0.0 开始就有了,我们翻看 Redis-4.0.9 的源码,就在 t_zset.c 文件中的zslGetRank 函数中。

redislabs上市 redis top10_redislabs上市_06

注释写的很明白,就是通过 key 和 score 在入参 zsl 中寻找该键的排名。zsl 就是跳表,Redis 能 O(logN) 时间定位海量元素中任何一个元素的排名就靠这玩意儿。

第 11 行的 for 循环中的第 16 行在干的事情就是在累加每一层 span。而且通过第 13 行~第 15 行我们知道了 Redis 底层的排名逻辑是分数是第一关键字,key(字典序)是第二关键字进行升序排序。

最后理解了上面的跳表的思想之后,我们就可以理解,为什么 Reids 的 ZRANK 那么快,因为用跳表来实现实时排行榜功能是再合适不过的了。

Java 中,ConcurrentSkipListMap、ConcurrentSkipListSet 就是基于跳表的。根据我面试过的人来看,基本上很少有人看过它的源码,后面有时间我来唠叨它。

那么看完本文,我的面试问题来了!Redis Zset 采用跳表而不是平衡树的原因是什么?

Redis Zset 作者是这么解释的:

redislabs上市 redis top10_链表_07

Redis 源码中还有非常多的精妙绝伦的设计,后面我们一起来揭开它的更多面纱吧!