Redis学习笔记&源码阅读--跳跃表

申明

  • 本文基于Redis源码5.0.8
  • 本文内容大量借鉴《Redis设计和实现》和《Redis5设计与源码分析》

概念

引子

跳跃表这个概念在第一次接触时还是比较唬人的,我们先来聊聊它产生的背景。我们知道数组和链表的区别,数组是在内存上连续的且保存的数据一般是同类型的,随意我们可以通过指针进行随机访问,在做查询操作时就特别方便,这是它的优势,但是在做删除和新增时就略显笨重了,需要做大量的内存拷贝操作;相反,链表的内存并不是连续的,所以在新增和删除时都不需要拷贝内存,只需要做指针的改动操作就可以了,但是缺点在于链表不能随机访问链表中的元素,只能通过链表的header一步步访问过去,这在查询时显得比较笨重。
鱼与熊掌,该怎么选?小孩子才做选择题,我们既要查询灵活,又想增删改灵活,这就是跳跃表产生的原始动力,有人可能想到红黑树,红黑树和跳跃表在整体性能是比较相似的,但是Redis中使用跳跃表的结构主要是有序集合,对有序集合的区间查询时一种常见的功能,跳跃表比红黑树要容易实现,另外一点,在实际场景中,如果对底层是红黑树的结构存储大量元素并进行频繁的增删操作会导致特别多的平衡操作,非常占用CPU资源。
说了这么久,跳跃表到底是什么呢?跳跃表源自链表,我们就从对有序链表的使用衍生出跳跃表的大致结构,有序链表是所有元素以递增或递减方式有序排列的数据结构,其中每个节点都有指向下个节点的next指针,最后一个节点的next指针指向NULL。递增有序链表举例如图所示。
在这里插入图片描述
如果要查询值为51的元素,需要从第一个元素开始依次向后查找、比较才可以找到,查找顺序为1→11→21→31→41→51,共6次比较,时间复杂度为O(N)。有序链表的插入和删除操作都需要先找到合适的位置再修改next指针,修改操作基本不消耗时间,所以插入、删除、修改有序链表的耗时主要在查找元素上。

如果我们将有序链表中的部分节点分层,每一层都是一个有序链表。在查找时优先从最高层开始向后查找,当到达某节点时,如果next节点值大于要查找的值或next指针指向NULL,则从当前节点下降一层继续向后查找,这样是否可以提升查找效率呢?

分层有序链表如下图所示,我们再次查找值为51的节点,查找步骤如下。
在这里插入图片描述

  1. 从第2层开始,1节点比51节点小,向后比较。
  2. 21节点比51节点小,继续向后比较。第2层21节点的next指针指向NULL,所以从21节点开始需要下降一层到第1层继续向后比较。
  3. 第1层中,21节点的next节点为41节点,41节点比51节点小,继续向后比较。第1层41节点的next节点为61节点,比要查找的51节点大,所以从41节点开始下降一层到第0层继续向后比较。
  4. 在第0层,51节点为要查询的节点,节点被找到。

采用上图所示的数据结构后,总共查找4次就可以找到51节点,比有序链表少2次。当数据量大时,优势会更明显。

综上所述,通过将有序集合的部分节点分层,由最上层开始依次向后查找,如果本层的next节点大于要查找的值或next节点为NULL,则从本节点开始,降低一层继续向后查找,依次类推,如果找到则返回节点;否则返回NULL。采用该原理查找节点,在节点数量比较多时,可以跳过一些节点,查询效率大大提升,这就是跳跃表的基本思想。

我们先看一个跳跃表的实例
在这里插入图片描述

从图我们可以看出跳跃表有如下性质。

  • 跳跃表由很多层构成。
  • 跳跃表有一个头(header)节点,头节点中有一个64层的结构,每层的结构包含指向本层的下个节点的指针,指向本层下个节点中间所跨越的节点个数为本层的跨度(span)。
  • 除头节点外,层数最多的节点的层高为跳跃表的高度(level),图中跳跃表的高度为3。4)每层都是一个有序链表,数据递增。
  • 除header节点外,一个元素在上层有序链表中出现,则它一定会在下层有序链表中出现。6)跳跃表每层最后一个节点指向NULL,表示本层有序链表的结束。
  • 跳跃表拥有一个tail指针,指向跳跃表最后一个节点。
  • 最底层的有序链表包含所有节点,最底层的节点个数为跳跃表的长度(length)(不包括头节点),图中跳跃表的长度为7。
  • 每个节点包含一个后退指针,头节点和第一个节点指向NULL;其他节点指向最底层的前一个节点。
    跳跃表每个节点维护了多个指向其他节点的指针,所以在跳跃表进行查找、插入、删除操作时可以跳过一些节点,快速找到操作需要的节点。归根结底,跳跃表是以牺牲空间的形式来达到快速查找的目的。

跳跃表结构

Redis源码中通过跳跃表节点和跳跃表两个部分组成一个跳跃表的实现,先看下节点的源码结构:

typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

