文章目录
前言
本文总结了众多文章中最精华的部分,将从以下几点剖析redis中zset数据类型:
- zset的基本功能;
- zset的底层数据结构;
- 什么是跳表;
- hash、B+树、跳表的比较。
一、zset的基本功能
1.1 zset基本功能描述
redis的zset是一个自动根据元素score排序的有序集合,和普通集合set非常相似,是一个没有重复元素的字符串集合。
1.2 zset的常用命令
#返回按score从大到小排序后且索引在[start,stop]区间的元素,从0开始
zrevrange key start stop [WITHSCORES]
#返回按score从大到小排序后且分数在[min,max]区间的元素
zrevrangebyscore key max min[WITHSCORES]-
#通过字典区间返回有序集合的成员
zrangebylex key min max[LIMIT offset count]
#返回元素member的索引,不存在nil
zrank key member-
#返回有序集合中指定成员的排名,有序集成员按分数值递减(从大到小)排序
zrevrank key member
#返回有序集合中,元素member分数
zscore key memberr
#迭代有序集合中的元素(包括元素的分值)
zscan key cursor [MATCH pattern][COUNT count]
#计算给定的一个或多个有序集的并集,并存储在新的 key 中
zunionstore destination numkeys key [key …]-
#计算给定的一个或多个有序集的交集并将结果集存储在新的有序集合 key 中
zinterstore destination numkeys key [key …]
#有序集合中对指定元素的分数加上增量 increment
zincrby key increment member
#移除有序集合中的一个或多个元素
zrem key member[member…]
#移除有序集合中给定的字典区间的所有成员
zremrangebylex key min max
#移除有序集合中给定的排名区间的所有成员
zremrangebyrank key start stop
#移除有序集合中给定的分数区间的所有成员
zremrangebyscore key min max
二、zset的底层数据结构
zset有两种不同的实现方式:紧凑列表和跳表。
2.1 紧凑列表
在元素较少或总体元素占用空间较少时,使用紧凑列表实现。
紧凑列表在不同redis版本有不同的实现:早期版本[ziplist]–>Redis3.0[quicklist]–>Redis5.0[listpack]
紧凑列表不同版本的详解这里先不做赘述,[另外一个文章](https://blog.csdn.net/qq_32139981/article/details/141677488)讲解,本文主要针对跳表
2.2 跳表
在不符合使用紧凑列表的条件时,使用字典hash+跳表skiplist实现。
三、什么是跳表
3.1 跳表定义
跳表(SkipList):增加了向前指针的链表叫作跳表。跳表全称叫做跳跃表,简称跳表。跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。
3.2 跳表详解
3.2.1 跳表的演进
【单链表】
对于一个单链表来说,即使链表中的数据是有序的,如果我们想要查找某个数据,也必须从头到尾的遍历链表,很显然这种查找效率是十分低效的,时间复杂度为O(n)。
那么我们如何提高查找效率呢?我们可以对链表建立一级“索引”,每两个结点提取一个结点到上一级,我们把抽取出来的那一级叫做索引或者索引层,如下图所示,down表示down指针。
【单链表+一级索引】
假设我们现在要查找值为16的这个结点。我们可以先在索引层遍历,当遍历索引层中值为13的时候,通过值为13的结点的指针域发现下一个结点值为17,因为链表本身有序,所以值为16的结点肯定在13和17这两个结点之间。然后我们通过索引层结点的down指针,下降到原始链表这一层,继续往后遍历查找。这个时候我们只需要遍历2个结点(值为13和16的结点),就可以找到值等于16的这个结点了。如果使用原来的链表方式进行查找值为16的结点,则需要遍历10个结点才能找到,而现在只需要遍历7个结点即可,从而提高了查找效率。
那么我们可以由此得到启发,和上面建立第一级索引的方式相似,在第一级索引的基础上,每两个一级索引结点就抽到一个结点到第二级索引中。再来查找值为16的结点,只需要遍历6个结点即可,从而进一步提高了查找效率。
【单链表+二级索引】
上面举得例子中的数据量不大,所以即便加了两级索引,查找的效率提升的也不是很明显,下面通过一个64结点的链表来更加直观的感受下索引提升查找效率,如图所示,建立了五级索引。
【单链表+五级索引】
从图中我们可以看出来,原来没有索引的时候,查找62需要遍历62个结点,现在只需要遍历11个结点即可,速度提高了很多。那么,如果当链表的长度为10000、10000000时,通过构件索引之后,查找的效率就会提升的非常明显。
3.2.1 跳表高效的动态插入和删除
跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是 ○(㏒n)。
对于单纯的单链表,需要遍历每个结点来找到插入的位置。但是对于跳表来说,因为其查找某个结点的时间复杂度是 ○(㏒n),所以这里查找某个数据应该插入的位置,时间复杂度也是 ○(㏒n)。
3.2.2 跳表索引动态更新
当我们不停的往跳表中插入数据时,如果我们不更新索引,就可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表会退化成单链表。
作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平滑,也就是说如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。
跳表是通过随机函数来维护前面提到的 平衡性。
我们往跳表中插入数据的时候,可以选择同时将这个数据插入到第几级索引中,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。
那么这个随机函数是如何生成的呢?下面看一下源码:
// file: src/t_zset.c#defineZSKIPLIST_MAXLEVEL32/* Should be enough for 2^32 elements */#defineZSKIPLIST_P0.25/* Skiplist P = 1/4 *//* Returns a random level for the new skiplist node we are going to create.
* 返回一个随机值,用作新跳跃表节点的层数。
* 返回值介乎 1 和 ZSKIPLIST_MAXLEVEL 之间(包含 ZSKIPLIST_MAXLEVEL),
* 根据随机算法所使用的幂次定律,越大的值生成的几率越小。
*
* T = O(N)
*/intzslRandomLevel(void){int level =1;while((random()&0xFFFF)<(ZSKIPLIST_P *0xFFFF)) level +=1;return(level < ZSKIPLIST_MAXLEVEL)? level : ZSKIPLIST_MAXLEVEL;}
redis通过zslRandomLevel函数随机生成一个1~32的值,作为新建节点的高度,值越大出现的概率越低,节点高度确定后不会再修改,从上述生成节点高度代码可以看出,level的初始值为1,通过while循环,每次生成一个随机值,取这个值的低16位作为x,当x小于0.25倍的0xFFFF时,level值加1;否则return退出循环,最终返回level和ZSKIPLIST_MAXLEVEL这两者中的最小值。
直观上期望的目标是 50% 的概率被分配到Level 1,25% 的概率被分配到Level 2,12.5% 的概率被分配到Level 3,以此类推…有 2^63 的概率被分配到最顶层,因为这里每一层的晋升率都是 50%。
Redis 跳跃表默认允许最大的层数是 32,被源码中 ZSKIPLIST_MAXLEVEL 定义,当 Level[0] 有 2^64 个元素时,才能达到 32 层,所以定义 32 完全够用了。
3.3 zset中的跳表
3.3.1 skiplist数据结构
skiplist作为zset的存储结构,整体存储结构如下图,核心点主要是包括一个dict对象和一个skiplist对象。dict保存key/value,key为元素,value为分值;skiplist保存的有序的元素列表,每个元素包括元素和分值。两种数据结构下的元素指向相同的位置。
上图中 zskiplist 结构包含以下属性:
- header:指向跳跃表的表头节点
- tail:指向跳跃表的表尾节点
- level:记录目前跳跃表内,层数最大的那个节点层数(表头节点的层数不计算在内)
- length:记录跳跃表的长度,也就是跳跃表目前包含节点的数量(表头节点不计算在内)
位于 zskiplist 结构右侧是四个 zskiplistNode 结构,该结构包含以下属性:
- 层(level):节点中用 L1、L2、L3 等字样标记节点的各个层,L1 代表第一层,L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其它节点,而跨度则记录了前进指针所指向节点和当前节点的距离。
- 后退(backward)指针:节点中用 BW 字样标识节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
- 分值(score):各个节点中的 1.0、2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。
- 成员对象(obj):各个节点中的 o1、o2 和 o3 是节点所保存的成员对象。
四、hash、B+树、跳表的比较
数据结构实现原理key查询方式查找效率存储大小插入、删除效率Hash哈希表支持单key接近O(1)小,除了数据没有额外的存储O(1)B+树平衡二叉树扩展而来单key,范围,分页O(Log(n)除了数据,还多了左右指针,以及叶子节点指针O(Log(n),需要调整树的结构,算法比较复杂跳表有序链表扩展而来单key,分页O(Log(n)除了数据,还多了指针,但是每个节点的指针小于<2,所以比B+树占用空间小O(Log(n),只用处理链表,算法比较简单
五、Redis 为什么使用跳表而不是B+树
Redis使用跳表而不是B树的主要原因有以下几点:
- 时间复杂度优势:跳表是一种基于链表的数据结构,可以在O(log n)的时间内进行插入、删除和查找操作。而B树需要维护平衡,操作的时间复杂度较高,通常为O(log n)或者更高。在绝大多数情况下,跳表的性能要优于B树。
- 简单高效:跳表的实现相对简单,并且容易理解和调试。相比之下,B树的实现相对复杂一些,需要处理更多的情况,例如节点的分裂和合并等操作。
- 空间利用率高:在关键字比较少的情况下,跳表的空间利用率要优于B树。B树通常需要每个节点存储多个关键字和指针,而跳表只需要每个节点存储一个关键字和一个指针。
- 并发性能好:跳表的插入和删除操作比B树更加简单,因此在并发环境下更容易实现高性能。在多线程读写的情况下,跳表能够提供较好的并发性能。
总的来说,Redis选择跳表作为有序集合数据结构的底层实现,是基于跳表本身的优点:时间复杂度优势、简单高效、空间利用率高和并发性能好。这使得Redis在处理有序集合的操作时能够获得较好的性能和并发能力。redis是内存数据库,而B+树纯粹是为了mysql这种IO数据库准备的。B+树的每个节点的数量都是一个mysql分区页的大小。
参考文章
https://www.cnblogs.com/xuwc/p/14016461.html
https://blog.csdn.net/u011066470/article/details/132914218
版权归原作者 romanYSX 所有, 如有侵权,请联系我们删除。