深度解密 Redis 的有序集合(ZSet)

楔子

下面来解密 Redis 的有序集合,整篇文章分为三个部分。

  • Redis 有序集合的相关命令;
  • Redis 有序集合的应用场景;
  • Redis 有序集合的实现原理;

Redis 有序集合的相关命令

Redis 的有序集合相比集合多了一个排序属性:score(分值),对于有序集合 ZSet 来说,每个存储元素相当于有两个值,一个是有序集合的元素值(element),一个是分值(score)、也可以理解为权重。有序集合存储的元素值也是不重复的,但分数可以重复。

当我们把学生的成绩存储在有序集合中,它的存储结构如下图所示:

下面我们来看看它所支持的 API 操作。


zadd key score1 ele1 score2 ele2:设置 score 和 element

127.0.0.1:6379> zadd zset1 1 n1 2 n2 3 n2
(integer) 2
127.0.0.1:6379> 

一个 score 对应一个 element,element 不会重复。因此即便我们这里添加了 3 个,但是后面两个的 element 都是 n2,所以实际上只有两个元素,并且 n2 以最后一个 score 为准,因为相当于覆盖了。


zscore key ele:获取 element 对应的 score

127.0.0.1:6379> zscore zset1 n2
"3"
127.0.0.1:6379>

zrange key start end:获取指定范围的 element,递增排列,这里是基于索引获取

127.0.0.1:6379> zadd zset2 1 n1 3 n3 2 n2 4 n4
(integer) 4
127.0.0.1:6379> zrange zset2 0 -1
1) "n1"
2) "n2"
3) "n3"
4) "n4"
127.0.0.1:6379> zrange zset2 0 2
1) "n1"
2) "n2"
3) "n3"
127.0.0.1:6379> 

如果结尾加上 with scores 参数,那么会和 score 一同返回,注意:score 是在下面。我们看到这个 zset 有点像 hash 啊,element 是 hash 的 k,score 是 hash 的 v。

127.0.0.1:6379> zrange zset2 0 2 withscores
1) "n1"
2) "1"
3) "n2"
4) "2"
5) "n3"
6) "3"
127.0.0.1:6379> 

zrevrange key start end:获取所有的 element,递减排列,同理也有 withscores 参数

127.0.0.1:6379> zrevrange zset2 0 -1
1) "n4"
2) "n3"
3) "n2"
4) "n1"
127.0.0.1:6379> 

zrangebyscore key 开始score 结束score:获取大于等于开始score、小于等于结束score 的 element,递增排列,同理也有 withscores 参数

zrevrangebyscore key 结束score 开始score:功能和上面相同,但是递减排列,另外注意这里的开始score 和结束score 是相反的

127.0.0.1:6379> zadd zset3 1 n1 2 n2 3 n3 4 n4 5 n5 6 n6 7 n7
(integer) 7
127.0.0.1:6379> zrangebyscore zset3 3 6
1) "n3"
2) "n4"
3) "n5"
4) "n6"
127.0.0.1:6379> zrevrangebyscore zset3 6 3
1) "n6"
2) "n5"
3) "n4"
4) "n3"
# 如果在分数前面加上了 (, 那么会不匹配边界
# 同理也支持 withscores
127.0.0.1:6379> zrangebyscore zset3 (3 (6
1) "n4"  
2) "n5"
127.0.0.1:6379> zrevrangebyscore zset3 (6 (3
1) "n5"
2) "n4"
127.0.0.1:6379> 

zrem key ele1 ele2···:移除对应的 element

127.0.0.1:6379> zrem zset3 n1 n2 n3 n4
(integer) 4
127.0.0.1:6379> zrange zset3 0 -1
1) "n5"
2) "n6"
3) "n7"
127.0.0.1:6379> 

zcard key:获取有序集合的元素个数

127.0.0.1:6379> zcard zset3
(integer) 3
127.0.0.1:6379> 

zcount key 开始分数区间 结束分数区间:获取集合指定分数区间内的元素个数

127.0.0.1:6379> zcount zset3 6 8
(integer) 2
127.0.0.1:6379> zcount zset3 5 7
(integer) 3
127.0.0.1:6379>

Redis 有序集合的使用场景

有序集合的经典使用场景如下:

  • 学生成绩排名;
  • 粉丝列表,根据关注的先后时间排序;

Redis 有序集合的实现原理

根据元素个数的不同,ZSet 采用了两种不同的结构实现。

  • 压缩列表(ziplist);
  • 哈希表 + 跳表;

当有序集合保存的元素个数小于等于 128 个,并且每个元素都不超过 64 字节时,会采用 ziplist 存储;否则使用哈希表 + 跳表存储;

