Redis源码阅读【2-跳跃表】

Redis源码阅读【1-简单动态字符串】

1、介绍

有序集合在我们日常生活中非常常见,例如排名,大小排序等等,我们经常会使用例如:数组,链表,平衡树等结构来实现。但是数组和链表在海量数据面前性能低下,平衡树等结构虽然效率高,但是实现复杂。在这里Redis使用了一种新的数据结构:【跳跃表】一种用空间换取时间,性能堪比红黑树,实现却远比红黑树简单。Redis在有序集合键,和集群节点中使用了跳跃表这种结构

2、普通链表

有序链表的结构如下:
在这里插入图片描述
在有序链表中,下一个元素的获取始终要经过上一个元素,不像数组可用直接通过偏移量的方式快速定位到指定数据的位置,但是链表有个好处,插入效率高,例如我要在15的位置插入一个17,那么我只需要把15的 next 指针和17的 next 指针替换一下即可,所以其实链表的插入并不低效,只是在定位元素,查找的时候浪费太多的时间而已,所以我们希望能解决链表查询低效的问题,跳跃表正是为了解决这个问题而来的。

3、什么是跳跃表

跳跃表,顾名思义是一种可以用类似于跳跃跨度的方式去查找数据的结构,我们可以把一个跳跃表看成是多个分层的有序链表,其中每一层的元素数据都不一样,从最上层开始往下,每层的元素依次增多,最底层就是实际数据存储的链表

如图:
在这里插入图片描述
如图所示,如果我要查找yu链表中61的元素,那么传统链表我需要经过6次扫描,但是跳跃表我只需要三次扫描即可找到目标元素
在这里插入图片描述
从中也能看出,跳跃表性能其实也和分层挂钩,当高层查找到最后一个元素或者,发现当前元素小于当前层的下一个元素的时候,会自动下降一层继续查找,直到找打目标元素。例如:要查找元素31,那么跳跃表的路径就是 1->21->31。
综上所述,跳跃表就是将有序列表分层,由最上层开始依次往 下后方 查找,直到找到目标元素。

4、跳跃表的结构

那么看完跳跃表的思路,我们了解一下Redis使用跳跃表的地方有哪些:

1、数据量大时,实现有序集合键
2、是在集群节点中用作内部数据结构

Redis的跳跃表结构基本如下图所示:
在这里插入图片描述
别慌,看似很多内容的图片,其实仔细观察,发现内部实现还是比较简洁又规律性的,从图中我们可以看出Redis的跳跃表有以下几个性质:

1、Redis的跳跃表由多层组成(最大64层)  	
2、跳跃表有一个Header结点,头结点有一个64层的结构,每层结构包含,指向该层下一个结点的next指针 也包含本层跨度 (span) 	
3、除了头结点外其余结点的最高层的层数就是当前跳跃表最高层数 	
4、每一层都是一个有序链表,数据递增 	
5、除了Header结点外,一个元素在上层出现,那么它一定会在下层出现 	
6、跳跃表没层最后一个结点是NULL 	
7、跳跃表有一个tail指针,指向跳跃表的最后一个结点 	
8、最底层有序链表包含所有元素,最底层链表的(length)为跳跃表的长度 	
9、每个结点都有一个backward指针,指向上一个结点(双向链表) 	
10、score作为跳跃表各层的排序依据

4.1、跳跃表节点结构

zskiplistNode 是跳跃表的结构体,它在源码文件 (server.h)中

typedef struct zskiplistNode {
    sds ele; //用于存储字符串类型的数据
    double score; //用于排序的数据分值
    struct zskiplistNode *backward; // 回退结点指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 本层下一个结点指针
        unsigned long span;  //用于记录当前层结点的跨度
    } level[]; //分层数组,如果是header是 64层
} zskiplistNode;

结构体有以下内容:

字段 含义
ele 用于存储字符串类型的数据
score 用于存储排序分值
backward 后退指针,只能当前最底层的前一个结点
level 分层数组,通过索引获取当前node的不同层的位置,每个node的层数会不一样
forward 指向本层的下一个结点,尾结点指向NULL
span 记录forward指向的结点与本结点直接的元素个数。span越大,跳过的结点个数越多,span最终相加是等于链表长度的

跳跃表的Header结点是一个特殊的结点,其具有跳跃表的最高层数64层,从中我们也能发现通过level[i] 的方式程序可以在O(1) 的情况下快速定位到任意一层。

