注意:本系列文章分析的 Redis 源码版本:github.com/Sidfate/red… ,是文章发布时间的最新版。node
你们知道 redis 五种经常使用的数据结构有:字符串(string), 散列(hash), 列表(list), 集合(set)和有序集合(sorted set) 。相对而言 sorted set(如下简称为zset) 用的相对较少,它他的实现结构却颇有趣,这种结构被称为 跳跃列表 skiplist ,后面我还会结合一个平常生活的例子来解释它。git
首先简单介绍下 zset 是个啥有什么用,已经了解的童鞋能够直接到下一章节。github
有序集合的数据类型,相似于集合(set)和散列表(hash)之间的混合。有序集合和集合同样,元素是惟一的。而且有序集合中的每一个元素均可以对应一个score值,因此它也像一个散列表。另外,有序集合中的元素能够根据score值进行排序遍历。redis
看了上面的介绍,你可能已经猜到了,zset 是 2 种数据结构的结合,在源码的注释中是这么解释的:数组
Zset 是使用了 2 个数据结构来保存相同元素的有序集合,同时还能保证复杂度为 O(log(N)) 的插入和删除操做。Zset 中的元素被添加到一个散列表中,保存着 Redis对象 - score 的映射关系。同时,这些元素被添加到一个跳跃列表 skiplist 中,将 score 映射到 Redis对象(对象根据 score 排序)。数据结构
注意下这句: “同时还能保证复杂度为 O(log(N)) 的插入和删除操做”,有没有一种婆婆介绍儿子的赶脚,言语间透露的自豪感,请记住它,下面我还会提到。散列表(hash),在以前的文章中已经分析过了,具体能够查看个人文章《【最完整系列】Redis-结构篇-字典》,因此再也不说明了,接下来着重分析下 skiplist。less
跳跃列表在很早以前就已经被发明了,有兴趣的能够看下[它的历史](Skip Lists: A Probabilistic Alternative to Balanced Trees)。首先从名字上看它是一个 list,而且咱们以前说过它仍是有序的。通常来讲,有序链表长这个样子:dom
最左侧节点为空的头节点,a 是我本身取得名字,方便作区分。函数
思考下,咱们插入一个新的元素 “23” 须要怎么作,首先要遍历链表,比较节点元素直到找到一个大于 “23” 的元素,因此复杂度为 O(N),删除某个元素也是一个道理,大家发现没,其实添加和删除后就是一个查询的过程。post
为此,若是咱们稍作优化,小小地改变一下链表的结构,为相邻的节点增长一个指针,指向下下个节点:
上图中能够发现造成了一个新链表 b(7 - 19 - 26),节点个数为原先链表,这时候咱们从新去查询 “23” :
大家有没有发现,是否是很相似于二分查找,最终咱们减小了查询的次数,咱们甚至还能够再分一次建立一个新链表 c:
这时候的查询步骤变成了:
能够发现咱们遍历的元素个数在逐渐减小,可想而知若是包含的元素个数足够大,查询的效率也会大幅提高。下面我举一个平常生活的例子来讲明这种作法的优点:
咱们在有序链表中查询一个元素的过程就比如在酒店里坐电梯。假如酒店有10层楼高,1-5层是普通套房,住的人多;6-10层是高级套房,住的人少。我住在9楼(嘿嘿嘿),那么我坐电梯下去 1 层的时候极可能在各个低层会停留(因低层住的人多),这对于高层客人确定不爽。后面酒店改了,新造了电梯,分红了单双停靠,对我来讲确定是比以前更快了,可是一、三、5层仍是会常常停靠,高层客人仍是不满意。在以后酒店作绝了,直接造了一个1-5层不停留直达高层的电梯,这下舒服了,客户评价立刻上去了。
若是你看懂了上面的例子,其实也发现了这个作法的一个劣势:须要造更多的 “电梯“,也就是须要建立更多的链表,黑话叫 “空间换时间”。
咱们的 skiplist 正是在上面这种多层链表基础上设计而来的。实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,相似于一个二分查找,使得查找的时间复杂度能够下降到 O(log n) (还记得我以前让你记住的那个复杂度吗?)。可是,这种方法在插入数据的时候有很大的问题。新插入一个节点以后,就会破坏了上下相邻两层链表上节点个数 2:1 的比例关系。要维持这种对应关系,就必须把新插入的节点后面的全部节点(也包括新插入的节点)从新进行调整,这会让时间复杂度又下降成了 O(n),删除节点也同样。
shiplist 固然也考虑到这个问题,为此,它不要求上下相邻两层链表之间的节点个数有严格的比例关系,而是每一个节点随机一个层数 level 。好比,一个节点随机出的层数是 3,那么就把它链入到第 1 层到第 3 层这三层链表中。为了表达清楚,下图展现了如何经过一步步的插入操做从而造成一个skiplist的过程:
为了减小一部分童鞋的疑惑,我先总结一下上图过程的 3 个特色:
前方高晕预警,如下内容涉及一些几率学和数学知识,摘自发明者的论文。能够直接跳过查看下一章节。
比较有意思的一点是元素的层数随机,这意味着 skiplist 是一个 “几率型” 的数据结构。实际上决定层数的随机计算对跳表的查找性能有着很大影响,这并非一个普通的服从均匀分布的随机数,它的计算过程以下:
伪代码以下:
randomLevel()
level = 1
// random()返回一个[0...1)的随机数
while random() < p and level < MaxLevel do
level := level + 1
return level
复制代码
在 Redis 的 skiplist 实现中,p=1/4 ,MaxLevel=64。
Redis 在 skiplist 的基础结构上作了一些变化来知足本身的需求,首先 show you source code:
#define ZSKIPLIST_MAXLEVEL 64 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25 /* Skiplist P = 1/4 */
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
复制代码
ZSKIPLIST_MAXLEVEL
和 ZSKIPLIST_P
2个常量就是咱们上一章节最后提到的部分。
属性 | 含义 |
---|---|
header | 头指针 |
tail | 尾指针 |
length | 链表长度,即链表包含的节点总数,不包含头指针。 |
level | skiplist 的总层数 |
为何 zskiplistNode 的前向指针 backward 只有一个?
我发现基本上没有文章提到这一点,可是我以为仍是有必要解释下的。首先节点只有一个后向指针,也就意味着只有第一层的链表是一个双向链表,以前咱们的例子里的链表都是单向的,为何要把第一层变成双向呢?缘由之一是第一层的数据最完整,缘由之二是:试想一下,咱们有一个元素的 score 为 8,其第一层的相邻节点 score 为 7 和 10,如今咱们想要更新这个元素的 score 为 9,理论上咱们要删除在插入,但其实这个元素的位置根本不须要改动,这种状况下能够先判断第一层相邻节点的大小,若是仍是在区间内,就直接更新值,省去了删除插入的步骤。
level 中 span 的意义?
解释这个问题须要图片帮助,首先放一个 skiplist 的图:
箭头中上的数字就是 span 的值,span有不少好处,例如咱们要找score为 3 的排名,直接取头指针中 L5 的span = 3 就好了,若是存在须要多层查询的状况就是累加的过程,反之还能够经过长度-累加值的操做计算逆序的排名。
属性 | 含义 |
---|---|
ele | 数据本体。这里能够看到它是一个 sds 结构,sds 是 redis 中的字符串结构。关于 sds 结构的结构的详情你参考个人文章《【最完整系列】Redis-结构篇-字符串》。 |
score | 数据对应的分数。 |
backward | 指向链表前一个节点的指针(前向指针)。 |
level[] | zskiplistLevel 数组,存放指向各层链表后一个节点的指针(后向指针)。 |
level[].forward | 表示单层的后向指针。 |
level[].span | 表示当前的指针跨越了多少个节点。 |
总结下 redis 针对 skiplist 作出的 3 点调整:
扩展一下,看看做者是怎么说的:
They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
也不是很是耗费内存,实际上取决于生成层数函数里的几率 p,取决得当的话其实和平衡树差很少。
A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
由于有序集合常常会进行 ZRANGE 或 ZREVRANGE 这样的范围查找操做,跳表里面的双向链表能够十分方便地进行这类操做。
They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.
实现简单,ZRANK 操做还能达到 O(logN) 的时间复杂度。