Redis数据类型之有序集合(sorted set)

Redis有序集合是string类型元素的集合,每个元素关联一个double分数进行排序。它通过ZADD、ZCARD、ZRANGE等命令进行操作。有序集合的底层数据结构包括ziplist和skiplist,ziplist适合元素数量较少且元素较小的情况,而skiplist则提供更快的查找速度。插入、删除和查找操作的时间复杂度为O(logn)。
摘要由CSDN通过智能技术生成

Redis数据类型之有序集合(sorted set)

一定注意看红色注意项。

Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。

不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。

有序集合的成员是唯一的,但分数(score)却可以重复。

集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。 集合中最大的成员数为 232 - 1 (4294967295, 每个集合可存储40多亿个成员)。

他的结构如下图:索引有正向和反向索引。

在这里插入图片描述

特征:和set具有相似特征,但是他是有序的,因为他里面不光存有元素,还存有一个叫score的double型数值,可以叫他权重,也可以叫分值。就是通过这个分值来排序的,而且插入后自动排序,如果所有元素分值都为1,那么就按照元素的ASCII码排

他的命令基本都是Z开头的String命令(Z的来历,因为set已经用了S开头,所以他就以最low的方式命名,直接取26个字母的最后一个命名,就是这么任性):如下

  1. Zadd:score1 是分值,可以设置一个任意值;member1 是值。如果设置多个同样的值,会自动去重。score 和member成对出现,表示这个值的分值是多少。用于排序
    ZADD key score1 member1 [score2 member2]
    向有序集合添加一个或多个成员,或者更新已存在成员的分数

  2. Zcard:返回集合的成员数量有几个
    ZCARD key
    获取有序集合的成员数

  3. Zrange:start和stop是他的索引,有正向索引和反向索引。Zrange k1 0 -1;表示取出所有值。WITHSCORES表示取出他的分值。
    ZRANGE key start stop [WITHSCORES]
    通过索引区间返回有序集合指定区间内的成员
    在这里插入图片描述

  4. ZrangebyScore:min和max表示分值的大小
    ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT]
    通过分数返回有序集合指定区间内的成员
    在这里插入图片描述

  5. Zrevrange:因为他是按分值顺序排序的,如果想要取出分数最高的前两名怎么取呢。Zrevrange k1 0 1。rev全程reverse是反向的意思。**适用于取出点赞数最多的前10名场景下
    ZREVRANGE key start stop [WITHSCORES]
    返回有序集中指定区间内的成员,通过索引,分数从高到低
    在这里插入图片描述

  6. Zscore:
    ZSCORE key member
    返回有序集中,成员的分数值

  7. Zrank:
    ZRANK key member
    返回有序集合中指定成员的索引

  8. Zincrby:增量后会实时维护数据的顺序,比如在歌曲排行榜中一般按照热度排行,可以设置热度值为score。score会随时增或者减,减就是设置负值。随着score的变化,排行榜顺序会不断更新。因为redis是单线程的也不会有安全问题。
    ZINCRBY key increment member
    有序集合中对指定成员的分数加上增量 increment
    在这里插入图片描述

  9. Zcount:
    ZCOUNT key min max
    计算在有序集合中指定区间分数的成员数

  10. Zrem:删除元素
    ZREM key member [member …]
    移除有序集合中的一个或多个成员

  11. ZremrangeByrank:移除排名(既索引)区间内的值;zremrangebyrank k1 0 1表示移除前两名
    ZREMRANGEBYRANK key start stop
    移除有序集合中给定的排名区间的所有成员
    在这里插入图片描述

  12. ZremrangeByscore:移除分值区间内的值;zremrangebyscore k1 0 2表示删除分值在0-2之间的元素
    ZREMRANGEBYSCORE key min max
    移除有序集合中给定的分数区间的所有成员
    在这里插入图片描述

  13. Zrevrangebyscore:这个是从大到小排的别写反了
    ZREVRANGEBYSCORE key max min [WITHSCORES]
    返回有序集中指定分数区间内的成员,分数从高到低排序
    在这里插入图片描述