4.2、跳跃表外结构

zskiplist 是跳跃表主结构,它的源码在(server.h)中

typedef struct zskiplist {
    struct zskiplistNode *header, *tail; //zskiplistNode  指向头尾的指针
    unsigned long length; // 当前跳跃表的长度
    int level; //当前跳跃表的高度
} zskiplist;
字段 含义
header 指向跳跃表头结点的指针
tail 指向跳跃表尾结点的指针
length 当前跳跃表的长度
level 当前跳跃表的层高

可以看到zskiplistNode是zskiplist结构中的一个成员,正如上面结构图所示,跳跃表是头尾两个指针,以及当前表的层高和长度。

5、创建跳跃表

5.1、获取新结点层高

结点层高最小值为1 ,最大值为64 ,其中最大值是 ZSKIPLIST_MAXLEVEL(64) 跳跃表除了Header结点以外,其余的结点层高是随机的,比如往跳跃表里面插入一个zskiplistNode结点其中当前结点的层高是随机的,越往高层概率越低,其中生成层高是通过zslRandomLevel的函数实现的,具体代码如下,代码在(t_zset.c)中:

int zslRandomLevel(void) {
    int level = 1; //默认层高是1
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) // ZSKIPLIST_P 默认是 0.25 
        level += 1; //层数加一
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL; //如果大于最大层数,则直接返回最大层数64
}

如代码所示,随机树会在0XFFFF中随机生成一个数,如果该数小于0XFFFF的0.25倍(一个实验出来的常量值)的时候,返回的层数会加一,如果最终返回的层数大于最大层数,那么直接返回最大层数64,通过这种方式可以随机的获取跳跃表每个结点的层数,而且也满足跳跃表的原则:越高层元素越少的特点

5.2、创建跳跃表节点

跳跃表的每个节点都是有序集合的一个元素,在创建跳跃表节点时,待创建节点的层高,分值等基本数据都能确定,对于每个结点我们都需要申请内存存储,代码如下,代码在(t_zset.c)中:

/**
 * 
 * @param level 层高 
 * @param score 结点分数
 * @param ele  sds 数据
 * @return 
 */
zskiplistNode *zslCreateNode(int level, double score, sds ele) {
    zskiplistNode *zn =
        zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel)); //为跳跃表结点分配内存
    zn->score = score; //保存分数
    zn->ele = ele;  // sds数据
   return zn;
}

通过图上可以看出来,每个创建的zskiplistNode占用的空间大小等于:(zskiplistNode大小 + 当前结点层数 * zskiplistLevel大小

5.2.1、创建头结点

头结点是一个特殊的结点,除了有64层,以及像普通节点一样分配内存外,同时还会初始化每一层的forward为空,score为0 ,如下(t_zset.c):

    //这里指定创建层数为64,score为0 ,sds为null
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL); //zslCreateNode 分配内存
    //循环遍历初始化每一层
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        zsl->header->level[j].forward = NULL;
        zsl->header->level[j].span = 0;
    }
    //backward也设置成null
    zsl->header->backward = NULL;

5.3、跳跃表初始化

跳跃表会使用zslCreate函数去创建并且初始化一个空的跳跃表结构

zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;

    zsl = zmalloc(sizeof(*zsl)); //为跳跃外层主结构表分配空间
    zsl->level = 1; //默认层高
    zsl->length = 0; //默认长度
    //创建头节点这里指定创建层数为64,score为0 ,sds为null
    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;
    }
    //头节点的backward也设置成null
    zsl->header->backward = NULL;
    zsl->tail = NULL;
    return zsl;
}

5.4、插入节点

创建好跳跃表后,下一步就是往跳跃表中插入数据了,插入一般分为5步骤走:

1、创建待插入节点(如上所示)
2、根据score查找节点需要插入的位置
3、调整跳跃表高度
4、插入节点
5、调整backward指针

5.4.1、插入步骤一(查找插入节点位置)

无论是有序数组插入还是,普通有序链表插入,第一件事情都是查找到需要插入的位置,前面我们大致介绍了跳跃表的查找结点的方式

 /**
     * 这里有两个循环,外层按照当前跳跃表的层高循环
     * 内层根据结点的位置循环判断查找目标位置
     * 
     */
    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];
        /**
         * 这里的判断逻辑是有两块:
         * 1、当前点forward不为空并且当前score 小于目标插入score 或者
         * 2、当前点forward不为空并且当前score 等于目标插入score 并且 当前sds字符串 小于 待插入结点字符串
         */
        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;
    }

