一、前言
老周写这篇文章的初衷是这样的,之前项目中有大量使用 Redis 的 ZSet 数据结构来实现各种排行榜的功能。老周以前也写过关于跳表的数据结构,但那是纯数据结构方面来分析的,今天我们就来从跳跃表在 Redis 中的底层实现方向来分析。我们都知道 Redis 有五种常用的数据结构:String、Hash、List、Set 以及 ZSet,其中 ZSet 是 Redis 提供的一个非常特别的数据结构,常用作排行榜等功能,以用户 id 为 value,关注时间或者分数作为 score 进行排序。
ZSet 有两种不同的实现,分别是 ziplist 和 skiplist。具体使用哪种结构进行存储,规则如下:
ziplist
:满足以下两个条件
- [value,score] 键值对数量少于 128 个
- 每个元素的长度小于 64 字节
skiplist
:不满足以上两个条件时使用跳表、组合了 hash 和 skiplist
- hash 用来存储 value 到 score 的映射,这样就可以在 O(1) 时间内找到 value 对应的分数
- skiplist 按照从小到大的顺序存储分数
- skiplist 每个元素的值都是 [value,score] 对
使用 ziplist 的示意图如下所示:
使用跳表时的示意图:
ziplist 压缩列表本文不是重点讨论范围,我们着重来看下跳跃表 skiplist。
二、什么是跳跃表(skiplist)
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。和链表、字典等数据结构被广泛地应用在 Redis 内部不同,Redis 只在两个地方用到了跳跃表,一个是实现有序集合健,另一个是在集群节点中用做内部数据结构,除此之外,跳跃表在 Redis 里没有其它用途。
我们来想一下,为啥 Redis 中这两个场景要选择 skiplist?
既然跳跃表是一种有序数据结构,那我们就来考虑在有序序列中查找某个特定元素的情境:
- 如果该序列用支持随机访问的线性结构(数组)存储,那么我们很容易地用二分查找来做。
- 但是考虑到增删效率和内存扩展性,很多时候要用不支持随机访问的线性结构(链表)存储,就只能从头遍历、逐个比对。
- 作为折衷,如果用二叉树结构(BST)存储,就可以不靠随机访问特性进行二分查找了。
我们知道,普通 BST 插入元素越有序效率越低,最坏情况会退化回链表。因此很多大佬提出了自平衡 BST 结构,使其在任何情况下的增删查操作都保持 O(logn) 的时间复杂度。自平衡 BST 的代表就是 AVL 树及其衍生出来的红黑树。如果推广之,不限于二叉树的话,我们耳熟能详的 B 树和 B+ 树也属此类,常用于文件系统和数据库。
自平衡BST显然很香,但是它仍然有一个不那么香的点:树的自平衡过程比较复杂,实现起来麻烦,在高并发的情况下,加锁也会带来可观的overhead。如AVL树需要LL、LR、RL、RR四种旋转操作保持平衡,红黑树则需要左旋、右旋和节点变色三种操作。下面的动图展示的就是AVL树在插入元素时的平衡过程。
那么,有没有简单点的、与自平衡 BST 效率相近的实现方法呢?答案就是跳跃表,并且它简单很多,下面我们就来看一看。
三、如何理解跳跃表
对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。
那怎么来提高查找效率呢?请看我下面画的图,在该链表中,每隔一个节点就有一个附加的指向它在表中前两个位置上的节点的链,正因为如此,在最坏的情形下,最多考察 n/2 + 1 个节点。比如我们要查 90 这个节点,按照之前单链表的查找的话要 8 个节点,现在只需 5 个节点。
我们来将这种想法扩展一下,得到下面的图,这里每隔 4 个节点就有一个链接到该节点前方的下一个第 4 节点的链,只有 n/4 + 1 个节点被考察。
这里我们利用数学的思想,针对通用性做扩展。每隔第 2^i 个节点就有一个链接到这个节点前方下一个第 2^i 个节点链。链的总个数仅仅是加倍,但现在在一次查找中最多只考察 logn 个节点。不难看到一次查找的总时间消耗为 O(logn),这是因为查找由向前到一个新的节点或者在同一节点下降到低一级的链组成。在一次查找期间每一步总的时间消耗最多为 O(logn)。注意,在这种数据结构中的查找基本上是折半查找(Binary Search)。
我只举了两个例子,这里你可以自己想象下大量数据也就是链表长度为 n 的时候,查找的效率更加的凸显出来了。
这种链表加多级索引的的结构,就是跳跃表。接下来我们来定量的分析下,用跳表查询到底有多快。
四、跳跃表的时间复杂度分析
我们知道,在一个单链表中查询某个数据的时间复杂度是 O(n)。那在一个具有多级索引的跳表中,查询某个数据的时间复杂度是多少呢?
我把问题分解一下,先来看这样一个问题,如果链表里有 n 个结点,会有多少级索引呢?
按照我们上面讲的,第一级索引的链节点个数大约就是 n/2 个,第二级索引的链节点个数大约就是 n/4 个,第三级索引的链节点个数大约就是 n/8 个,依次类推,也就是说,第 k 级索引的链节点个数是第 k-1 级索引的链节点个数的 1/2,那第 k 级索引节点的个数就是 n/(2k)。
假设索引有 h 级,最高级的索引有 2 个节点。通过上面的公式,我们可以得到 n/(2h)=2,从而求得 h=log2n-1。如果包含原始链表这一层,整个跳表的高度就是 log2n。我们在跳表中查询某个数据的时候,如果每一层都要遍历 m 个节点,那在跳表中查询一个数据的时间复杂度就是 O(m*logn)。
那这个 m 的值是多少呢?按照前面这种索引结构,我们每一级索引都最多只需要遍历 3 个结点,也就是说 m=3,为什么是 3 呢?我来解释一下。
假设我们要查找的数据是 x,在第 k 级索引中,我们遍历到 y节点之后,发现 x 大于 y,小于后面的节点 z,所以我们通过 y 的 down 指针,从第 k 级索引下降到第 k-1 级索引。在第 k-1 级索引中,y 和 z 之间只有 3 个节点(包含 y 和 z),所以,我们在 k-1 级索引中最多只需要遍历 3 个结点,依次类推,每一级索引都最多只需要遍历 3 个节点。
通过上面的分析,我们得到 m=3,所以在跳跃表中查询任意数据的时间复杂度就是 O(logn)。这个查找的时间复杂度跟二分查找是一样的。换句话说,我们其实是基于单链表实现了二分查找,前提是建立了很多级索引,也就是我们讲过的空间换时间的设计思路。
我们的时间复杂度很优秀,那跳跃表的空间复杂度是多少呢?
实际上,在软件开发中,我们不必太在意索引占用的额外空间。在讲数据结构和算法时,我们习惯性地把要处理的数据看成整数,但是在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。
五、跳跃表的底层结构
Redis 的跳跃表是由 redis.h/zskiplistNode 和 redis.h/zskiplist 两个结构定义,其中 zskiplistNode 用于表示跳跃节点,而 zskiplist 结构则用于保存跳跃表节点的相关信息,比如节点的数量以及指向表头节点和表尾节点的指针等等。
上图最左边的是 zskiplist 结构,该结构包含以下属性:
- header:指向跳跃表的表头节点
- tail:指向跳跃表的表尾节点
- level:记录目前跳跃表内,层数最大的那个节点层数(表头节点的层数不计算在内)
- length:记录跳跃表的长度,也就是跳跃表目前包含节点的数量(表头节点不计算在内)
位于 zskiplist 结构右侧是四个 zskiplistNode 结构,该结构包含以下属性:
- 层(level):节点中用 L1、L2、L3 等字样标记节点的各个层,L1 代表第一层,L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其它节点,而跨度则记录了前进指针所指向节点和当前节点的距离。
- 后退(backward)指针:节点中用 BW 字样标识节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
- 分值(score):各个节点中的 1.0、2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。
- 成员对象(obj):各个节点中的 o1、o2 和 o3 是节点所保存的成员对象。
我们接下来看下 Redis 是如何实现 skiplist 的。
5.1 结构定义
zskiplist 的结构定义:
zskiplistNode 的结构定义:
5.2 skiplist 创建
这里需要注意的是常量 ZSKIPLIST_MAXLEVEL,它定义了 zskiplist 的最大层数,值为 32,这也是节点最高只到 L32 的原因。
5.3 skiplist 插入节点
其大致执行流程如下:
- 按照前面讲过的查找流程,找到合适的插入位置。注意 zset 允许分数 score 相同,这时会根据节点数据 obj 的字典序来排序。
- 调用 zslRandomLevel() 方法,随机出要插入的节点的层数。
- 调用 zslCreateNode() 方法,根据层数 level、分数 score 和数据 obj 创建出新节点。
- 每层遍历,修改新节点以及其前后节点的前向指针 forward 和跳跃长度 span,也要更新最底层的后向指针 backward。
- 其中维护了两个数组 update 和 rank。update 数组用来记录每一层的最后一个分数小于待插入 score 的节点,也就是插入位置。rank 数组用来记录上述插入位置的上一个节点的排名,以便于最后更新 span 值。
5.4 skiplist 删除节点
5.5 skiplist 更新节点
更新的过程和插入的过程都是是使用着 zadd 方法的,先是判断这个 value 是否存在,如果存在就是更新的过程,如果不存在就是插入过程。在更新的过程是,如果找到了 Value,先删除掉,再新增,这样的弊端是会做两次的搜索,在性能上来讲就比较慢了,在 Redis 5.0 版本中,Redis 的作者 Antirez 优化了这个更新的过程,目前的更新过程是如果判断这个 value 是否存在,如果存在的话就直接更新,然后再调整整个跳跃表的 score 排序,这样就不需要两次的搜索过程。
5.6 skiplist 查找节点
六、skiplist 与平衡树、哈希表的比较
欢迎大家关注我的公众号【老周聊架构】,Java后端主流技术栈的原理、源码分析、架构以及各种互联网高并发、高性能、高可用的解决方案。
喜欢的话,点赞、再看、分享三连