Redis 为何用跳表而不用平衡树?

本文是《Redis内部数据结构详解》系列的第六篇。在本文中,咱们围绕一个Redis的内部数据结构——skiplist展开讨论。javascript

Redis里面使用skiplist是为了实现sorted set这种对外的数据结构。sorted set提供的操做很是丰富,能够知足很是多的应用场景。这也意味着,sorted set相对来讲实现比较复杂。同时,skiplist这种数据结构对于不少人来讲都比较陌生,由于大部分学校里的算法课都没有对这种数据结构进行过详细的介绍。所以,为了介绍得足够清楚,本文会比这个系列的其它几篇花费更多的篇幅。java

咱们将大致分红三个部分进行介绍:node

  1. 介绍经典的skiplist数据结构,并进行简单的算法分析。这一部分的介绍,与Redis没有直接关系。我会尝试尽可能使用通俗易懂的语言进行描述。
  2. 讨论Redis里的skiplist的具体实现。为了支持sorted set自己的一些要求,在经典的skiplist基础上,Redis里的相应实现作了若干改动。
  3. 讨论sorted set是如何在skiplist, dict和ziplist基础上构建起来的。

咱们在讨论中还会涉及到两个Redis配置(在redis.conf中的ADVANCED CONFIG部分):redis

zset-max-ziplist-entries 128
zset-max-ziplist-value 64复制代码

咱们在讨论中会详细解释这两个配置的含义。算法

注:本文讨论的代码实现基于Redis源码的3.2分支。跨域

skiplist数据结构简介

skiplist本质上也是一种查找结构,用于解决算法中的查找问题(Searching),即根据给定的key,快速查到它所在的位置(或者对应的value)。数组

咱们在《Redis内部数据结构详解》系列的第一篇中介绍dict的时候,曾经讨论过:通常查找问题的解法分为两个大类:一个是基于各类平衡树,一个是基于哈希表。但skiplist却比较特殊,它无法归属到这两大类里面。网络

这种数据结构是由William Pugh发明的,最先出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。对细节感兴趣的同窗能够下载论文原文来阅读。数据结构

skiplist,顾名思义,首先它是一个list。实际上,它是在有序链表的基础上发展起来的。less

咱们先来看一个有序链表,以下图(最左侧的灰色节点表示一个空的头结点):

在这样一个链表中,若是咱们要查找某个数据,那么须要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止(没找到)。也就是说,时间复杂度为O(n)。一样,当咱们要插入新数据的时候,也要经历一样的查找过程,从而肯定插入位置。

假如咱们每相邻两个节点增长一个指针,让指针指向下下个节点,以下图:

这样全部新增长的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。如今当咱们想查找数据的时候,能够先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再回到原来的链表中进行查找。好比,咱们想查找23,查找的路径是沿着下图中标红的指针所指向的方向进行的:

  • 23首先和7比较,再和19比较,比它们都大,继续向后比较。
  • 但23和26比较的时候,比26要小,所以回到下面的链表(原链表),与22比较。
  • 23比22要大,沿下面的指针继续向后和26比较。23比26小,说明待查数据23在原链表中不存在,并且它的插入位置应该在22和26之间。

在这个查找过程当中,因为新增长的指针,咱们再也不须要与链表中每一个节点逐个进行比较了。须要比较的节点数大概只有原来的一半。

利用一样的方式,咱们能够在上层新产生的链表上,继续为每相邻的两个节点增长一个指针,从而产生第三层链表。以下图:

在这个新的三层链表结构上,若是咱们仍是查找23,那么沿着最上层链表首先要比较的是19,发现23比19大,接下来咱们就知道只须要到19的后面去继续查找,从而一会儿跳过了19前面的全部节点。能够想象,当链表足够长的时候,这种多层链表的查找方式能让咱们跳过不少下层节点,大大加快查找的速度。

