redis的数据结构zset---跳跃表

一、跳跃表

这里思考一下redis的zset有哪些特点,为什么要用跳表,明白跳表示什么
功能一:zset支持快速插入和删除
对应的解决思路:针对快速插入和删除,有没有想到什么?首选肯定是链表,所以,底层基础得有一个value和score组成的node连接起来的链表。
功能二:zset有序且支持范围查询,且是的
对应的解决思路:有序这个条件,我们可以先让链表按照顺序排列,但查找来说,链表的查询时间复杂度为O(n),并不高效,还要满足范围查找,如何解决这个问题?那么这时候就想到,能不能给链表做一个索引,提高它的查找效率的同时,让它也能支持范围查找,构建索引的话,是为了提高效率,如果只构建一层索引,数据量小的时候无所谓,但数据量大的时候呢?好像无法起到根本上提升效率的作用,所以应该给链表添加多级索引。

1.1 什么是跳跃表

在这里插入图片描述
以上这种链表加多级索引的结构,就是跳表。每两个节点向上抽取一个,抽取到上一级的叫做索引,索引可继续向上抽取。目的是为了提高查询效率。

二、Redis跳跃表

Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时, Redis就会使用跳跃表来作为有序集合健的底层实现。

这里我们需要思考一个问题——为什么元素数量比较多或者成员是比较长的字符串的时候Redis要使用跳跃表来实现?

从上面我们可以知道,跳跃表在链表的基础上增加了多级索引以提升查找的效率,但其是一个空间换时间的方案,必然会带来一个问题——索引是占内存的。原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值值和几个指针,并不需要存储对象,因此当节点本身比较大或者元素数量比较多的时候,其优势必然会被放大,而缺点则可以忽略。

2.1 Redis中跳跃表的实现

1.Redis的跳跃表有两个结构定义(zskiplistNode和skiplist)
2.节点结构为zskiplistNode(跳跃表节点)
3.跳跃表信息zskiplist(保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等).下图最左边
在这里插入图片描述

4.zskiplist结构属性
5.header:指向跳跃表表头节点
6.tail:指向跳跃表表尾节点
7.level:指跳跃表当前层数最大那个节点的层数(表头层数不计算在内)
8.length:记录跳跃表的长度,跳跃表当前节点数量(表头节点不计算在内)
9.zskiplistNode结构属性
10.层(Level):节点中用L1、L2、L3等表示,每层都带有两个属性:前进指针与跨度。前进指针用于访问表尾方向的其他节点;跨度记录了前进指针指向节点与当前节点距离
11.后退(backward)指针:节点中用BW表示,指向当前节点的前一个节点
12.分值(score):各个节点中的1.0、2.0、3.0是节点的分值,在跳跃表中,节点按各自保存的分支从小到大排序
13.成员对象(object):各个节点中的o1、o2、o3是节点所保存的对象

2.1.1 跳跃表节点

在这里插入图片描述

2.1.2 层