字段的含义如下:

  • ele:存储的实际值;
  • score:存储的值得得分,排序是按照score进行;
  • backward:最底层的前一个节点,反向遍历跳跃表使用;
  • level:层级数组,数组长度和当前节点层级相同,其中forward表示当前层下一节点,如果当前是尾结点,forward值为null;span表示当前节点和forward指向节点之间跨越的元素个数;
    接下来再看看跳跃表的源码结构:
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

字段含义如下:

  • header:跳跃表的表头节点,头节点是跳跃表的一个特殊节点,它的level数组元素个数为64。头节点在有序集合中不存储任何member和score值,ele值为NULL, score值为0;也不计入跳跃表的总长度。头节点在初始化时,64个元素的forward都指向NULL, span值都为0。
  • tail:指向跳跃表的尾结点
  • length:跳跃表长度,表示除头节点之外的节点总数。
  • level:跳跃表的高度。

基本操作

创建跳跃表

直接看源码:

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;
}

我们直接从创建头结点开始讲解,在前面知道header结点是64层,所以使用ZSKIPLIST_MAXLEVEL,header结点的score和ele都是没有实际意义的,所以传值0和null,zslCreateNode的源码如下所示:

zskiplistNode *zslCreateNode(int level, double score, sds ele) {
    zskiplistNode *zn =
        zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));
    zn->score = score;
    zn->ele = ele;
    return zn;
}

首先计算一个level层的node需要多大内存并申请相应内存,给score和ele赋值,然后直接返回,可以看出zslCreateNode创建的node并不是完全初始化的,level数组和backawrd当前还是未初始化状态,所以在Create函数返回后接着就对level和backward进行初始化操作,由于是header节点,遍历所有的level并将forward置为null,span置为0,表示跳跃表当前是空,header节点的backward是没有意义的,直接赋值null。跳跃表本身的一些字段进行相应的初始化操作后,一个zslCreate就创建好了。

插入元素

跳跃表的插入操作相对比较复杂,在剖析源码前,我们先结合跳跃表的结构思考下跳跃表插入元素需要更新哪些字段的值?

  • 跳跃表的length改变;
  • 如果插入元素是跳跃表的首个元素,跳跃表的tail指针需要更新;
  • 如果插入元素的level比跳跃表当前level高,则更新跳跃表的level和header中新增加level的forward指针;
  • 如果插入元素的level比跳跃表的level低,那么相差的那些level层中必有某个节点的span增加一个单位;
  • 插入元素的在各层level都需要融入到跳跃表中,所以需要知道在各层的插入位置,并让插入位置前一个元素的forward指向插入元素,插入元素的forward指向正确的位置;另外元素的插入导致前一个元素和后一个元素的span都发生变化了,也需要更新;

上面几个问题中,前三个问题都比较好处理,主要是后面两个问题,我们需要知道在各层中插入元素的位置,所谓的插入位置就是,新元素在各层分别插入到哪些元素的后面,所以我们需要知道插入元素在各层前面的元素具体是哪个。另外一点,新元素在插入到两个节点之间后,比如导致前一个元素的span信息发生变化,这个信息需要更新,怎么更新呢?我们后面再说。其实插入元素除了这两点难理解外,其他都比较好理解,所以下面介绍时也重点放在这两个数据的逻辑。
我们zslInsert的源码拆开来剖析,先看一段代码:

    for (i = zsl->level-1; i >= 0; i--) {//按照表的level逐层进行一次计算
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                    sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;
    }

这段代码是在更新update和rank两个数组,含义如下:

  • update:插入元素的每层前一个元素的forward都需要指向到插入元素,在每层需要改变的元素都是不一样的,所以需要找到并保存下来;
  • rank:记录了从header到本层update指向元素之间的跨越的元素个数,主要是用于在更新插入元素时计算插入元素和前一个元素的span值;
    到这里如果你还是比较迷糊,没有关系的,到这里你只需要明确update和rank记录的什么就可以了。
    代码中对rank的操作部分,我们暂时不管,注意力只放在update数组的更新逻辑,我粗略的画了一个update计算变迁图,如下所示:
    在这里插入图片描述
    图中虚线表示的新插入节点的level,其他都是当前跳跃表节点的level信息,为了简介图中忽略了很多level之间的箭头指向,红色线表示的就是寻找各层插入元素前一个元素的路径,我们结合源码来看,这里掌握一个关键点,路径每一次的变化只有两种情况:
  • 指向元素发生变化;
  • 指向元素没有变化,但是指向的level下降一层;
    对应到代码中,指向元素变化是通过while循环控制的,while循环条件的判断翻译成伪码如下:
while(forawrd是否存在 && forward元素的score是否小于当前元素的score && 如果score相等那么在字典序上是否小于当前元素)

如果条件都满足的话,那么就继续转到forward元素进行一次判断直到走到尾部或者找到一个元素是大于当前元素的,while结束时也就表示当前level已经找到插入元素的前一个元素了,就是此时的x,将x保存到update[i]中,再一次进入for循环,此时level下降一层,注意此时的x还是上一轮的找到的update[i],没有发生变化, 发生变化的是level,我们直接从x下降了一层的level开始寻找本层的update元素,这个逻辑就对应到上图中元素没有变化, level下降一层的迁移路径,update更新的核心逻辑已经讲完了。