>>> import redis
>>> client = redis.Redis(host="...", decode_responses="utf-8")
>>> # 写入一个元素
>>> client.zadd("my_zset1", {"a" * 64: 1})
1
>>> # 底层数据结构使用 ziplist
>>> client.object("encoding", "my_zset1")
'ziplist'
>>> # 还是写入一个元素,但是元素长度大于 64
>>> client.zadd("my_zset2", {"a" * 65: 1})
1
>>> # 底层数据结构变成了跳表
>>> client.object("encoding", "my_zset2")
'skiplist'
>>> # 元素个数超过 128 个,也会使用跳表
>>> client.zadd("my_zset3", dict(zip(range(129), range(129))))
129
>>> client.object("encoding", "my_zset3")
'skiplist'
>>> 

所以当有序集合保存的元素的长度大于 64 字节、或者元素个数超过128个时,有序集合的底层结构就会从 ziplist 转换成 skiplist。另外在高版本的 Redis 中,ziplist 被替换成了 listpack,但当前的 6.2 版本使用的还是 ziplist。

可以通过配置文件中的 zset-max-ziplist-entries(默认 128)和 zset-max-ziplist-value(默认 64)来设置有序集合使用 ziplist 存储的临界值。

注意:虽然查看结构时显示的是 skiplist,但它除了使用跳表之外,还使用了哈希表。所以有序集合比较特殊,它是唯一同时使用两种数据结构的类型。这样的好处是既能进行高效的范围查询,也能进行高效的单点查询。

  • zrangebyscore key 开始score 结束score:按照元素的分值(或者说权重)返回一个范围内的元素,时间复杂度为 O(logN)+M;
  • zscore key ele:基于元素获取对应的分值,时间复杂度为 O(1);

我们看一下它的底层结构:

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

通过这个结构体可以看出,ZSet 之所以能支持范围查询,是因为它的底层数据结构采用了跳表;而它又能以常数复杂度获取元素的分值,是因为底层数据结构同时采用了哈希表。

由于压缩列表和哈希表前面已经说过了,所以接下来重点看一下跳表的实现原理。

跳表的结构设计

我们知道链表在查找元素的时候,因为需要逐一查找,所以查询效率很低,时间复杂度是 O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层级」的有序链表,这样的好处是能快速定位数据。

我们用一张图,展示一下跳表的结构。

当我们在跳跃表中查询值 62 时,执行流程如下:

  • 从最上层开始找,由于 1 比 62 小,那么在当前层移动到下一个节点进行比较;
  • 比较时发现 27 仍比 62 小,那么在当前层继续向后移动一个节点,也就是 100,继续比较;
  • 比较时发现 100 大于 62,所以要以 27 为目标,移动到下一层(图中的第二层)继续向后比较;
  • 50 小于 62,继续向后移动查找,对比 100 大于 62,因此以 50 为目标,移动到下一层(图中的最后一层)继续向后比较;
  • 对比 62 等于 62,值被顺利找到;

从上面的流程可以看出,跳表会先从最上层开始找起,依次向后查找。如果本层的节点大于要找的值,或者本层的节点为 NULL 时,则以该节点的上一个节点为目标,往下移一层继续向后查找并循环此流程,直到找到满足条件的节点并返回。如果对比到最后一层仍未找到,则返回 NULL。

所以如果回顾整个过程,我们会发现跳表有点类似于二分查找,而跳表在查找元素时的时间复杂度也是 O(logN)。实际上 ZSet 还可以使用 AVL 树或红黑树实现,但由于性能相近,并且跳表更加的好实现,于是 Redis 选择了跳表。

跳表是多层级的有序链表,比如上面图中的跳表总共有 3 层,但其实图中画的还不够完全。因为当前层找不到的时候,会跳到下一层去找,但问题是怎么跳到下一层呢?所以每一层的有序链表之间应该还需要一个指针建立连接,但连接是单向的,我们只需要从上一层指向下一层即可。我们将上面的图补充完整:

接下来我们就来看看 Redis 的跳表在底层是怎么定义的,先来看跳表节点,因为跳表也是由一个个的节点组成的,只不过这些节点分散在多层级的链表上,并且部分节点之间可以在不同层级之间跳跃,因此叫跳表。

补充一点:我们上图画的跳表结构和 Redis 实际实现的跳表还是有点区别的,后面会说明,但总之跳表的核心思想就是图中展示的那样。

typedef struct zskiplistNode {
    // ZSet 对象的元素值,是一个 SDS 字符串
    sds ele;
    // 元素的分值,
    double score;
    // 比如我们执行如下命令,就会产生 3 个节点
    // zadd test_zset 1 satori 2 koishi 3 sakuya
    // 第一个节点的 ele 就是 "satori",score 就是 1
    // 第二个节点的 ele 就是 "koishi",score 就是 2
    // 第三个节点的 ele 就是 "sakuya",score 就是 3   
    
    // 指针,指向前一个节点
    struct zskiplistNode *backward;
    // 节点的 level 数组,保存每一层的指针和跨度
    struct zskiplistLevel {
        // 指针,指向后一个节点
        struct zskiplistNode *forward;
        // 跨度
        unsigned long span;
    } level[];
} zskiplistNode;