因为他有set特征,所以他也有交、并、差集
  1. Zunionstore:destination 表示新key的名称,numkeys表示有几个key参与并集。 后面表示参与的key。 (后面不写就是默认值)weights 给前面参与的key设置权重,按顺序设置。aggergate 表示并集时使用的函数,默认用SUM求和
    zunionstore destination numkeys key [key …] [WEIGHTS weight] [AGGREGATE SUM|MIN|MAX]
    计算给定的一个或多个有序集的并集,并存储在新的 key 中
    在这里插入图片描述
    可以看出增加aggregate min 表示对并集求最小值返回。同理max最大值。默认使用sum。
    weights权重表示并集时对每个元素乘以权重后进行并集,如下
    在这里插入图片描述

在这里插入图片描述

  1. Zinterstore:destination 表示新key的名称,numkeys表示有几个key参与并集。 后面表示参与的key。 (后面不写就是默认值)weights 给前面参与的key设置权重,按顺序设置。aggergate 表示并集时使用的函数,默认用SUM求和
    zinterstore destination numkeys key [key …] [WEIGHTS weight] [AGGREGATE SUM|MIN|MAX]
    计算给定的一个或多个有序集的交集并将结果集存储在新的有序集合 destination 中
    交集和并集的参数设置一样。只是求他们的交集而已。
    在这里插入图片描述
    当权重都为2时,求max。他们的交集是tom和sean。tom里面最大值时80,乘以他的权重2就是160。sean最大值是100,乘以权重2等于200。所以最终他们的交集是 tom 160 sean 200

sorted set (zset)的排序是怎么实现的,底层的数据结构是什么样的。

ZSet 有两种不同的实现,分别是 ziplist压缩表 和 skiplist跳跃表。具体使用哪种结构进行存储,规则如下:
当sorted set中的元素个数小于128时(即元素对member score的个数,共256个元素),使用ziplist,若ziplist中所有元素的总长度超过64字节时使用zset。

zset配置

zset-max-ziplist-entries128
zset-max-ziplist-entries64

ziplist(压缩表)

ziplist 是一个特殊双向链表,不像普通的链表使用前后指针关联在一起,它是存储在连续内存上的。

整体的结构布局如下图:

在这里插入图片描述

  1. zlbytes: 32 位无符号整型,记录 ziplist 整个结构体的占用空间大小。当然了也包括 zlbytes本身。这个结构有个很大的用处,就是当需要修改 ziplist 时候不需要遍历即可知道其本身的大小。 这个 SDS中记录字符串的长度有相似之处,这些好的设计往往在平时的开发中可以采纳一下。
  2. zltail: 32 位无符号整型, 记录整个 ziplist 中最后一个 entry 的偏移量。所以在尾部进行 POP操作时候不需要先遍历一次。
  3. zllen: 16 位无符号整型, 记录 entry 的数量, 所以只能表示2^16。但是 Redis 作了特殊的处理:当实体数超过 2^16 ,该值被固定为 2^16 - 1。 所以这种时候要知道所有实体的数量就必须要遍历整个结构了。
  4. entry: 真正存数据的结构。
  5. zlend: 8 位无符号整型, 固定为 255 (0xFF)。为 ziplist 的结束标识。
entry 节点

每个 entry 都包含两条信息的元数据为前缀:
第一元数据用来存储前一个 entry 的长度,以便能够从后向前遍历列表。
第二元数据是表示 entry 的编码形式。 用来表示 entry 类型,整数或字符串,在字符串的情况下,它还表示字符串有效的长度。
所以一个完整的 ziplist 是这样存储的:
在这里插入图片描述

<prevlen> <encoding> <entry-data>
prelen
  1. 记录前一个 entry 的长度。若前一个 entry 的长度小于 254 , 则使用 1 个字节的 8 位无符号整数来表示。(1111 1111=255)
<prevlen from 0 to 253> <encoding> <entry>
  1. 当链表的前一个 entry 占用字节数大于等于 254,此时 prevlen 用 5 个字节来表示,其中第 1 个字节的值固定是 254 (FE)(相当于是一个标记,代表后面跟了一个更大的值),后面 4 个字节才是真正存储前一个 entry 的占用字节数。
0xFE <4 bytes unsigned little endian prevlen> <encoding> <entry>

注意:1 个字节完全你能存储 255 的大小,之所以只取到 254 是因为 zlend 就是固定的 255,所以 255 这个数要用来判断是否是 ziplist 的结尾。

encoding 编码

