1. 什么是跳跃表(skiplist)

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
Redis 使用跳跃表作为有序集合键(ZSET)的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis 就会使用跳跃表来作为有序集合键的底层实现。
Redis 只在两个地方用到了跳跃表(skiplist)

  • 实现有序集合键
  • 在集群节点中用作内部数据结构

1.1 为什么需要跳跃表(skiplist)?

对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)

redis为什么用 redis为什么用跳跃表_跳跃表

如果我们想要提高其查找效率,可以考虑在链表上建索引的方式。每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引

redis为什么用 redis为什么用跳跃表_跳跃表_02


这个时候,我们假设要查找节点 8,我们可以先在索引层遍历,当遍历到索引层中值为 7 的结点时,发现下一个节点是 9,那么要查找的节点 8 肯定就在这两个节点之间。我们下降到链表层继续遍历就找到了 8 这个节点。原先我们在单链表中找到 8 这个节点要遍历 8 个节点,而现在有了一级索引后只需要遍历五个节点。

从这个例子里,我们看出,加来一层索引之后,查找一个结点需要遍的结点个数减少了,也就是说查找效率提高了,同理再加一级索引。

redis为什么用 redis为什么用跳跃表_redis_03


从图中我们可以看出,查找效率又有提升。在例子中我们的数据很少,当有大量的数据时,我们可以增加多级索引,其查找效率可以得到明显提升。

redis为什么用 redis为什么用跳跃表_Redis_04


像这种链表加多级索引的结构,就是跳跃表!

1.2 为什么只有在元素多/长的时候,使用跳跃表?

从上面我们可以知道,跳跃表在链表的基础上增加了多级索引以提升查找的效率,但其是一个空间换时间的方案,必然会带来一个问题——索引是占内存的。原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值值和几个指针,并不需要存储对象,因此当节点本身比较大或者元素数量比较多的时候,其优势必然会被放大,而缺点则可以忽略。

2. 跳跃表(skiplist)的数据结构

2.1 跳跃表节点(zskiplistNode)

typedef struct zskiplistNode {
    // 后退指针
    struct zskiplistNode *backward;
    
    // 分值
    double score;
    
    // 成员对象
    robj *obj;
    
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        
        // 跨度
        unsigned int span;
    } level[];
} zskiplistNode;

2.1.1 后退指针(backward)

节点的后退指针(backward 属性)用于从表尾表头方向访问节点,跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。

具体使用流程如下图:

redis为什么用 redis为什么用跳跃表_数据结构_05


程序首先通过跳跃表的 tail 指针访问表尾节点,然后通过后退指针访问倒数第二个节点,以此类推,知道后退指针指向 NULL,至此访问结束。

2.1.2 分值(score)

节点的分值(score 属性)是一个 double 类型的浮点数,跳跃表中的所有节点都按分值从小到大排序。例如后退指针图中的 1.0、2.0、3.0

如果两个成员对象的分值相同,则按照成员对象在字典序中的大小,从小到大进行排序。

2.1.3 成员对象(obj)

节点的成员对象(obj 属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个 SDS 值。例如后退指针中的 obj#1、obj#2、obj#3

在同一个跳跃表中,各个节点保存的成员对象必须是唯一的。

2.1.4 层

skiplistNode 中的 level 数组可以包含多个 zskiplistLevel 元素,程序可以通过这些层来加快访问其他节点的速度,一般来说,level 数组包含的元素越多,访问其他节点的速度越快。
每次创建一个新的跳跃表节点的时候,程序都会随机生成一个介于 1-32 之间的值作为 level 数组的大小,这个大小就是层的“高度”。

2.1.4.1 前进指针(forward)

前进指针用于从表头表尾方向访问节点,如图虚线和数字所示,图中只描述了其中一种遍历路线。

redis为什么用 redis为什么用跳跃表_Redis_06

2.1.4.2 跨度

层的跨度(level[i].span 属性)用于记录两个节点之间的距离。

  • 两个节点之间的跨度越大,它们相距的距离越远;
  • 指向 NULL 的所有前进指针的跨度都为 0

2.2 跳跃表(zskiplist)

使用跳跃表(zskiplist)可以在跳跃表节点的基础上,更方便地对整个跳跃表进行处理,比如:

  • 快速访问跳跃表的表头节点表尾节点
  • 快速获取跳跃表节点的数量
typedef struct zskiplist {
    // 表头节点和表尾节点
    struct skiplistNode *header, *tail;
    
    // 表头节点的数量
    unsigned long length;
    
    // 表中层数最大的节点的层数
    int level;
} zskiplist;
  • headertail 指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为 O(1)
  • length:用于记录节点的数量
  • level:用于获取跳跃表中层高最大的那个节点的层数量。

3. 跳跃表 API

函数

作用

时间复杂度

zslFree

释放给定跳跃表,以及表中包含的所有节点

O(N),N 为跳跃表的长度

zslInsert

将新节点添加到跳跃表中

平均O(logN),最坏O(N),N 为跳跃表的长度

zslDelete

将给定节点从跳跃表中删除

平均O(logN),最坏O(N),N 为跳跃表的长度

zslGetRank

返回包含给定成员和分值的节点在跳跃表中的排位

平均O(logN),最坏O(N),N 为跳跃表的长度

zslGetElementByRank

返回跳跃表在给定排位上的节点

平均O(logN),最坏O(N),N 为跳跃表的长度

zslFirstInRange

给定一个分值范围,返回跳跃表中第一个符合这个范围的节点

平均O(logN),最坏O(N),N 为跳跃表的长度

zslLastInRange

给定一个分值范围,返回跳跃表中最后一个符合这个范围的节点

平均O(logN),最坏O(N),N 为跳跃表的长度

zslDeleteRangeByScore

给定一个分值范围,删除跳跃表中所有在这个范围之内的节点

O(N),N 为被删除节点的数量

zslDeleteRangeByRank

给定一个排位范围,删除跳跃表中所有在这个范围之内的节点

O(N),N 为被删除节点的数量

时间复杂度 ≠ O(1) 的 API

4. 参考

  • Redis数据结构 — 跳跃表
  • 《Redis 设计与实现》