ZSet 对象要同时保存元素和元素的分值,对应到跳表节点结构里就是 SDS 类型的 ele 成员和 double 类型的 score 成员。每个跳表节点都有一个指针,指向前一个节点,目的是为了方便从跳表的尾节点开始反向访问,这样倒序查找时很方便。

另外跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的 zskiplistLevel 类型的数组 level。level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层。所以查找的时候虽然是从上往下,但是计算层数的时候其实是从下往上计算的,也就是最下层才是第一层。

可以想象一下走楼梯,越往上层数越高。然后 zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度是用来记录两个节点之间的距离。

光说的话可能不好理解,我们画一张图就清晰了,并且接下来画的才是 Redis 中的跳表节点对应的结构。

这里我们再单独解释一下跨度,它存在的意义就是计算节点在跳表中的排位,那么具体是怎么计算的呢?首先跳表中的节点都是按照顺序排列的,那么计算某个节点排位的时候,把从头结点到该节点的查询路径中沿途访问过的所有层的跨度加起来,得到的结果就是该节点在跳表中的排位。

比如查找 score 为 27 的节点在跳表中的排位,我们会从 1 开始查找,经过一个层即可找到 27,并且层的跨度是 3,所以节点 27 在跳表中的排位就是 3。

上面显示的就是跳表中的每一个节点,下面再来看看跳表在底层的定义:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

跳表结构体里包含了:

  • 跳表的头尾节点,便于在 O(1) 时间复杂度内访问跳表的头节点和尾节点;
  • 跳表的长度,便于在 O(1) 时间复杂度获取跳表的节点数量;
  • 跳表的最大层数,也就是所有节点的 span 的最大值,便于以 O(1) 时间复杂度获取跳表中层数最高的节点的层数量;

跳表的节点查询

查找一个跳表节点时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的分值来进行判断,共有两个判断条件:

  • 如果当前节点的分值「小于」要查找的分值时,跳表就会访问该层的下一个节点;
  • 如果当前节点的分值「等于」要查找的分值时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表会继续访问该层的下一个节点;

如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,相当于跳到了下一层接着查找。这里第一个条件很好理解,但第二个条件估计会有人困惑,我们解释一下。

首先对于 ZSet 而言,它节点内部的元素是不可以重复的,比如有一个内部元素(ele)叫 "xxx" 的节点,那么跳表中就不可能有第二个也叫 "xxx" 的节点。不过虽然元素不可以重复,但是分值(权重)可以重复,也就是不同的元素可以具有相同的 score 值。在跳表中的节点会按照内部 score 值从小到大的顺序进行组织,如果 score 相同,那么会按照内部元素(ele)的字典序进行组织。

我们举个例子:

ZADD zset_test 1 n1 5 n2 11 n3 20 n4 27 n5 33 n6 50 n7 62 n8 100 n9
(integer) 9

我们按照 n1、n2、n3 ··· 的顺序和每个权重进行了组合,接下来我们进行查找:

# ZSCORE key ele:获取元素对应的 score
# 这个和跳表没多大关系,单值查询是通过哈希表实现的
127.0.0.1:6379> ZSCORE zset_test n2
"5"
# ZRANGE key start end [WITHSCORES]:获取指定范围的元素
# 并且递增排列,这个就相当于遍历跳表的第一层
# 加上 WITHSCORES 会同时返回 score
# 同理还有 ZREVRANGE,输出结果递减排列
127.0.0.1:6379> ZRANGE zset_test 0 2
1) "n1"
2) "n2"
3) "n3"
# ZRANGEBYSCORE key 开始score 结束score
# 获取 >=开始score 并 <=结束score 的元素,递增排列
# 也可以加上 WITHSCORES 同时返回 score
# 也有 ZREVRANGEBYSCORE,输出结果递减排列
127.0.0.1:6379> ZRANGEBYSCORE zset_test 5 60
1) "n2"
2) "n3"
3) "n4"
4) "n5"
5) "n6"
6) "n7"
127.0.0.1:6379> 

显然 ZRANGEBYSCORE 用到了跳表的结构,那么它是怎么做的呢?首先从最高层的 1 开始查找,发现 1 小于 5 ,于是移动到下一个节点,但发现 27 大于 5,于是回到 score 为 1 的节点中的下一层,也就是第 2 层。

然而第 2 层的下一个节点为 11,也大于 5,于是回到 score 为 1 的节点中再下一层,也就是第 1 层。此时发现下一个节点的 score 为 5,正好匹配,所以左边界对应的节点就找到了。如果没有 score 等于 5 的节点,那么就去找第一个 score 大于 5 的节点作为边界。