skiplist正是受这种多层链表的想法的启发而设计出来的。实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就很是相似于一个二分查找,使得查找的时间复杂度能够下降到O(log n)。可是,这种方法在插入数据的时候有很大的问题。新插入一个节点以后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。若是要维持这种对应关系,就必须把新插入的节点后面的全部节点(也包括新插入的节点)从新进行调整,这会让时间复杂度从新蜕化成O(n)。删除数据也有一样的问题。

skiplist为了不这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每一个节点随机出一个层数(level)。好比,一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中。为了表达清楚,下图展现了如何经过一步步的插入操做从而造成一个skiplist的过程(点击看大图):

从上面skiplist的建立和插入过程能够看出,每个节点的层数(level)是随机出来的,并且新插入一个节点不会影响其它节点的层数。所以,插入操做只须要修改插入节点先后的指针,而不须要对不少节点都进行调整。这就下降了插入操做的复杂度。实际上,这是skiplist的一个很重要的特性,这让它在插入性能上明显优于平衡树的方案。这在后面咱们还会提到。

根据上图中的skiplist结构,咱们很容易理解这种数据结构的名字的由来。skiplist,翻译成中文,能够翻译成“跳表”或“跳跃表”,指的就是除了最下面第1层链表以外,它会产生若干层稀疏的链表,这些链表里面的指针故意跳过了一些节点(并且越高层的链表跳过的节点越多)。这就使得咱们在查找数据的时候可以先在高层的链表中进行查找,而后逐层下降,最终降到第1层链表来精确地肯定数据位置。在这个过程当中,咱们跳过了一些节点,从而也就加快了查找速度。

刚刚建立的这个skiplist总共包含4层链表,如今假设咱们在它里面依然查找23,下图给出了查找路径:

须要注意的是,前面演示的各个节点的插入过程,实际上在插入以前也要先经历一个相似的查找过程,在肯定插入位置后,再完成插入操做。

至此,skiplist的查找和插入操做,咱们已经很清楚了。而删除操做与插入操做相似,咱们也很容易想象出来。这些操做咱们也应该能很容易地用代码实现出来。

固然,实际应用中的skiplist每一个节点应该包含key和value两部分。前面的描述中咱们没有具体区分key和value,但实际上列表中是按照key进行排序的,查找过程也是根据key在比较。

可是,若是你是第一次接触skiplist,那么必定会产生一个疑问:节点插入时随机出一个层数,仅仅依靠这样一个简单的随机数操做而构建出来的多层链表结构,能保证它有一个良好的查找性能吗?为了回答这个疑问,咱们须要分析skiplist的统计性能。

在分析以前,咱们还须要着重指出的是,执行插入操做时计算随机数的过程,是一个很关键的过程,它对skiplist的统计特性有着很重要的影响。这并非一个普通的服从均匀分布的随机数,它的计算过程以下:

  • 首先,每一个节点确定都有第1层指针(每一个节点都在第1层链表里)。
  • 若是一个节点有第i层(i>=1)指针(即节点已经在第1层到第i层链表中),那么它有第(i+1)层指针的几率为p。
  • 节点最大的层数不容许超过一个最大值,记为MaxLevel。

这个计算随机层数的伪码以下所示:

randomLevel()
    level := 1
    // random()返回一个[0...1)的随机数
    while random() < p and level < MaxLevel do level := level + 1
    return level复制代码

randomLevel()的伪码中包含两个参数,一个是p,一个是MaxLevel。在Redis的skiplist实现中,这两个参数的取值为:

p = 1/4
MaxLevel = 32复制代码

skiplist的算法性能分析

在这一部分,咱们来简单分析一下skiplist的时间复杂度和空间复杂度,以便对于skiplist的性能有一个直观的了解。若是你不是特别偏执于算法的性能分析,那么能够暂时跳过这一小节的内容。

咱们先来计算一下每一个节点所包含的平均指针数目(几率指望)。节点包含的指针数目,至关于这个算法在空间上的额外开销(overhead),能够用来度量空间复杂度。