接下来我们再来看看rank的更新逻辑,理解rank只要把握一点,那就是关注update节点迁移过程中是否有元素指向变化,一旦变化,必然涉及到span数值的更新动作,也就是说我们只要把update节点迁移路径只从是否发生指向元素变化这一个维度来考虑就可以了。

rank在for循环和while循环中都涉及到值得修改,我们按照上一段中理解rank把握的关键点来看rank的修改,第一次for循环中rank被指定为0,这是因为第一次时x指向的是header,此时x还没指向任何一个有效的元素,span当然为0 (span就是两个元素跨越的元素数量),在while循环中,while每迭代一次,rank就增加当前元素的span值,while迭代就表示元素发生了变化,while循环结束后,rank[i]是不是就表示header到update[i]之间跨越了多少个元素啊。

再一次进入for循环时level下降一层,我们看rank的初始值是继承了上一层的结果值,因为只是level变化而已,元素没变化,不管是哪一层header距离当前元素的距离都不会发生变化,对吧。到这里,upadte和rank的更新逻辑应该是清楚了,我们接着看看其他需要改动的地方是如何修改的。

在创建一个新元素时需要确定当前元素的level,改数值是通过zslRandomLevel函数计算的,其源码如下:

int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

上述代码中,level的初始值为1,通过while循环,每次生成一个随机值,取这个值的低16位作为x,当x小于0.25倍的0xFFFF时,level的值加1;否则退出while循环。最终返回level和ZSKIPLIST_MAXLEVEL两者中的最小值。

    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;
    }

如果当前元素的level高于跳跃表的level,就更新多出的level在rank和update数组中的值,
(这里待补充)
接下来,通过zslCreateNode创建node,我们在介绍zslCreateNode时说过创建一个node后level和backward信息是没有初始化的,所以在实际插入前还需要将这些信息初始化好,level的初始化如下源码所示:

    for (i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;

        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

其中forward的变动就是常见的链接操作,这里不赘述,可以自己演算下,重点是span的计算,我简单画了一个图示:
在这里插入图片描述

在L2层计算新元素的span,其实就是将N1的span拆分成两部分,那么元素N1在N4插入后span的值怎么计算呢?其实就是N1和N2之间个元素个数,为什么是N2呢?因为N2是update[0],L0层记录的rank就是实际Header距离的真实元素数量,在上图中对应b1,N1距离header的距离是b2,那么在N4插入后,N1和N4之间的距离是不是就是(b1-b2)+1,为啥要加1,因为插入了一个新元素N4了啊,原来的N1.span是没有统计这个新元素的,N4的span就是N1.span- (b1-b2)了,映射到源码中,b1表示rank[0],b2表示rank[i]。

接下来,还有一处需要改变,如果新元素的level小于跳跃表的level,那么高层的level对应的update元素的span是不是需要增加1,入上图中的N0的元素的span因为新插入的元素是不是需要增加一?就是这个道理。

还记得我们还有一个没有初始化吗?backward,对的,对backward的更新也是链表的基本操作,就不再赘述了,最后给zsl的长度加一,这样我们插入元素的操作就完成了。

删除元素

看完了插入元素后,我们知道了update数组的作用以及如何生成update数组,在删除元素的过程中我们同样也需要使用到update数组,我们先假设已经生成了update数组,看下源码是如何删除元素的。

void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {
    int i;
    for (i = 0; i < zsl->level; i++) {
        if (update[i]->level[i].forward == x) {
            update[i]->level[i].span += x->level[i].span - 1;
            update[i]->level[i].forward = x->level[i].forward;
        } else {
            update[i]->level[i].span -= 1;
        }
    }
    if (x->level[0].forward) {
        x->level[0].forward->backward = x->backward;
    } else {
        zsl->tail = x->backward;
    }
    while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
        zsl->level--;
    zsl->length--;
}

其中x表示待删除的元素,for循环里面分两种情况,update[i].forward==x表示当前高度不必x的level高,通过链表的指针键操作就能简单的将x剔除出来,并更新update[i]的span即可,如果updata[i]!=x 表示当前level已经高于x的level,只需要将span减一即可。

接着更新下backward,while循环表示什么意思呢?考虑下如果x的level是最高的,那么x删除了后,跳跃表的level是不是相应地降低了,所以需要做可能存在的调整,最后更新下跳跃表的长度即可,到此一个元素的删除操作就完成了,我们再来看下跳跃表的删除函数封装:

int zslDelete(zskiplist *zsl, double score, sds ele, zskiplistNode **node) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    int i;

    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                     sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            x = x->level[i].forward;
        }
        update[i] = x;
    }
    /* We may have multiple elements with the same score, what we need
     * is to find the element with both the right score and object. */
    x = x->level[0].forward;
    if (x && score == x->score && sdscmp(x->ele,ele) == 0) {
        zslDeleteNode(zsl, x, update);
        if (!node)
            zslFreeNode(x);
        else
            *node = x;
        return 1;
    }
    return 0; /* not found */
}

首先组装update数组,找到删除元素,通过zslDeleteNode删除元素和释放元素空间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值