entry 的编码字段取决于具体值的内容,分为字符串、数字两种类型单独处理。

  1. 当 entry 是字符串时,有 3 种编码方式。编码第 1 个字节的前 2 位将保存用于存储字符串长度的编码类型,后面是字符串的实际长度。
1. 长度小于或等于 63 字节(6 位)的字符串值。 “pppppp”表示无符号的 6 位数据长度。

|00pppppp| - 1 byte

2. 长度小于或等于 16383 字节(14 位)的字符串值。14 位的数据采用  big endian 存储。
big endian 是一种字节序方式,有Little-EndianBig-Endian两种。

|01pppppp|qqqqqqqq| - 2 bytes

3. 长度大于或等于 16384 字节的字符串值。
采用 big endian 存储且可表示的字符串长度最大2^32-1,所以第一个字节没有用到,所以低6位没有用,所以都是0|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 bytes 

  1. 当 entry 是整数时,有 6 种编码方式。前 2 位都固定为 1,接下来的 2 位用于指定将在此标头后存储哪种类型的整数。
1. 整数编码为 int16_t(2 字节)。

|11000000| - 3 bytes


2. 整数编码为int32_t(4个字节)。

|11010000| - 5 bytes


3. 整数编码为 int64_t(8 字节)。

|11100000| - 9 bytes


4. 整数编码为24位带符号(3个字节)。

|11110000| - 4 bytes


5. 整数编码为 8 位有符号(1 字节)。

|11111110| - 2 bytes


6. 012的无符号整数。编码后的值实际上是113,因为00001111不能用,所以要从编码后的4位值中减去1才能得到正确的值。

|1111xxxx| - (with xxxx between 0001 and 1101) immediate 4 bit integer


  1. 结尾编码标识
1. 表示 ziplist 结尾的标识。

|11111111|


entry-data

entry-data 存储的是具体数据。当存储小整数(0-12)时,因为 encoding 就是数据本身,此时 entry-data 部分会被省略,省略了 entry-data 部分之后的 ziplist 中的 entry 结构如下:

<prevlen> <encoding>

ziplist 连锁更新问题

连锁更新是 ziplist 一个比较大的缺点,这也是在 v7.0 被 listpack 所替代的一个重要原因。

ziplist 在更新或者新增时候,如空间不够则需要对整个列表进行重新分配。当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。

ziplist 节点的 prevlen 属性会根据前一个节点的长度进行不同的空间大小分配:

1. 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值。
2. 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值。
假设有这样的一个 ziplist,每个节点都是等于 253 字节的。新增了一个大于等于 254 字节的新节点,比如插入到第一个位置了,由于之前的节点 prevlen 长度是 1 个字节。

为了要记录新增节点的长度所以需要对节点 1 进行扩展,由于节点 1 本身就是 253 字节,再加上扩展为 5 字节的 pervlen 则长度超过了 254 字节,这时候下一个节点又要进行扩展了。噩梦就开始了。
在这里插入图片描述

ziplist总结
  1. ziplist为了节省内存,采用了紧凑的连续存储。所以在修改操作下并不能像一般的链表那么容易,需要从新分配新的内存,然后复制到新的空间。
  2. ziplist 是一个双向链表,可以在时间复杂度为 O(1) 从下头部、尾部进行 pop 或 push。
  3. 新增或更新元素可能会出现连锁更新现象。
  4. 不能保存过多的元素,否则查询效率就会降低。

skiplist(跳跃表)

跳表(skiplist、跳跃表) 是一个很优秀的数据结构,比如用于 Redis、levelDB等出名的开源项目上。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。结构如图:
在这里插入图片描述

  • 跳表结合了链表和二分查找的思想
  • 由原始链表和一些通过“跳跃”生成的链表组成
  • 第0层是原始链表,越上层“跳跃”的越高,元素越少
  • 上层链表是下层链表的子序列
  • 查找时从顶层向下,不断缩小搜索范围

特性

跳表有很多层,如果只看0层的话,就是一个有序链表。那么其他层是什么呢?

