文章目录
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性能有较高的提升,大量数据的情况下而且红黑树的深度也会是一个问题。
其次,跳跃表虽然会和红黑树对比,但是本质上还是线性的数据结构,与树形结构还是有所不同,线性结构在顺序相关的需求上会有天然的优势,而且通过链表可以很快速获取当前节点的先驱或者后继。
以上内容是本人自己的见解,并不代表官方权威解释