根据前面randomLevel()的伪码,咱们很容易看出,产生越高的节点层数,几率越低。定量的分析以下:

  • 节点层数至少为1。而大于1的节点层数,知足一个几率分布。
  • 节点层数刚好等于1的几率为1-p。
  • 节点层数大于等于2的几率为p,而节点层数刚好等于2的几率为p(1-p)。
  • 节点层数大于等于3的几率为p2,而节点层数刚好等于3的几率为p2(1-p)。
  • 节点层数大于等于4的几率为p3,而节点层数刚好等于4的几率为p3(1-p)。
  • ......

所以,一个节点的平均层数(也即包含的平均指针数目),计算以下:

如今很容易计算出:

  • 当p=1/2时,每一个节点所包含的平均指针数目为2;
  • 当p=1/4时,每一个节点所包含的平均指针数目为1.33。这也是Redis里的skiplist实如今空间上的开销。

接下来,为了分析时间复杂度,咱们计算一下skiplist的平均查找长度。查找长度指的是查找路径上跨越的跳数,而查找过程当中的比较次数就等于查找长度加1。之前面图中标出的查找23的查找路径为例,从左上角的头结点开始,一直到结点22,查找长度为6。

为了计算查找长度,这里咱们须要利用一点小技巧。咱们注意到,每一个节点插入的时候,它的层数是由随机函数randomLevel()计算出来的,并且随机的计算不依赖于其它节点,每次插入过程都是彻底独立的。因此,从统计上来讲,一个skiplist结构的造成与节点的插入顺序无关。

这样的话,为了计算查找长度,咱们能够将查找过程倒过来看,从右下方第1层上最后到达的那个节点开始,沿着查找路径向左向上回溯,相似于爬楼梯的过程。咱们假设当回溯到某个节点的时候,它才被插入,这虽然至关于改变了节点的插入顺序,但从统计上不影响整个skiplist的造成结构。

如今假设咱们从一个层数为i的节点x出发,须要向左向上攀爬k层。这时咱们有两种可能:

  • 若是节点x有第(i+1)层指针,那么咱们须要向上走。这种状况几率为p。
  • 若是节点x没有第(i+1)层指针,那么咱们须要向左走。这种状况几率为(1-p)。

这两种情形以下图所示:

用C(k)表示向上攀爬k个层级所须要走过的平均查找路径长度(几率指望),那么:

C(0)=0
C(k)=(1-p)×(上图中状况b的查找长度) + p×(上图中状况c的查找长度)复制代码

代入,获得一个差分方程并化简:

C(k)=(1-p)(C(k)+1) + p(C(k-1)+1)
C(k)=1/p+C(k-1)
C(k)=k/p复制代码

这个结果的意思是,咱们每爬升1个层级,须要在查找路径上走1/p步。而咱们总共须要攀爬的层级数等于整个skiplist的总层数-1。

那么接下来咱们须要分析一下当skiplist中有n个节点的时候,它的总层数的几率均值是多少。这个问题直观上比较好理解。根据节点的层数随机算法,容易得出:

  • 第1层链表固定有n个节点;
  • 第2层链表平均有n*p个节点;
  • 第3层链表平均有n*p2个节点;
  • ...

因此,从第1层到最高层,各层链表的平均节点数是一个指数递减的等比数列。容易推算出,总层数的均值为log1/pn,而最高层的平均节点数为1/p。

综上,粗略来计算的话,平均查找长度约等于:

  • C(log1/pn-1)=(log1/pn-1)/p

即,平均时间复杂度为O(log n)。

固然,这里的时间复杂度分析仍是比较粗略的。好比,沿着查找路径向左向上回溯的时候,可能先到达左侧头结点,而后沿头结点一路向上;还可能先到达最高层的节点,而后沿着最高层链表一路向左。但这些细节不影响平均时间复杂度的最后结果。另外,这里给出的时间复杂度只是一个几率平均值,但实际上计算一个精细的几率分布也是有可能的。详情还请参见William Pugh的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。