我们知道,链表的查询复杂度为O ( n ) O(n)O(n)
在这里插入图片描述
如果在这个基础之上,增加一层“索引”,查找就会快一点点。
之前直接查找单链表,比如查询节点7:
在这里插入图片描述
会经过6个节点,那通过索引呢?在这里插入图片描述
经过4个节点就能找到了。是不是快了一点。
要注意到,每一层上的索引是可以向下层走的。上面的图只是一个简化结构,更严谨的一个结构应该如下:
在这里插入图片描述
最左边的是header节点,不存值,上图的31,出现在了0,1,2,3层,其实就是一个节点。不是四个节点(这个要看具体的实现,这里是通过数组实现,可以通过下标访问,也可以通过链式实现)。这些层次信息是通过forwards(ArrayList)保存的。因此可以很快的访问到下一层。

如果元素个数很多的话,通常层数也会相应的增加。比如我们再增加一层。
在这里插入图片描述
现在访问节点7经过的节点为:1->6->7。

这里有必要提出的是,每隔两个节点往上提升一层建立索引只是理想情况,实际上是通过随机层数来实现的。这点后面会分析。

结构

跳跃表由 redis . h/zskiplistNode 和 redis . h/zskiplist 两个结构定 义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构则用于保存跳 跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。
在这里插入图片描述

1. zskiplistNode 节点

在这里插入图片描述
层(level): 节点中用Ll、L2、L3等字样标记节点的各个层,L1代表第一层,L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于 访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的 距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是 跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。

后退(backward)指针: 节点中用BW字样标记节点的后退指针,它指向位于当前节 点的前一个节点。后退指针在程序从表尾向表头遍历时使用。

分值(score ): 各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中, 节点按各自所保存的分值从小到大排列。

成员对象(obj ): 各个节点中的〇1、〇2和〇3是节点所保存的成员对象。是一个指针,它指向一个字符串对象;字符串对象则保 存着一个SDS值。分值相同的节点将按照成员对象在字典序中的大小来进行排序。
在这里插入图片描述

2. zskiplist 节点

在这里插入图片描述

zskiplist
  • header: 指向跳跃表的表头节点
  • tail: 指向跳跃表的表尾节点
  • level: 层数最大的那个节点的层数
  • length: 记录跳跃表的长度(节点的数量)

特点:

  • 跳跃表是有序集合的底层实现之一
  • 每个跳跃表节点的层高都是1至32之间的随机数
  • 在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的
  • 跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小 进行排序

查找

在这里插入图片描述
查找时从顶层向下,不断缩小搜索范围。

以前面的图片为例,假设有这样一个跳表。查询节点值为7的过程如上所示。
首先从头结点header开始,并起始于顶层(这里是1)。
第1层:经由1,4,6。 由于6的下一个节点(这里说的下一个节点都是指右边一个,不是下一层)是8,我们要查找的是7,因此小于7的最大节点就是6,我们从此处往下到达下一层。
第0层:经由6,7。 从6往右就到了7了。找到了!!
整个查询的复杂度为O ( log ⁡ n ) O(\log n)O(logn)

插入

在这里插入图片描述
给定如上跳表,假设要插入节点2。
首先需要判断节点2是否已经存在,若存在则返回false。

否则,随机生成待插入节点的层数。

假设我们生成的层数是3。
在这里插入图片描述
在1和3之间插入节点2,层数是3,也就是节点2跳跃到了第3层。

删除

之前在研究二叉树的时候,发现所有的平衡的二叉树(也包括多叉树,如B树)删除算法都是最难的。
上文说了跳表的一个优点是实现简单,删除也不例外,也是异常的简单

该删除算法是根据查找算法实现的,并通过大量的测试(随机插入2000个数据,并根据插入顺序删除,没有抛出异常,因此应该是没问题的,如果发现删除实现有问题,请一定要告诉我)。
我看了网上其他O ( log ⁡ n ) O(\log n)O(logn)的删除算法实现基本都是基于双向链表的,但是双向链表需要多维护一个pre指针,或者额外需要一个updates列表来记录前驱节点,增加了复杂度。根据查找算法,理论上是可以在一次查找过程中找到它的前驱节点,并进行删除的。

删除可以说是插入的逆过程
在这里插入图片描述
上文中我们插入了节点2,如果想要删除它的话,就是将它的前驱节点指向它的后继节点(跳表需要对链表的操作比较熟悉,如果不太了解的话,建议先去搜一下)。

复杂度

空间复杂度

在这里插入图片描述

时间复杂度

上文说了,查找的时间复杂度为O(logn),根据上面的图解,也不难理解,其实插入和删除都是在一次查找过程中实现的。
插入和删除的复杂度也是O(logn)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值