Redis跳跃表详解

1.前言

  自己学跳跃表是因为当初听人说想要找一份高薪工作, Redis跳跃表是要知道的. 当时学的时候也是网上的文章反复看, 花了几个晚上才彻底弄明白, 所以在此记录一下吧, 为了下次面试好回顾

2.跳跃表基本概念准备

跳跃表是有序集合(zset)的底层实现之一。

2.1跳跃表的数据结构

跳跃表zskiplist定义在server.h中
在这里插入图片描述
header; 跳跃表的表头节点
tail: 指向跳跃表的表尾节点
level: 记录目前跳跃表内, 层数最大的那个节点的层数(表头节点的层数不计算在内. 因为它的层数为level31: 从level0开始, 所以是32层)
length: 记录跳跃表的长度, 也就是, 跳跃表目前包含节点的数量(表头节点不计算在内)

2.2跳跃表节点的数据结构

跳跃表的节点zskiplistNode定义在server.h中, 定义如下
在这里插入图片描述
robj: RedisObject的别名, 在跳跃表中它的类型是sds字符串
score: 浮点类型的数值
backward: 后退指针, 指向跳跃表当前节点的前一个节点的指针
level[]: 数组中的一个元素包含下列两项
  forward: 前进指针
  span: 当前层跨越的节点数量

节点按分值从低到高排列, 分值相同时按 robj字典顺序排列, 而不是按对象本身的大小
一个节点在每一层都有一个forward指针(各层的forward指针可能相同, 可能不同)
如图:
在这里插入图片描述
那么对应节点8而言:
  节点8的level0的forward为节点9, span值为1
  节点8的level1的forward为节点12, span值为4
  节点8的level2的forward为节点16, span值为8
所以, forward指针指向的是当前节点的当前level层所能指向的最右的节点(其最大的level层 >= 当前节点的当前level层), 而span就是这个过程中跨越的节点数. 将上述节点8的三个level层依次带入, 应该就能理解上面这句话了.

2.3Rank

它代表每个节点在跳跃表中的相对位置(类似数组下标)
如上图中节点8的rank值为 8 , 从第一个节点开始(不包含头节点) rank = 1, 然后依次类推.

3.跳跃表的创建

zskiplistNode *zslCreateNode(int level, double score, robj *obj) {
    zskiplistNode *zn = zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));
    zn->score = score;
    zn->obj = obj;
    return zn;
}
 
zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;
    zsl = zmalloc(sizeof(*zsl));                               
    zsl->level = 1;                                         
    zsl->length = 0;
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        zsl->header->level[j].forward = NULL;
        zsl->header->level[j].span = 0;
    }
    zsl->header->backward = NULL;
    zsl->tail = NULL;
    return zsl;
}

其中:
  ZSKIPLIST_MAXLEVEL,这个是跳跃表的最大层数,源码里通过宏定义设置为了32,也就是说,节点再多,也不会超过32层(level31)
  header节点的初始化: 创建跳跃表时, 初始化的header节点的level数组是有32层(level31)的, 且每一层的forward指向的都是null, 这里的知识在后面节点插入时会用到.
  而一般节点的level层数是在节点插入到跳跃表时 随机给定的, 根据一个随机算法:
在这里插入图片描述
上图是随机给定每一层的概率

4.跳跃表的插入

  终于到了最关键的部分, 这里弄明白, 跳跃表也就拿下了. 下面给出插入函数:

zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
 
    // 记录寻找元素过程中,每层能到达的最右节点
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
 
    // 记录寻找元素过程中,每层所跨越的节点数
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
 
    int i, level;
 
    redisAssert(!isnan(score));
    x = zsl->header;
    // 记录沿途访问的节点,并计数 span 等属性
    // 平均 O(log N) ,最坏 O(N)
    for (i = zsl->level-1; i >= 0; i--) {
        /* store rank that is crossed to reach the insert position */
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
 
        // 右节点不为空
        while (x->level[i].forward &&                   
            // 右节点的 score 比给定 score 小
            (x->level[i].forward->score < score ||      
                // 右节点的 score 相同,但节点的 member 比输入 member 要小
                (x->level[i].forward->score == score && 
                compareStringObjects(x->level[i].forward->obj,obj) < 0))) {
            // 记录跨越了多少个元素
            rank[i] += x->level[i].span;
            // 继续向右前进
            x = x->level[i].forward;
        }
        // 保存访问节点
        update[i] = x;
    }
 
    /* we assume the key is not already inside, since we allow duplicated
     * scores, and the re-insertion of score and redis object should never
     * happpen since the caller of zslInsert() should test in the hash table
     * if the element is already inside or not. */
    // 因为这个函数不可能处理两个元素的 member 和 score 都相同的情况,
    // 所以直接创建新节点,不用检查存在性
 
    // 计算新的随机层数
    level = zslRandomLevel();
    // 如果 level 比当前 skiplist 的最大层数还要大
    // 那么更新 zsl->level 参数
    // 并且初始化 update 和 rank 参数在相应的层的数据
    if (level > zsl->level) {
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }
        zsl->level = level;
    }
 
    // 创建新节点
    x = zslCreateNode(level,score,obj);
    // 根据 update 和 rank 两个数组的资料,初始化新节点
    // 并设置相应的指针
    // O(N)
    for (i = 0; i < level; i++) {
        // 设置指针
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;
 
        /* update span covered by update[i] as x is inserted here */
        // 设置 span
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }
 
    /* increment span for untouched levels */
    // 更新沿途访问节点的 span 值
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }
 
    // 设置后退指针
    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    // 设置 x 的前进指针
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        // 这个是新的表尾节点
        zsl->tail = x;
 
    // 更新跳跃表节点数量
    zsl->length++;
 
    return x;
}

那么如何理解上述函数就是关键了.

4.1两个关键的数组

这两个数组一定要理解!!!
update[]: 数组的每个元素update[i] --> XXX节点: XXX节点的第i层的forward指向插入节点
rank[]: 每一层中XXX节点的rank值(后面用来计算span的)

update[]: 记录寻找元素过程中, 每层能到达的最左节点(XXX节点)
rank[]: 最左节点的rank

补充: 最左节点是对插入节点而言, 如果根据后面介绍的, 从header开始遍历, 寻找update[i], 则可以看成最右节点.

4.2插入一个新节点时涉及的操作

  前面说过, 跳跃表插入节点时, level是随机给的, 我们这里假设插入节点的分值为9.5, 随机生成的层数是2, 那么此时跳跃表如图:
在这里插入图片描述
对跳跃表而言, 插入一个新节点所涉及的操作有:
  插入节点每一层的forward和span获取
  每一层最左节点的forward和span更新

插入节点9.5之后:
level0:
  插入节点的forward可以根据score排序得到, span为1
  update[0]是插入节点的backward, span为1
level1:
  8 ~ 12之间的span = s p a n 8 span_{8} span8 + 1 =>
  8 ~ 9.5 + 9.5 ~ 12 = s p a n 8 span_{8} span8 + 1 =>
  求出8 ~ 9.5,也就同样能求出 9.5 ~ 12
  8 ~ 9.5 = 8 ~ 9 + 1
      =rank(节点9) - rank(节点8) + 1

   s p a n 8 span_{8} span8表示的是插入之前, 节点8在level1的span值

对上述公式的解释:
  我要更新插入节点的level1层的最左节点的forward和span, forward为update[1], span就需要根据上述公式计算得到, 而刚好节点9为update[0], 节点8为update[1], 所以:
   =rank(update[0]) - rank(update[1]) + 1
同理: 对于更新插入节点的第i层的最左节点的span值
  抽象为: rank(update[0]) - rank(update[level.i]) + 1
      =rank(0) - rank(i) + 1