skiplist与平衡树、哈希表的比较

  • skiplist和各类平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。所以,在哈希表上只能作单个key的查找,不适宜作范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的全部节点。
  • 在作范围查找的时候,平衡树比skiplist操做要复杂。在平衡树上,咱们找到指定范围的小值以后,还须要以中序遍历的顺序继续寻找其它不超过大值的节点。若是不对平衡树进行必定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就很是简单,只须要在找到小值以后,对第1层链表进行若干步的遍历就能够实现。
  • 平衡树的插入和删除操做可能引起子树的调整,逻辑复杂,而skiplist的插入和删除只须要修改相邻节点的指针,操做简单又快速。
  • 从内存占用上来讲,skiplist比平衡树更灵活一些。通常来讲,平衡树每一个节点包含2个指针(分别指向左右子树),而skiplist每一个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。若是像Redis里的实现同样,取p=1/4,那么平均每一个节点包含1.33个指针,比平衡树更有优点。
  • 查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大致至关;而哈希表在保持较低的哈希值冲突几率的前提下,查找时间复杂度接近O(1),性能更高一些。因此咱们日常使用的各类Map或dictionary结构,大都是基于哈希表实现的。
  • 从算法实现难度上来比较,skiplist比平衡树要简单得多。

Redis中的skiplist实现

在这一部分,咱们讨论Redis中的skiplist实现。

在Redis中,skiplist被用于实现暴露给外部的一个数据结构:sorted set。准确地说,sorted set底层不只仅使用了skiplist,还使用了ziplist和dict。这几个数据结构的关系,咱们下一章再讨论。如今,咱们先花点时间把sorted set的关键命令看一下。这些命令对于Redis里skiplist的实现,有重要的影响。

sorted set的命令举例

sorted set是一个有序的数据集合,对于像相似排行榜这样的应用场景特别适合。

如今咱们来看一个例子,用sorted set来存储代数课(algebra)的成绩表。原始数据以下:

  • Alice 87.5
  • Bob 89.0
  • Charles 65.5
  • David 78.0
  • Emily 93.5
  • Fred 87.5

这份数据给出了每位同窗的名字和分数。下面咱们将这份数据存储到sorted set里面去:

对于上面的这些命令,咱们须要的注意的地方包括:

  • 前面的6个zadd命令,将6位同窗的名字和分数(score)都输入到一个key值为algebra的sorted set里面了。注意Alice和Fred的分数相同,都是87.5分。
  • zrevrank命令查询Alice的排名(命令中的rev表示按照倒序排列,也就是从大到小),返回3。排在Alice前面的分别是Emily、Bob、Fred,而排名(rank)从0开始计数,因此Alice的排名是3。注意,其实Alice和Fred的分数相同,这种状况下sorted set会把分数相同的元素,按照字典顺序来排列。按照倒序,Fred排在了Alice的前面。
  • zscore命令查询了Charles对应的分数。
  • zrevrange命令查询了从大到小排名为0~3的4位同窗。
  • zrevrangebyscore命令查询了分数在80.0和90.0之间的全部同窗,并按分数从大到小排列。

总结一下,sorted set中的每一个元素主要表现出3个属性:

  • 数据自己(在前面的例子中咱们把名字存成了数据)。
  • 每一个数据对应一个分数(score)。
  • 根据分数大小和数据自己的字典排序,每一个数据会产生一个排名(rank)。能够按正序或倒序。

Redis中skiplist实现的特殊性

咱们简单分析一下前面出现的几个查询命令:

  • zrevrank由数据查询它对应的排名,这在前面介绍的skiplist中并不支持。
  • zscore由数据查询它对应的分数,这也不是skiplist所支持的。
  • zrevrange根据一个排名范围,查询排名在这个范围内的数据。这在前面介绍的skiplist中也不支持。
  • zrevrangebyscore根据分数区间查询数据集合,是一个skiplist所支持的典型的范围查找(score至关于key)。