1.跳跃表节点的level数组可以包含多个元素,一般来说层数越多访问其他节点的速度越快。
2.每次创建一个新跳跃表节点的时候,程序根据幂次定律(power law,越大的数出现的频率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”

2.1.3前进指针

每个层都有一个指向表尾方向下一个节点的前进指针。图5-3用虚线表示出了程序从表头向表尾方向,遍历跳跃表中所有节点的路径:
在这里插入图片描述
1.迭代程序首先访问跳跃表中的第一个节点(表头),然后从第四层的前进指针移动到第一个节点
2.在第二个节点时,程序沿着第二层的前进指针移动到第三个节点
3.在第三个节点时,程序沿着第二层的前进指针移动到第四个节点
4.当程序继续沿着第二层的前进指针移动时,它碰到一个null,程序知道这时已经达到了跳跃表表尾,结束本次遍历

问题:为什么从所有跨度为1的前进指针中选择层数最高的指针进行遍历?

1.因为层数最高跳跃的步进越大且过滤掉的数据越大,类似于二叉树,先过滤掉一半数据,层数最高如果只有两步,且刚好是中间位置,则就是二叉树的第一步二分,可以滤掉更多的数据。
2.并且可以判断,如果前进是超过了当前目标值,则说明目标值就在当前与下个跳跃节点之间,那么可以用同样的方式判断递减一层的数据,直至找到最低层,从最底层开始查找目标值,这样保证了遍历的链表元素是最少的,小于log(N)复杂度

2.2 Redis跳跃表常用操作的时间/空间复杂度

跳表查询时间复杂度:
有n个结点的链表,假设每两个链表构建一个索引,那么:
第一级索引个数为:n/2;
第二级索引个数为:n/4;
···
第h级索引个数为:n/2^h;
现在假设最后一级索引的个数为2 ,则h +1 = logn,算上最底下的一层链表,那么这个跳表的高度H= logn。
当我们要查找跳表里的一个数时,参考图如下:
在这里插入图片描述
在图里,我们想查找x,在第k级,遍历到y结点,发现x大于y,但x小于y后面的结点z,所以先顺着y往下到第k-1级,发现y,z之间有三个节点,所以我们在k-1级索引中,遍历3个节点找到x,以此类推,在每一层需要通过3个节点找目标数,那么总的时间复杂度就为O(3*logn),因为3是常数,所以最后的时间复杂度为O(logn)。
这一结构相当于让跳表实现了二分查找,只是建立这么多的索引是否会浪费空间呢?我们来看一下跳表的空间复杂度。
跳表的空间复杂度:
还是回到刚刚的例子,我们可以发现,链表上的索引数目按第一层,第二层,···,倒数第二层,最后一层的顺序排列下来分别为:n/2,n/4,···,4,2,观察到了吗?就是一个等比数列,计算该跳表的空间复杂度,相当于给等比数列求和,高中数学都快忘完了,网上求得一个等比数列求和公式,放在这里:
在这里插入图片描述
顺着公式依次带入:a1=n/2,an= 2,q=1/2,求得Sn= n-2,所以空间复杂度为O(n),与此同时,我们顺便考虑一下每三个节点抽取一个索引的情况,还是依据刚刚的思路,发现Sn= n-1/2,空间复杂度将近缩减了一半。
总之,跳表就是空间换时间的那个思路,但如果链表中存储的对象很大时,其实索引占用的这些空间对整个来说是可以忽略不计的。

三、跳表的高效插入和删除:

插入
  之前就说了,之所以选用链表作为底层结构支持,也是为了高效地动态增删。单链表在知道删除的节点是谁时,时间复杂度为O(1),因为跳表底层的单链表是有序的,为了维护这种有序性,在插入前需要遍历链表,找到该插入的位置,单链表遍历查找的时间复杂度是O(n),同理可得,跳表的遍历也是需要遍历索引数,所以是O(logn)。

删除
  删除的节点要分两种情况,如果该节点还在索引中,那删除时不仅要删除单链表中的节点,还要删除索引中的节点;另一种情况是删除的节点只在链表中,不在索引中,那只需要删除链表中的节点即可。但针对单链表来说,删除时都需要拿到前驱节点才可改变引用关系从而删除目标节点。

跳表的动态更新:
跳表更新时有一个不能忽视的重要问题,如果在单链表的两个节点之间一直插入,会导致跳表退化成单链表,就像平衡二叉树和红黑树一样,跳表在这样的插入操作下,也是需要一些调整来维持高效的结构的:
  跳表通过“随机函数”来维护前面的“高效性”,具体的操作是:往跳表中插入数据a时,通过随机函数生成一个随机数h,a插入单链表的同时,在第1级到第h级中也同时插入索引a。随机函数不是乱选的,要能保证索引的大小及跳表的平衡性,防止它退化成单链表的窘境。跳表的实现有点复杂,所以在此不再赘述。

四、为什么使用跳跃表,而不是平衡树等用来做有序元素的查找

1.跳跃表的时间复杂度和红黑树是一样的,而且实现简单
2.在并发的情况下,红黑树在插入删除的时候可能需要做rebalance的操作,这样的操作可能会涉及到整个树的其他部分;而链表的操作就会相对局部,只需要关注插入删除的位置即可,只要多个线程操作的地方不一样,就不会产生冲突。

开发者的解释:

开发者的解释:
There are a few reasons:
They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a
 node to have a given number of levels will make then less memory intensive than btrees. => 并不是非常耗费
 内存。控制好ZSKIPLIST_P的值,内存消耗和平衡树差不多
 
A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a 
linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced 
trees. => 有序集合经常会进行 zrange 或 zrevrange 这样的范围查找,跳表里的双向链表可以十分方便的进行这
操作
They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a 
patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little 
changes to the code.

About the Append Only durability & speed, I don’t think it is a good idea to optimize Redis at cost of more code
 and more complexity for a use case that IMHO should be rare for the Redis target (fsync() at every 
 command). Almost no one is using this feature even with ACID SQL databases, as the performance hint is big 
 anyway. => 实现简单,zrank 还能达到O(logn)的时间复杂度
 



About threads: our experience shows that Redis is mostly I/O bound. I’m using threads to serve things from
 Virtual Memory. The long term solution to exploit all the cores, assuming your link is so fast that you can 
 saturate a single core, is running multiple instances of Redis (no locks, almost fully scalable linearly with 
 number of cores), and using the “Redis Cluster” solution that I plan to develop in the future.

在 src/t_zset.c 文件中,主要有两个结构,zskiplist 和 zskiplistNode,前者保存跳跃表信息(如表头节点、表尾节点、长度),而 zskiplistNode 用于保存节点
另外,跳跃表中的节点按照分值大小进行排序, 当分值相同时, 节点按照成员对象的大小进行排序。这一点也是redis针对跳表这个结构做出的优化之一。具体的优化点为:

  • 允许重复分值,即多个节点允许相同的分值,但是每个节点的成员对象必须是唯一的
  • 比较的时候不仅仅是分值,还有整个对象,即分值相同,按照成员对象大小排序
  • 在第一层有一个back指针,适用于 ZREVRANGE 方法,允许从尾部到头部来遍历列表。

五、为何使用跳表不使用B+树做索引

因为B+树的原理是 叶子节点存储数据,非叶子节点存储索引,B+树的每个节点可以存储多个关键字,它将节点大小设置为磁盘页的大小,充分利用了磁盘预读的功能。每次读取磁盘页时就会读取一整个节点,每个叶子节点还有指向前后节点的指针,为的是最大限度的降低磁盘的IO;因为数据在内存中读取耗费的时间是从磁盘的IO读取的百万分之一
而Redis是 内存中读取数据,不涉及IO,因此使用了跳表;

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值