Redis 跳跃表

跳跃表

咱们可以想象一个场景,我们需要维护一个有序的集合,集合是按照某个字段进行排序的,集合会有添加和删除操作。

如何在内存中存储存储并维护这样的集合呢,我们可以想到链表或者数组进行存储,他们都是线性的结构,我们分别分析下这两种接口存储有序集合的场景。

  • 数组

使用数组维护集合,当插入一个元素时,首先需要找到插入位置,通过二分查找时间复杂度为O(logn),找到插入位置后,需要将后面的元素都移动一位,时间复杂度为O(n)。

两个操作时间复杂度为O(n)。

  • 链表

使用链表维护集合,当插入一个元素时,首先需要找到插入位置,链表不能二分查找,所以遍历链表直到找到元素时间复杂的为O(n),找到后插入元素复杂度为O(1)。

两个操作时间复杂度为O(n)。

当集合数据量很大时,会对性能产生影响。

我们可以观察到,链表这种结构删除或者新增一个节点复杂度都为O(n),而找某个节点需要遍历,复杂度为O(n),我们能不能想办法把找某个节点的复杂度优化一下呢?

跳跃表 就是优化了查找某个节点的复杂度。

例如有如下的链表:

 

 

 我们找某个节点需要遍历链表,时间复杂度为O(n)。我们在这个链表之上再维护一个链表,为下面的偶数节点列表。

例如下图链表:

 

 假如我要插入元素4,我遍历上面基数链表,定位到 3 和 5 之间,再返回到原来的链表定位,还是定位到 3 和 5 之间。

数据量少不够明显,可以想象下如果数据量大,查找次数将减少一半。这就是跳跃表的思想。

当然需要额外开辟空间来维护上面的链表,典型的空间换时间策略。

再数据量足够大的情况下我们还可以添加索引,直到同一层节点只有两个元素(只有一个没有比较的意义),这种多层链表结构就是跳跃表。

跳跃表新增节点:

  1. 新节点和各层索引节点逐一比较,确定原链表的插入位置。O(logn)
  2. 把索引插入到原链表。O(1)
  3. 利用抛硬币的随机方式,决定新节点是否提升为上一级索引。结果为“正”则提升并继续抛硬币,结果为“负”则停止。O(logn)

总体跳跃表时间复杂度为O(logn),空间复杂度为O(n)。

条约表删除节点:

  1. 自上而下,查找第一次出现节点的索引,并逐层找到每一层对应的节点。O(logn)
  2. 删除每一层查找到的节点,如果该层只剩下1个节点,删除整个一层(原链表除外)。O(logn)

总体跳跃表删除操作的时间复杂度是O(logn)。

Redis跳跃表

Redis有序集合(zset)底层是由 ziplist 和 Redis 跳跃表两种方式实现的。

满足一下条件使用 ziplist:

  • 有序集合保存的元素数量小于128个
  • 有序集合保存的所有元素的长度小于64字节

接下来我们讨论Redis的跳跃表是如何实现的。

Redis跳跃表的实现

Redis 跳跃表由 redis.h/zskiplistNode 和 redis.h/zskiplist 两个结构定义。

其中 zskiplistNode 结构用于表示跳跃表节点, 而 zskiplist 结构则用于保存跳跃表节点的相关信息。

可参考如下图:

跳跃表节点

跳跃表节点的实现由 redis.h/zskiplistNode 结构定义:

typedef struct zskiplistNode {

    // 后退指针
    struct zskiplistNode *backward;

    // 分值
    double score;

    // 成员对象
    robj *obj;

    //
    struct zskiplistLevel {

        // 前进指针
        struct zskiplistNode *forward;

        // 跨度
        unsigned int span;

    } level[];

} zskiplistNode;

跳跃表节点的 level 数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度。

  • 前进指针

每个层都有一个指向表尾方向的前进指针(level[i].forward 属性), 用于从表头向表尾方向访问节点。

  • 跨度

层的跨度(level[i].span 属性)用于记录两个节点之间的距离, 两个节点之间的跨度越大, 它们相距得就越远。

指向 NULL 的所有前进指针的跨度都为 0 , 因为它们没有连向任何节点。

  • 后退指针

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

  • 分值

节点的分值(score 属性)是一个 double 类型的浮点数, 跳跃表中的所有节点都按分值从小到大来排序。

  • 成员

节点的成员对象(obj 属性)是一个指针, 它指向一个字符串对象, 而字符串对象则保存着一个 SDS 值。

 在同一个跳跃表中, 各个节点保存的成员对象必须是唯一的, 但是多个节点保存的分值却可以是相同的。

分值相同的节点将按照成员对象在字典序中的大小来进行排序, 成员对象较小的节点会排在前面(靠近表头的方向), 而成员对象较大的节点则会排在后面(靠近表尾的方向)。

跳跃表

zskiplist 结构的定义如下:

typedef struct zskiplist {

    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;

    // 表中节点的数量
    unsigned long length;

    // 表中层数最大的节点的层数
    int level;

} zskiplist;
  • header 和 tail 指针分别指向跳跃表的表头和表尾节点, 通过这两个指针, 程序定位表头节点和表尾节点的复杂度为 O(1) 。
  • 通过使用 length 属性来记录节点的数量, 程序可以在 O(1) 复杂度内返回跳跃表的长度。
  • level 属性则用于在 O(1) 复杂度内获取跳跃表中层高最大的那个节点的层数量, 注意表头节点的层高并不计算在内。

 

ZSet在内存中的结构

zset 的结构定义如下:

/*
 * 有序集合
 */
typedef struct zset {

    // 字典,键为成员,值为分值
    // 用于支持 O(1) 复杂度的按成员取分值操作
    dict *dict;

    // 跳跃表,按分值排序成员
    // 用于支持平均复杂度为 O(log N) 的按分值定位成员操作
    // 以及范围操作
    zskiplist *zsl;

} zset;

有了上面的内容,我们可以看看zset是怎么利用跳跃表进行存储数据的。

例如,我们设置 这样的一个zset:

key = language,value=(1-java,2-go,3-php)

那么上述zset,对应下图:

 

 

 参口文献

https://zhuanlan.zhihu.com/p/53975333

Redis 设计与实现第二版

posted @ 2020-11-13 21:16  hulunbao  阅读(182)  评论(0)    收藏  举报