实际上,Redis中sorted set的实现是这样的:

  • 当数据较少时,sorted set是由一个ziplist来实现的。
  • 当数据多的时候,sorted set是由一个dict + 一个skiplist来实现的。简单来说,dict用来查询数据到分数的对应关系,而skiplist用来根据分数查询数据(多是范围查找)。

这里sorted set的构成咱们在下一章还会再详细地讨论。如今咱们集中精力来看一下sorted set与skiplist的关系,:

  • zscore的查询,不是由skiplist来提供的,而是由那个dict来提供的。
  • 为了支持排名(rank),Redis里对skiplist作了扩展,使得根据排名可以快速查到数据,或者根据分数查到数据以后,也同时很容易得到排名。并且,根据排名的查找,时间复杂度也为O(log n)。
  • zrevrange的查询,是根据排名查数据,由扩展后的skiplist来提供。
  • zrevrank是先在dict中由数据查到分数,再拿分数到skiplist中去查找,查到后也同时得到了排名。

前述的查询过程,也暗示了各个操做的时间复杂度:

  • zscore只用查询一个dict,因此时间复杂度为O(1)
  • zrevrank, zrevrange, zrevrangebyscore因为要查询skiplist,因此zrevrank的时间复杂度为O(log n),而zrevrange, zrevrangebyscore的时间复杂度为O(log(n)+M),其中M是当前查询返回的元素个数。

总结起来,Redis中的skiplist跟前面介绍的经典的skiplist相比,有以下不一样:

  • 分数(score)容许重复,即skiplist的key容许重复。这在最开始介绍的经典skiplist中是不容许的。
  • 在比较时,不只比较分数(至关于skiplist的key),还比较数据自己。在Redis的skiplist实现中,数据自己的内容惟一标识这份数据,而不是由key来惟一标识。另外,当多个元素分数相同的时候,还须要根据数据内容来进字典排序。
  • 第1层链表不是一个单向链表,而是一个双向链表。这是为了方便以倒序方式获取一个范围内的元素。
  • 在skiplist中能够很方便地计算出每一个元素的排名(rank)。

skiplist的数据结构定义

#define ZSKIPLIST_MAXLEVEL 32
#define ZSKIPLIST_P 0.25