左边界找到了之后是不是该去找右边界了呢?答案是不需要,只需要从左边界对应的节点的第一层开始不断向后遍历,当出现第一个 score 大于右边界的节点时停止遍历即可。因为跳表不像数组,即使你找到了右边界,也依旧需要从左边界开始遍历一遍。

跳表的节点层数

实际使用跳表的时候需要维护相邻两层的节点数量,让它们保持一个合理的比例关系,因为跳表相邻两层的节点数量的比例会影响跳表的查询性能。

注意:这里我们说相邻两层的节点其实不太严谨,因为给人一种感觉,好像是不同层的节点之间彼此独立一样。但我们知道,在 Redis 的跳表中多层共用一个节点,每个节点内部通过数组维护多个指针来实现多层级。当然了,这里我们为了方便描述,就使用这种不严谨的说法了,心中理解就好。

比如第二层的节点只有 1 个,但第一层有 100 个,这显然不太合理,因为此时就类似于链表了。最终需要在第一层的节点中依次顺序查找,复杂度就变成了 O(N)。因此为了避免跳表退化成链表,我们需要维持相邻层之间的节点数量关系。

跳表的相邻两层的节点数量最理想的比例是 2: 1,这样查找复杂度可以降低到 O(logN)。不过话虽如此,即使将相邻两层的节点数量维持在了 2:1,但还是有一点需要注意,我们举个栗子:

这里相邻两层的节点数量大概也在 2:1,但很明显它起不到任何加速查询的效果,或者说这种结构不具备任何跳表的性质,查询速度甚至还不如链表。

因此跳表的层数越高,节点不仅越少,节点之间还要越稀疏,应该像二分查找一样,每次都能过滤掉一部分元素。如果相邻两层的节点数量控制在 2:1,并且跳表的最高层只有 3 个节点(首、尾、中间),那么此时就可以认为其等价于二分查找。

那怎样才能维持相邻两层的节点数量的比例为 2: 1 呢?

如果采用新增节点或者删除节点的方式,来调整不同层级比例的话,会带来额外的开销。所以 Redis 采用了一种巧妙的方法,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2: 1 的情况。

具体的做法是,跳表在创建节点时候,会生成一个范围在 0~1 的随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层;然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。

小结

1)ZSet 在数据不超过 128、并且元素长度不超过 64 时,采用 ziplist 存储,每个元素紧凑排列,节省内存;否则转为 hashtable + skiplist 存储,降低查询的时间复杂度;

2)hashtable 存储 element -> score 的映射关系,所以 ZSCORE 的时间复杂度为 O(1);

3)skiplist 是一个「有序链表 + 多层索引」的结构,把查询元素的复杂度降到了 O(logN),服务于 ZRANGE, ZREVRANGE 这类命令;

4)skiplist 的多层索引,采用「随机」的方式来构建,也就是说每次添加一个元素进来,要不要对这个元素建立「多层索引」、建立「几层索引」,都要通过「随机数」的方式来决定;

具体做法是每次随机一个 0~1 之间的数,如果这个数小于 0.25(25% 概率),那就给这个元素加一层指针,持续随机直到大于 0.25 结束,最终确定这个元素的层数(层数越高,概率越低,且限制最多 64 层,详见 t_zset.c 的 zslRandomLevel 函数);

这个预设「概率」决定了一个跳表的内存占用和查询复杂度:概率设置越低,层数越少,元素指针越少,内存占用也就越少,但查询复杂会变高,反之亦然。这也是 skiplist 的一大特点,可通过控制概率,进而控制内存和查询效率;

5)skiplist 新插入一个节点,只需修改这一层前后节点的指针,不影响其它节点的层数,降低了操作复杂度(相比平衡二叉树的再平衡,skiplist 插入性能更优);

6)关于 Redis 的 ZSet 为什么用 skiplist 而不用平衡二叉树实现,原因如下:

  • skiplist 更省内存:25% 概率的随机层数,可通过公式计算出 skiplist 平均每个节点的指针数是 1.33 个,平衡二叉树每个节点指针是 2 个(左右子树);
  • skiplist 遍历更友好:skiplist 找到大于目标元素后,向后遍历链表即可,平衡树需要通过中序遍历方式来完成,实现也略复杂;
  • skiplist 更易实现和维护:扩展 skiplist 只需要改少量代码即可完成,平衡树维护起来较复杂;

其实最大的原因还是跳表实现简单,在性能差不多的情况下肯定选择跳表。


 

本文参考自:

  • 极客时间蒋德钧:《Redis 源码剖析与实战》

  • 小林 coding:《图解 Redis》

  • 课代表 kaito 在极客时间《Redis 源码剖析与实战》评论区的精彩回答

posted @ 2024-03-17 23:49  万明珠  阅读(1360)  评论(0编辑  收藏  举报