从代码中看出,代码里面使用了两个长度为64的数组来辅助操作,update[] 和 rank[]

update[]:记录插入结点时候,所涉及会影响到的结点位置的指针
rank[]:记录当前层从header结点到update[i]结点经理的步长

如图所示,假设当前跳表的状态如下图:
在这里插入图片描述
假设需要插入的结点为:(score=31 ,Level =3 )那么过程如下:

1、第一次循环, i=1 。x为跳跃表的头表节点
2、此时i值和 zsl->Level -1 相等,所以rank[1]=0
3、内层 while 条件也满足进入while循环,循环中保存 rank[1] 为1 
4、while结束后update[1] 保存 x的指针(score=1)
5、第二次循环,i=0 x 为 score =1 结点的第L0层
6、此时i的值与zsl->level -1 不相等,所以rank[0]等于rank[1]的值为1
7、内层 while 条件也满足进入while循环,循环中rank[0]=1+1
8、最终循环都退出rank[0]=2 update[0]=(score=21)

结果如图所示:
在这里插入图片描述

5.4.2、插入步骤二(调整跳跃表层高)

由函数zslRandomLevel得知,跳跃表每个插入结点的层高是随机的,那么跳跃表整体层高也是随机的,如上所示,当出现新增的结点层高大于当前跳跃表层高的时候(上面跳跃表层高是2,新增结点层高假设是3)我们需要调整跳跃表的整体层高,代码如下(t_zset.c):

	level = zslRandomLevel(); //随机获取当前新增结点的层高
    if (level > zsl->level) { //如果生成的层高大于当前跳跃表的层高
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;      //对rank[当前层高]赋值为默认值0
            update[i] = zsl->header; //update[当前层高]设置为头结点
            update[i]->level[i].span = zsl->length;// 对当前层的span赋值
        }
        zsl->level = level; //更新当前跳跃表为新的层高
    }

调整后的结果如下:
在这里插入图片描述

5.4.3、插入步骤三(插入结点)

当update和rank都赋值好后,便可以插入结点了,代码如下(t_zset.c

	x = zslCreateNode(level,score,ele); //创建新的结点
    //和前面不同的是,这个循环是从底层往高层爬的
    for (i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward; //将新结点每一层的forward都等于update[i]当前层结点的forward
        update[i]->level[i].forward = x; //被更新结点的forward等与当前结点,因为当前节点是最高的
        //上面这里有一个技巧,无论level是比原本跳表小还是大,这个代码都满足,主要取决于跳跃表的结构特点

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

和以往不同这里的循环是从低层往高层爬的
插入过程如下:

第一次循环:
1、x的 level[0] 的forward为update[0]的level[0]的forward节点,即x->level[0].forward为score=41的节点
2、update[0]的level[0]的下一个节点为新插入的节点
3、rank[0]-rank[0]=0,update[0]->level[0].span=1,所以x->level[0].span=1
4、update[0]->level[0].span=0+1

第一次循环结果如下图:
在这里插入图片描述

第二次循环:
1、x的level[1]的forward为update[1]的level[1]的forward节点,即x->level[1].forward为NULL
2、update[1]的level[1]的下一个节点为新插入的节点
3、rank[0]-rank[1]=1,update[1]->level[1].span=2,所以x->level[1].span=1
4、update[1]->level[1].span=1+1=2

第二次循环结果如下:
在这里插入图片描述

第三次循环:
1、x的level[1]的forward为update[1]的level[1]的forward节点,即x->level[1].forward为NULL
2、update[2]的level[2]的下一个节点为新插入的节点
3、rank[0]-rank[2]=2,因为update[2]->level[2].span=3,所以x->level[2].span=1
4、update[2]->leve[2].span=2+1=3

第三次循环结果如下:
在这里插入图片描述
以上就是插入的完整流程,但是除了以上的步骤之外还有一段代码如下(t_zset.c):

for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

如果插入的节点level大于当前跳跃表的层高,那么这段代码不会执行,如果插入的节点的level小于跳跃表的层高那么其余节点大于level层的span都需要+1

5.5、调整backward

根据update的赋值过程,新插入节点的前一个结点一定是update[0],由于每个结点的后退指针只有一个,与此结点的层数无关,所以当插入结点不是最后一个结点时,需要初始化插入结点的backward和他后一个结点的backward,如果插入结点是最后一个结点,只需要初始化自身的backward即可
代码如下(t_zset.c):

	x->backward = (update[0] == zsl->header) ? NULL : update[0]; //如果插入结点前一个结点是header 则初始化为backward为 NULL否则为前一个结点
    if (x->level[0].forward) //判断插入结点是否为最后一个结点
        x->level[0].forward->backward = x; //如果不是最后一个结点更新下一个结点的backward为自己
    else
        zsl->tail = x; //插入结点是最后一个结点,更新跳跃表的尾指针为当前结点
    zsl->length++;  //跳跃表总长度加1

结果如下图:
在这里插入图片描述
到目前为止,整个跳跃表的插入工作就已经结束了

6、删除结点

删除是不可少的一种操作,跳跃表的删除结点操作分为两步:

1、查找目标结点
2、设置span和forward

6.1、查找目标结点

和插入一样,查找的时候也需要借助update数组,流程和上面插入的过程是一样的,假设需要删除的结点是score=31那么标记结果如下图:
在这里插入图片描述
标记结点代码如下(t_zset.c):

 	zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x; //创建update数组
    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)))
        //sdscmp 是 sds的比较函数
        {
            x = x->level[i].forward;
        }
        update[i] = x; //添加进update数组
    }