这个公式求得的是: 插入节点的第i层对应的最左节点的第i层span值.
此时我们再来看看这段:
在这里插入图片描述
每一层对应的最左节点的forward更新为update[i], span可以根据rank(0) - rank(i) + 1计算来更新
插入节点第i层的forward为update[i]在插入之前的forward(插入之后update[i]的第i层forward指向的是插入节点), span值获取: “8 ~ 9.5根据公式计算得到了, 9.5 ~ 12自然也就知道了”. 这么说不知道能不能理解.

而这个计算公式在源码中也有出处:
在这里插入图片描述
所以关键就是找到插入节点的update[] 和 rank[].

4.2遍历,记录update和rank

对应源码:

    // 记录寻找元素过程中,每层能到达的最右节点
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
 
    // 记录寻找元素过程中,每层所跨越的节点数
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
 
    int i, level;
 
    redisAssert(!isnan(score));
    x = zsl->header;
    // 记录沿途访问的节点,并计数 span 等属性
    // 平均 O(log N) ,最坏 O(N)
    for (i = zsl->level-1; i >= 0; i--) {
        /* store rank that is crossed to reach the insert position */
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
 
        // 右节点不为空
        while (x->level[i].forward &&                   
            // 右节点的 score 比给定 score 小
            (x->level[i].forward->score < score ||      
                // 右节点的 score 相同,但节点的 member 比输入 member 要小
                (x->level[i].forward->score == score && 
                compareStringObjects(x->level[i].forward->obj,obj) < 0))) {
            // 记录跨越了多少个元素
            rank[i] += x->level[i].span;
            // 继续向右前进
            x = x->level[i].forward;
        }
        // 保存访问节点
        update[i] = x;
    }

update和rank数组的值可以通过一次逐层的遍历确定
从跳跃表当前的最大层数开始遍历, 遍历到最底层为止.
     update[]      rank[]

length - 1层:
  X的head的level-1层(从0层开始, 这里的level是指跳跃表中当前的最大层数)指向节点X1(当前跳跃表内最高层), 判断:
  1.X1的level-1层的forward是否为null
    如:从 X1到表尾的所有节点的level层数都小于 X1的level层数, 此时length - 1 层的forward为null
  2.X1的score是否比插入节点的大
  满足其中之一 --> 跳出循环
  update[length -1] = header, rank[length - 1] = 0
  不满足其中之一 -->
  找到X1的length-1层的forward指向的节点X2(能到这 说明: X1与X2的level层数相等的), 判断:
  是否满足上述条件之一
  满足 --> 跳出循环
  update[length - 1] = X1, rank[length - 1] = X1在表中的rank值
length - 2层:
  X1的次高层的forward指向节点X3, 判断:
  …
  不满足 -->
  X3的最高层forward指向的节点 X4满不满足, X4的最高层forward指向的节点满不满足 假设满足:
  update[length - 2] = X4, rank[length - 2] = X4在跳跃表的rank值
length -3层:
  X4的次高层…
此时我们得到完整的update[] 和 rank[]

结合图示理解:
在这里插入图片描述
保姆级说明:
在这里插入图片描述

结合上述两幅图, 在插入节点后是如何通过一次遍历获取update[], rank[]数组的:
  1.首先在插入9.5节点之前, 跳跃表中level的值为3, 所以我们来到header节点的level-1层, 也就是level2, 其中的forward指向的是节点8(为什么header的level2中会有值, 下面会说明, 别急)
  2.判断循环条件, 不满足, 继续. 节点8的level2层的forward指向节点16, 满足. 此时update[2] = 节点8, rank[2] = 8
  3.从节点8的次高层level1出发, forward指向的是节点12, 判断循环条件, 满足. 此时update[1] = 节点8, rank[1] = 8
  4.从节点8的次次高层level0出发, forward指向的是节点9, 判断循环条件, 不满足, 继续, 节点9的最高层forward指向节点10, 判断循环条件, 满足, update[0] = 节点9, rank[0] = 9

  如果上述内容都理解了, 相信跳跃表的插入流程在脑子里大致是能跑同了, 但感觉还有些模糊地带, 接下来我们就来探索这些模糊地带, 全面理解Redis跳跃表.