typedef struct zskiplistNode {
    robj *obj;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;复制代码

这段代码出自server.h,咱们来简要分析一下:

  • 开头定义了两个常量,ZSKIPLIST_MAXLEVEL和ZSKIPLIST_P,分别对应咱们前面讲到的skiplist的两个参数:一个是MaxLevel,一个是p。
  • zskiplistNode定义了skiplist的节点结构。
    • obj字段存放的是节点数据,它的类型是一个string robj。原本一个string robj可能存放的不是sds,而是long型,但zadd命令在将数据插入到skiplist里面以前先进行了解码,因此这里的obj字段里存储的必定是一个sds。有关robj的详情能够参见系列文章的第三篇:《Redis内部数据结构详解(3)——robj》。这样作的目的应该是为了方便在查找的时候对数据进行字典序的比较,并且,skiplist里的数据部分是数字的可能性也比较小。
    • score字段是数据对应的分数。
    • backward字段是指向链表前一个节点的指针(前向指针)。节点只有1个前向指针,因此只有第1层链表是一个双向链表。
    • level[]存放指向各层链表后一个节点的指针(后向指针)。每层对应1个后向指针,用forward字段表示。另外,每一个后向指针还对应了一个span值,它表示当前的指针跨越了多少个节点。span用于计算元素排名(rank),这正是前面咱们提到的Redis对于skiplist所作的一个扩展。须要注意的是,level[]是一个柔性数组(flexible array member),所以它占用的内存不在zskiplistNode结构里面,而须要插入节点的时候单独为它分配。也正由于如此,skiplist的每一个节点所包含的指针数目才是不固定的,咱们前面分析过的结论——skiplist每一个节点包含的指针数目平均为1/(1-p)——才能有意义。
  • zskiplist定义了真正的skiplist结构,它包含:
    • 头指针header和尾指针tail。
    • 链表长度length,即链表包含的节点总数。注意,新建立的skiplist包含一个空的头指针,这个头指针不包含在length计数中。
    • level表示skiplist的总层数,即全部节点层数的最大值。

下图之前面插入的代数课成绩表为例,展现了Redis中一个skiplist的可能结构(点击看大图):

注意:图中前向指针上面括号中的数字,表示对应的span的值。即当前指针跨越了多少个节点,这个计数不包括指针的起点节点,但包括指针的终点节点。

假设咱们在这个skiplist中查找score=89.0的元素(即Bob的成绩数据),在查找路径中,咱们会跨域图中标红的指针,这些指针上面的span值累加起来,就获得了Bob的排名(2+2+1)-1=4(减1是由于rank值以0起始)。须要注意这里算的是从小到大的排名,而若是要算从大到小的排名,只须要用skiplist长度减去查找路径上的span累加值,即6-(2+2+1)=1。

可见,在查找skiplist的过程当中,经过累加span值的方式,咱们就能很容易算出排名。相反,若是指定排名来查找数据(相似zrange和zrevrange那样),也能够不断累加span并时刻保持累加值不超过指定的排名,经过这种方式就能获得一条O(log n)的查找路径。

Redis中的sorted set

咱们前面提到过,Redis中的sorted set,是在skiplist, dict和ziplist基础上构建起来的:

  • 当数据较少时,sorted set是由一个ziplist来实现的。
  • 当数据多的时候,sorted set是由一个叫zset的数据结构来实现的,这个zset包含一个dict + 一个skiplist。dict用来查询数据到分数(score)的对应关系,而skiplist用来根据分数查询数据(多是范围查找)。

在这里咱们先来讨论一下前一种状况——基于ziplist实现的sorted set。在本系列前面关于ziplist的文章里,咱们介绍过,ziplist就是由不少数据项组成的一大块连续内存。因为sorted set的每一项元素都由数据和score组成,所以,当使用zadd命令插入一个(数据, score)对的时候,底层在相应的ziplist上就插入两个数据项:数据在前,score在后。

ziplist的主要优势是节省内存,但它上面的查找操做只能按顺序查找(能够正序也能够倒序)。所以,sorted set的各个查询操做,就是在ziplist上从前向后(或从后向前)一步步查找,每一步前进两个数据项,跨域一个(数据, score)对。

随着数据的插入,sorted set底层的这个ziplist就可能会转成zset的实现(转换过程详见t_zset.c的zsetConvert)。那么到底插入多少才会转呢?

还记得本文开头提到的两个Redis配置吗?

zset-max-ziplist-entries 128
zset-max-ziplist-value 64复制代码

这个配置的意思是说,在以下两个条件之一知足的时候,ziplist会转成zset(具体的触发条件参见t_zset.c中的zaddGenericCommand相关代码):

  • 当sorted set中的元素个数,即(数据, score)对的数目超过128的时候,也就是ziplist数据项超过256的时候。
  • 当sorted set中插入的任意一个数据的长度超过了64的时候。

最后,zset结构的代码定义以下:

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;复制代码

Redis为何用skiplist而不用平衡树?

在前面咱们对于skiplist和平衡树、哈希表的比较中,其实已经不难看出Redis里使用skiplist而不用平衡树的缘由了。如今咱们看看,对于这个问题,Redis的做者 @antirez 是怎么说的:

There are a few reasons:

1) 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.

2) 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.

3) 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.

这段话原文出处:

news.ycombinator.com/item?id=117…

这里从内存占用、对范围查找的支持和实现难易程度这三方面总结的缘由,咱们在前面其实也都涉及到了。


系列下一篇咱们将介绍intset,以及它与Redis对外暴露的数据类型set的关系,敬请期待。

(完)

其它精选文章

相关文章
相关标签/搜索