内容和上面的思路差不多

6.2、设置span和forward

删除结点需要设置update数组中每个结点的span和forward,由于删除了结点,必然导致删除结点的前后每层span变化,代码如下(t_zset.c):

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

如果update[i]第i层的forward不为x,说明update[i]的层高大于x的层高,即update[i]第i层指向了x的后续结点或指向NULL。由于删除了一个结点,所以update[i]的level[i]的span减1。
如果update[i]的forward不为x,在要删除结点的高度小于跳表高度的情况下出现,i大于x高度的结点的forward与x无关,所以这些结点只需要更新其span减1即可
操作后的状态如下图:
在这里插入图片描述
update结点更新完之后,需要更新backward指针、跳跃表高度和长度。如果x不为最后一个节点,直接将第0层后一个节点的backward赋值为x的backward即可,否则,将跳跃表的尾指针指向x的backward节点即可。代码如下(t_zset.c):

	if (x->level[0].forward) { //被删除的不是尾节点
        x->level[0].forward->backward = x->backward; //调整尾节点backward的指针
    } else {
        zsl->tail = x->backward; //是尾节点,更新跳跃表的tail指针
    }
    while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL) //调整跳跃表高度
        zsl->level--;
    zsl->length--; //调整跳跃表长度

在这里插入图片描述

7、删除跳跃表

删除跳跃表是一个比较极端的操作,其中会遍历整个跳跃表,从头结点0层开始,通过forward一个个遍历去释放节点内存,和删除节点不一样,删除跳跃表时候的删除节点更加暴力不留余地,代码如下(t_zset.c):

	void zslFree(zskiplist *zsl) {
    zskiplistNode *node = zsl->header->level[0].forward, *next;

    zfree(zsl->header); //释放头结点
    while(node) {
        next = node->level[0].forward; //按照0层遍历到下一个
        zslFreeNode(node); //内部根据不同的结构进行不同的释放方式
        node = next;
    }
    zfree(zsl); //释放整个跳跃表主体外层结构
}

zslFreeNode的内部

void zslFreeNode(zskiplistNode *node) {
    sdsfree(node->ele);
    zfree(node);
}

8、结束

到这里Redis的整个跳跃表讲解就结束了,这里我们了解了跳跃表在Redis里面的实现方式,以及增删改查等操作,同时也对比了普通链表和跳跃表的区别,顺便这里也可以说明一下,为什么Redis不太愿意使用红黑树的方式来替代跳跃表。

首先,跳跃表的实现方式确实是比红黑树简单,并且容易维护,况且Redis使用跳跃表的地方只有Redis有序集合键,和集群节点管理,其余之处并未使用,可见就算使用红黑树,并不能让Redis性能有较高的提升,大量数据的情况下而且红黑树的深度也会是一个问题。

其次,跳跃表虽然会和红黑树对比,但是本质上还是线性的数据结构,与树形结构还是有所不同,线性结构在顺序相关的需求上会有天然的优势,而且通过链表可以很快速获取当前节点的先驱或者后继。

以上内容是本人自己的见解,并不代表官方权威解释

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章