5.如果插入节点随机生成的层数比当前跳跃表的最大层数大

  上文中提到过: 为什么header的level2中会有值. 接下来我们就来解释这个问题
  当插入节点随机生成的层数(j)比当前跳跃表的最大层数(level)大, 对于update[], rank[]中 数组下标≤ level 的依然可以通过一次遍历得到, 而对于数组下标: level <数组下标 ≤ j的, 此时显然:
  update[level + …] = header, rank[level+ …] = 0
  多出来的这些层的header的span:
    = rank[0] - rank[i] + 1
    =rank[0] + 1

而对于插入节点的那些 大于等于level层的forward = null, 所以也就没有span值

6.更新未涉及到的层

  如果随机生成的层数小于之前跳跃表中的层数, 那么大于随机生成的层数在创建新节点的过程中就没有被操作到(比如上述中, update[2] = 节点8 就没有被使用到,), 对于这些没有操作到的层, 里面的update节点对应的span应当+1(因为插入了一个新节点), forward不变.
  至此: 更新每一个update[i]的forward和span完成

  “物理的科学大厦已经建成, 剩下的只有大厦旁的两朵乌云, 后人只需在此基础上修补, 完善”. 原话找不到了, 凑合用吧!

7.设置后继指针

  针对每一层的调整已经全部完成了, 也就是level数组已经搞定, 接下来, 处理一个backward指针, 首先新节点的backward要指向前一个节点, 然后, 新节点的下一个节点要将backward指向新节点.

8.更新跳跃表节点个数

  最后, 全部搞定, 把跳跃表个数加1即可, 理解了插入, 对跳跃表可以说是基本掌握了. 因为掌握了插入, 也就掌握了跳跃表的其他操作(如 删除).
  其实就是觉得为了面试学到这, 用来装逼应该足够了. 当然时间够的还可以了解一下Redis的一致性hash, Redis的内存淘汰机制, 这两个理解起来会容易很多.
Redis的一致性hash:
https://blog.csdn.net/qq_21125183/article/details/90019034?
Redis的内存淘汰机制: 第9题有稍微说明
https://blog.csdn.net/weixin_43179522/article/details/109318370

9.补充

  跳跃表是一种随机化的数据结构, 在查找, 插入和删除这些字典操作上, 其效率可比拟于平衡二叉树(如红黑树), 大多数操作只需要O(log n)平均时间. 为什么时间能从O(n)提升到O(log n)
  用下图举个例子吧:
在这里插入图片描述
  当我们要查找新插入的节点9.5时, 如果不是用的跳跃表, 那么需要从节点1开始, 找到节点2, 然后一直找到节点9.5, 花费的时间为10.
  而如果是跳跃表结构, 我们从header的level-1层出发, 根据span和forward来到节点8, 发现节点8的score < 节点9.5的score, 然后我们接着从节点8的level2出发, 来到节点16, 发现节点16score > 节点9.5的score, 所以我们重新从节点8的level1出发, 来到节点9.5. 找到节点, 返回结果. 时间花费: header -> 节点8 -> 节点16, 节点8 -> 节点9.5 = 3
  总结: 跳跃表根据level数组, 相当于为跳跃表构建了多级索引, 每一层level都是一级索引, 从而减少了查询时间.

10.参考链接

https://blog.csdn.net/u013536232/article/details/105476382?
https://blog.csdn.net/weixin_30398227/article/details/94981429?
https://blog.csdn.net/universe_ant/article/details/51134020?
  在此对这三篇博客的作者提供的思路表示感谢.
  对了, 本来学这个是想面试中装逼用的, 结果愣是没人问. 我真的! 哎, 就像沈佳宜说的: “人生本来很多努力都是徒劳无功的”. 至少它很难, 但你学会了!!!
  最后, 如果觉得通过这篇博客理解了Redis跳跃表, 求个点赞, 收藏. 写博客后才发现, 码字也不是个轻松活.

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值