1、键和值用什么结构组织?

  • 为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有的键值对,在哈希表的每一个桶中都保存了键值对数据。
  • 在这个哈希表的 entry 结构中,哈希桶中的元素保存的并不是值本身,而是指向具体值的指针,也就是说,不管 value 存放的数据类型是 string ,还是集合类型,哈希桶中的元素其实都只是指向它们的指针。

image.png

  • Redis 解决哈希冲突的方法是拉链法

2、Redis 全局哈希表的 rehash 操作

2.1、Redis 如何进行 rehash 操作?

  • 使用拉链法来解决哈希冲突时会存在一个问题,也就是随着哈希表中的数据的写入,某个桶上的链表可能会变得非常长,这样在这个桶上查找元素时需要逐一遍历这个桶上的元素,效率不高,所以 Redis 会对哈希表进行 rehash 操作,也就是增加现有哈希桶的数量。
  • 为了 rehash 操作更加高效,Redis 默认使用了两个全局哈希表:分别记为哈希表 1 和哈希表 2 ,一开始插入数据时,默认使用哈希表 1 ,此时哈希表 2 并没有被分配空间。随着数据增多,Redis 需要进行 rehash ,这个过程分为三步:
    • 为哈希表 2 分配更大的空间,比如说是当前哈希表 1 大小的两倍
    • 将哈希表 1 中的数据重新映射到哈希表 2 中
    • 释放哈希表 1 的空间

然后将哈希表 2 作为新的操作表,而释放完空间的哈希表 1 就作为下一次 rehash 时扩容的备用表。

2.2、渐进式 rehash

上面的过程看似简单,但是在 rehash 的第二步中涉及了大量的数据拷贝,如果一次性将哈希表 1 的数据全部迁移完,那么势必会造成 Redis 线程的阻塞,从而使其无法服务其他请求。

  • 为了避免这个问题, Redis 使用了渐进式 rehash

在第二步拷贝数据时, Redis 仍然正常处理客户端请求,没处理一个请求,都从哈希表 1 中的第一个索引位置开始,顺带着将这个桶上的所有 entry 拷贝到哈希表 2 中;处理下一个请求时,顺带拷贝哈希表 1 下一个桶的 entry

image.png

3、单线程的 Redis 为什么可以那么快?

3.1、说明

Redis 的单线程主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的。而 Redis 的其他功能,比如说持久化,异步删除,集群数据同步等都是由额外的线程来执行。

3.2、Redis 为什么使用单线程?

  • 如果没有良好的系统设计,那么使用多线程反而会使得系统的吞吐量降低。

  • 如果使用多线程,那么还需要考虑多线程共享资源的正确性,也就是为资源加锁,这会导致额外的开销

  • 线程上下文的切换也可能会导致性能下降

3.3、Redis 为什么那么快?

  • Redis 绝大部分操作都是在内存中完成的,同时 Redis 使用了高效的数据结构(例如哈希表和跳表),这是它高性能的一个重要原因。
  • Redis 采用了多路复用机制,使其在网络 IO 中能并发处理大量的客户端请求,实现高吞吐率。

4、AOF 重写会阻塞吗?

  • 与 AOF 日志由主线程写回不同,AOF 的重写过程是由子进程 bgrewriteaof 来完成的,这也是为了避免阻塞主线程,导致 Redis 性能下降

AOF 重写的过程可以总结为一个拷贝,两处日志

  • 一个拷贝指每次执行重写时,主线程会 fork 出一个后台的 bgrewriteaof 子进程,此时,fork 会把主线程的内存拷贝一份给 bgwriteaof 子进程,这里面包含了 Redis 的最新数据。然后子进程在不影响主线程的情况下,将拷贝的数据写成操作,记入重写日志
  • 第一处日志指正在使用的 AOF 日志,Redis 会将这个操作写到它的缓冲区。这样一来,即使 Redis 宕机,那么这个 AOF 日志中的内容也是齐全的,可以用于恢复数据。
  • 第二处日志指新的 AOF 重写日志。这个操作也会被写到重写日志的缓冲区。这样重写日志也不会丢失最新的操作。等到拷贝数据的所有操作记录重写完成后,重写日志记录的这些最新操作也会被写入到新的 AOF 文件,以保证 Redis 最新状态的记录。

最后,就可以使用新的 AOF 文件代替原来的旧文件了。

image.png

总的来说,每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于重写;然后使用两个日志保证重写过程中,新写入的数据不丢失。而且,因为 Redis 采用额外的线程进行数据重写,所以重写过程不会阻塞主线程。

5、Redis 哨兵

  • 在 Redis 中,如果主库挂了,那么我们就需要运行一个新主库,比如说将一个从库切换为一个主库,把它当成主库,这其中涉及到三个问题:

    • 主库真的挂了吗?
    • 该选择哪个从库作为主库?
    • 怎么把新主库的相关信息通知给从库和客户端?
  • Redis 的哨兵机制是实现主从库自动切换的关键机制,它有效地解决了主从复制模式下故障转移的这三个问题。

5.1、哨兵机制的基本流程

  • 哨兵其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行。哨兵主要负责三个任务,即监控选主通知
  1. 监控:哨兵在运行过程中,会周期性地给所有的主从库发送 PING 命令,检测它们是否存活

如果从库没有在限定时间内响应哨兵的 PING 命令,那么哨兵会将其标记为下线状态;同样,如果主库没有在规定时间内响应哨兵的 PING 命令,那么哨兵就会判断主库下线,然后开始自动切换主库的流程

  1. 选主:当主库下线后,哨兵需要从很多个从库中,按照一定的规则选择一个从库实例,把它作为新的主库,在这一步完成后,现有的主从集群里就有了新主库
  2. 通知:在新的主库出现后,哨兵会执行最后一个任务:通知。

在执行通知任务时,哨兵会把新主库的连接信息发送给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制

同时,哨兵会把新主库的连接信息发送给客户端,让它们将请求操作发到新主库上

image.png

  • 哨兵的通知任务相对来说比较简单,哨兵只需要将新主库的信息发送给从库和客户端,让它们与新主库建立连接就行,并不涉及决策的逻辑
  • 在监控和选主两个任务中,哨兵需要做出两个决策:
    • 在监控任务中,哨兵需要判断主库是否处于下线状态
    • 在选主任务中,哨兵需要决定哪个从库实例升级为主库

5.2、主观下线和客观下线

哨兵对主库的下线判断有主观下线客观下线两种。

  • 主观下线:

哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接状况,用来判断实例的状态。如果哨兵发现主库或从库对 PING 命令的响应超时了,那么就会先把它标记为主观下线

  1. 如果检测的是从库,那么哨兵简单地将其标记为主观下线就可以了,因为从库的下线一般影响不大,集群对外服务不会间断。
  2. 如果检测的是主库,那么哨兵还不能简单地将其标记为主观下线,开启主从切换,因为可能存在这么一种情况:那就是哨兵误判了,其实主库没有故障。而一旦启动了主从切换,那么后续的选主和通知操作都会带来额外的开销。

哨兵的误判一般会发生在集群网络压力大,网络拥塞等情况。那么应该如何减少误判的发生?

哨兵一般采用多实例组成的集群模式进行部署,这也被称为哨兵集群。引入多个哨兵实例一起来判断,就可以避免因为单个哨兵网络不好,从而误判主库下线的情况。

  • 客观下线:只有当哨兵集群中大多数哨兵实例都认为主库已经下线时,主库才会被标记为客观下线,如果走到这一步,那么证明主库的下线已经是客观事实了,此时需要进行主从切换。

image.png

当有 N 个哨兵实例时,最好要有 (N / 2) + 1 个实例判断主库为主观下线,才能最终判定主库为客观下线。

5.3、如何选定新主库?

一般来说,将哨兵选择新主库的过程称为筛选 + 打分。简单来说,我们从多个从库中,先按照一定的筛选条件,将不符合条件的从库去掉,然后再从剩下的从库中,按照一定规则打分,最终选择得分最高的从库作为新主库。

image.png

  • 筛选条件:
  1. 需要是在线的从库才有资格参与选主(选新太子应该从活的皇子里选吧?)
  2. 需要判断从库的网络连接状态,如果某个在线的从库的网络状态差,那么也不能让它进入打分阶段,这是为了避免将其选为新主后,其因为网络连接状态差而挂掉。(身体有残疾或者得了重病的皇子应该不选,避免下一代皇帝也是短命鬼)
  • 如何判断从库网络连接状况?

通过 Redis 的配置项 down-after-milliseconds ,其中 down-after-milliseconds 是我们认定主从库断连的最大超时时间。如果在 down-after-milliseconds 时间内,主从节点都没有通过网络连接上,那么我们就可以认为主从节点断连。如果断连次数超过十次,那么就说明这个从库的网络状况不好,不适合作为主库。

  • 打分工作:

接下来就是要给剩下的从库进行打分,我们分别按照三个规则进行三轮打分,这三个规则分别是从库优先级、从库复制进度和从库 ID ,只要在某一轮中,有从库得分最高,那么它就是新主库了,如果没有出现得分最高的从库,那么继续下一轮

  1. 优先级最高的从库得分高

用户可以通过 slave-priority 配置项,给不同的从库设置不同的优先级。

如果在众多从库中有一个从库优先级最高,那么直接将其指定为新主库,如果从库的优先级都一样,那么进入第二轮打分

  1. 和旧主库同步程度最接近的从库得分高

这个规则的依据是,如果选择和旧主库同步最接近的那个从库作为主库,那么,这个新主库上就有最新的数据。

这里使用主库记录的 master_repl_offset 和 从库的 slave_repl_offset ,从库的 slave_repl_offset 记录的就是复制进度,应该选择 slave_repl_offset 与主库的 master_repl_offset 最接近的从库作为新主库。

image.png

在上图中,我们应该选择从库 2 作为新主库,如果有多个从库的同步程度相同,那我们需要进行新一轮打分

  1. ID 小的从库得分高

每一个从库实例都会有一个 ID ,这个 ID 相当于从库的编号,Redis 在选择主库时有一个默认规定:在优先级和复制进度相同的前提下,选择 ID 较小的从库作为新主库

6、为什么主从库之间的复制不使用 AOF ?

  • RDB 文件是二进制文件,无论是要数据写入磁盘,还是网络传输,RDB 的 IO 效率都比 AOF 高
  • 在从库进行文件恢复时,RDB 的恢复效率要远远高于 AOF

7、在主从切换过程中,客户端能否正常地进行请求操作?

主从集群一般采用读写分离模式,当主库故障后,客户端仍然可以将读请求发送给从库,让从库服务,但是,对于写请求操作,客户端就无法执行了。

8、Redis 什么时候进行 rehash?

和 HashMap 的扩容一样, Redis 使用负载因子(load factor)来判断是否需要进行 rehash ,其中 load factor = 哈希表中所有的 entry 个数 / 哈希表中的桶数

  • load factor >= 1 时,哈希表允许被进行 rehash

当 load factor 大于 1 时,此时表示至少有一个桶上有两个元素,此时当有数据写入时,会对哈希表中查询较慢的桶进行 rehash

  • load factor >= 5

此时表示保存的数据量远大于哈希桶的个数,哈希桶中存在大量哈希冲突的情况,此时要立马开始做 rehash

  • 采用渐进式 rehash 时,即使实例暂时没有收到新请求,那么也会通过定时任务执行渐进式的 rehash 操作。

9、Redis 缓存的淘汰策略

Redis 一共有 8 种缓存淘汰策略

  • Redis 4.0 之前一共实现了 6 种缓存淘汰策略
    • noeviction:不进行数据淘汰
    • volatile-lru:使用 LRU (最近最少使用)算法对存在过期时间的缓存进行淘汰
    • volatile-ttl:淘汰那些即将过期的缓存
    • volatile-random:随机淘汰那些即将过期的缓存
    • allkeys-random:在所有缓存中随机淘汰那些即将过期的缓存
    • allkeys-lru:在所有缓存中使用 LRU 算法进行淘汰
  • Redis 4.0 后增加了两种缓存淘汰策略
    • volatile-lfu:使用 LFU 算法对存在过期时间的缓存进行淘汰
    • allkeys-lfu:使用 LFU 算法对所有缓存进行淘汰

image.png

10、Redis 的两种原子操作方法

  • 将多个操作在 Redis 中实现成一个操作,也就是单命令操作。

  • 将多个操作写到一个 LUA 脚本中,以原子性的方式执行单个命令

当我们要执行的操作不是简单的增减值,而是有更复杂的判断逻辑或者是其他操作,那么,Redis 的单命令操作已经无法保证多个操作的互斥执行了,此时我们需要使用 LUA 脚本。

Redis 会将整个 LUA 脚本作为一个整体执行,在执行过程中不会被其他命令打断,从而保证了 LUA 脚本中操作的原子性

10.1、使用 Redis + LUA 脚本实现限制某个客户端在一定时间内的访问次数

  • 我们可以将客户端 IP 作为 Key,将客户端的访问次数作为 value ,保存在 Redis 中,客户端每访问一次,就使用 INCR 命令增加访问次数。

由于在这个操作中包含了判断逻辑,我们需要保证这段逻辑的原子性,此时使用 Redis 自带的单命令不能保证原子性,此时我们可以使用 LUA 脚本完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 获取 ip 对应的访问次数
currentIP = GET(ip)
// 如果访问次数超过 20 次,那么直接报错
IF currentIP != NULL AND currentIP > 20 THEN
ERROR "exceed 20 access per minute"
ELSE
// 如果访问次数不足 20 次,那么增加一次访问次数
value = INCR(ip)
// 如果是第一次访问,则将键值对的过期时间设置为 60 s
IF (value == 1) THEN
EXPIRE(ip, 60)
ELSE
// 执行操作
END
  • 在 Redis 中,我们可以使用 Redis 的 EVAL 命令来执行 LUA 脚本

11、Redis 6 新增的数据类型 – BitMaps

11.1、简介

现代计算机使用二进制(位)作为信息的基础单位,一个字节等于 8 位,即(1 byte = 8 bit),例如 “abc” 字符串由三个字节组成,但实际在计算机存储时将其使用二进制表示,”abc” 分别对应的 ASCII 码为 97 98 99 ,对应的二进制位为 011000010110001001100011 ,如下图

image.png

合理使用操作位可以有效地提高内存利用率和开发效率,Redis 提供了 Bitmaps 这个数据类型,它可以实现对位的操作:

  • Bitmaps 本身不是一种数据类型,实际上它就是字符串(Key-Value),但它可以对字符串的位进行操作。
  • Bitmaps 单独提供了一套命令,所以在 Redis 中使用 Bitmaps 和使用字符串的方法不太相同。可以将 Bitmaps 想象成一个以位为单位的数组,数组中的每个单元只能存储 0 和 1 ,数组的下标在 Bitmaps 中叫做偏移量。

image.png

11.2、setbit 命令

  • 作用:设置 bitmaps 中某个偏移量的值(只能设置为 0 或 1)

  • 格式:

1
set <key> <offset> <value>

offset (偏移量)从 0 开始

image.png

  • 实例:判断用户是否访问过网站

我们将用户 id 作为数组偏移量,如果用户在该天访问过用户,那么将数组对应偏移量的值设置为 1 ,否则设置为 0 ,假设现在有 20 个用户,其中用户 id 为 1 , 6 , 11 , 15 , 19 的用户对网站进行了访问,那么当前 Bitmaps 初始化结果为:

image.png

其中键 unique:users:20201106 代表在 2020-11-06 这一天用户访问记录的 Bitmaps

image.png

  1. 很多应用的用户 id 都会以一个指定数字开头,如果直接将用户 id 和 Bitmaps 的偏移量对应势必会造成一定的浪费,通常的做法是每次做 setbit 操作时将用户 id 减去这个数字。
  2. 在第一次初始化 Bitmaps 时,如果偏移量非常大,那么整个初始化过程执行会比较慢,可能会造成 Redis 的阻塞。

11.3、getbit 命令

  • 作用:用于获取 Bitmaps 中某个偏移量的值
  • 格式:
1
getbit <key> <offset>

获取键对应 bitmaps 的第 offset 位的值(offset 从 0 开始)

  • 实例:判断用户 id 为 8 的用户在 2020-11-06 这一天是否访问过网站
1
getbit unique:users:20201106 8

image.png

结果为 0 ,证明 id 为 8 的用户在 2020-11-06 这一天没有访问过网站

11.4、bitcount 命令

  • 作用:这个命令用于统计位图中被设置为 1 的 bit 数

一般情况下,给定的整个位图都会被进行计数,通过指定额外的 startend 参数,可以让计数只在特定的位上进行

startend 参数都可以使用负数值:比如说 -1 表示最后一个位,而 -2 表示倒数第二个位,**startend 是指位图中 offset 的下标数,二者皆包含,即 [start , end ]**

  • 格式:
1
bitcount <key> [start end]

image.png

  • 实例:统计 2020-11-06 这天访问网站的用户数
1
bitcount unique:users:20201106

image.png

11.5、bitop 命令

  • 作用:bitop 是一个符合操作,它可以做多个 Bitmaps 的 and(交集)、or(并集)、not(非)、xor(异或)操作并将结果保存到 destkey 中

  • 格式

1
bitop and(or/not/xor) <destkey> [key...]

image.png

  • 实例:统计 2020-11-06 和 2020-11-07 两天都访问过网站的用户
  1. 假设 2020-11-06 访问网站的 userid = 1,2,5,9
1
2
3
4
setbit unique:users:2020-11-06 1 1
setbit unique:users:2020-11-06 2 1
setbit unique:users:2020-11-06 5 1
setbit unique:users:2020-11-06 9 1
  1. 假设 2020-11-07 访问网站的 userid = 0,1,4,9
1
2
3
4
setbit unique:users:2020-11-07 0 1
setbit unique:users:2020-11-07 1 1
setbit unique:users:2020-11-07 4 1
setbit unique:users:2020-11-07 9 1
  1. 我们使用 bitop 命令将两天都访问过网站的用户计算出来,并保存到 key 为 unique:users:2020110620201107
1
bitop and unique:users:2020110620201107 unique:users:2020-11-06 unique:users:2020-11-07

使用 bitcount 统计 key 为 unique:users:2020110620201107 的位图的用户数

1
bitcount unique:users:2020110620201107

image.png

示意图如下

image.png

11.6、Bitmaps 与 Set 对比

假设网站有一亿用户,每天独立访问的用户有 5 千万,如果每天用集合类型和 Bitmaps 分别存储活跃用户可以得到表如下

image.png

很明显,这种情况下使用 Bitmaps 可以节省非常多空间,尤其是随着时间推移节省的内存十分可观

image.png

但 Bitmaps 不是万金油,假设网站每天访问的独立用户非常少,例如只有 10 万,那么二者的对比如下表所示

image.png

这个时候使用 Bitmaps 就不太合适了,因为此时基本上大部分位都是 0

12、Redis Cluster 集群

12.1、特点

  • 多主多从,去中心化,从节点不提供服务,而只是单纯作为主节点的备用节点
  • 不支持处理多个 Key:因为数据分散在多个节点,在数据量高并发情况下会影响性能。
  • 支持动态扩展节点
  • 节点之间相互通信,不再依赖哨兵,保证及时故障转移
  • Redis 集群不像单机 Redis 那样支持多数据库功能, 集群只使用默认的 0 号数据库

12.2、Redis Cluster 集群的键分布模型

  • Redis 集群的键空间被分割为 16384(16 * 1024) 个槽(slot),集群的最大节点数量也是 16384 个,每个键值对会根据它的 key ,然后被映射到一个哈希槽中。
  • Redis 会自动把这个槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么每个实例上的槽个数为 16384 / N 个槽

映射过程分为两大步:

  • 首先根据键值对的 key ,按照 CRC16 算法计算一个 16 bit 的值
  • 再用这个 16 bit 的值对 16384 进行取模,得到一个 0 - 16383 之间的值

HASH_SLOT = CRC16(key) mod 16384

12.3、Redis Cluster 是如何保证高可用的?

Redis Cluster 保证高可用主要是依靠:故障检测故障转移两种策略

  1. 故障转移

我们为 Redis Cluster 的实例添加从节点,从节点不提供服务,只复制主节点,当集群中的实例下线时,就可以在下线实例的从节点中选举一个新的主节点。

比如说现在实例中有 4 个实例,其中实例一存在两个从节点

image.png

ip1:6379 下线时,我们就从它的从节点中选取出一个新的主节点,代替原来下线的实例

image.png

ip1:6379 重新上线时,它会作为 ip5:6379 的新的从节点

image.png

  1. 故障检测
  • Redis Cluster 采用一种相互监督的方式来检测集群中的实例是否健康,每个实例都会定期地向集群中的其他节点发送 PING 消息,以检测对方是否在线;

如果接收 Ping 消息的节点没有在规定的时间内回传 PONG 消息,那么发送 PING 消息的节点就会将该节点标记为疑似下线(PFAIL)

image.png

  • 如果在集群中,超过半数以上负责处理槽的主节点都将某个节点 X 标记为疑似下线,那么某个主节点就会将 X 标记为已下线,然后广播这条消息

这样其他所有主节点都会将 X 标记为已下线,此时需要进行故障转移。

13、Redis 有序集合 Zset (Sorted Set)

13.1、简介

  • 有序集合 Zset 与普通集合 set 非常相似,它是一个没有重复元素的字符串集合

不同之处是有序集合的每个成员都关联了一个评分(score),这个评分被用于从最低分到最高分的方式排序集合中的成员。

  • 集合中的成员是唯一的,但是评分可以是重复的。

  • 因为集合是有序的,所以我们可以很快地根据评分或者次序来获取一个范围内的元素

访问有序集合的中间元素也是非常快的,因此我们可以使用有序集合作为一个 没有重复成员的只能列表。

13.2、zadd 命令

  • 作用:将一个或者多个 member 元素及其 score 加入到有序集合 key 中

  • 格式:

1
zadd <key> <score1> <value1> <score2> <value2>

image.png

  • 实例:
1
zadd topN 200 java 300 CPP 400 C

image.png

13.3、zrange 命令

  • 作用:返回有序集合 key 中,下标在 [start, stop] 之间的元素,如果带上 WITHSCORES ,那么可以让分数一起和值返回到结果值。

  • 格式:

1
zrange <key> <start> <stop> [WITHSCORES]
  • 实例:
  1. 取出有序集合中的所有内容
1
zrange topN 0 -1

image.png

将值与分数一同返回到结果集中

1
zrange topN 0 -1 WITHSCORES

image.png

  1. 返回有序集合中下标在 [0,1] 之间的元素,连同分数一起返回
1
zrange topN 0 1 WITHSCORES

image.png

13.4、zrangebyscore 命令

  • 作用:返回有序集合 key 中,所有 score 值处于 [min , max] 之间的成员,按照 score 从小到大排序
  • 格式:
1
zrangebyscore key min max [withscores] [limit offset count]
  • 实例:返回有序集合 topN 中 score 处于 [300, 500] 之间的成员,连带他们的 score 一起返回
1
zrangebyscore topN 300 500 WITHSCORES

image.png

13.5、zrevrangebyscore 命令

  • 作用:返回有序集合 key 中,所有 score 值处于 [min , max] 之间的成员,按照 score 从大到小排序
  • 格式:
1
zrevrangebyscore key max min [withscores] [limit offset count]
  • 实例:
  1. 逆序排序所有成员
1
zrevrangebyscore topN +inf -inf 

image.png

  1. 逆序排序所有成员并取前 2 条
1
zrevrangebyscore topN +inf -inf limit 0 2

image.png

13.6、zincrby 命令

  • 作用:为元素的 score 值加上增量
  • 格式:
1
zincrby <key> <increment> <value>

13.7、zrem 命令

  • 作用:删除该集合下指定元素的集合
  • 格式:
1
zrem <key> <value>

13.8、zcount 命令

  • 作用:统计该集合中,分数处于 [min, max] 区间的元素个数
  • 格式:
1
zcount <key> <min> <max>

13.9、zrank 命令

  • 作用:返回该值在集合中的排名,从 0 开始
  • 格式:
1
zrank <key> <value>

14、Zset 的底层实现

14.1、概述

ZSet 底层的存储结构包括压缩列表 ziplist 和**跳表 skiplist**。

  • Redis 如何决定使用 ziplist 还是 skiplist?

在 Redis 配置文件中存在两个属性,分别是 zset-max-ziplist-entries 128zset-max-ziplist-value 64(字节)当集合内元素个数小于 128 个且集合内保存的所有元素的长度小于 64 字节时使用 ziplist,否则使用 skiplist

image.png

从上面的源码可以看到,只要两个条件(集合内元素个数 大于 128 或集合内元素长度有一个大于 64 字节)有一个满足时,就会执行 zsetConvert 方法,这个方法会将底层数据结构由 ziplist 变为 skiplist

  • 集合内存储的元素分值相同时,Redis 会如何进行处理

所有节点的分值是按从小到大的方式进行排序的,当有序集合中成员分值相同时,节点会按照成员的字典序进行排序。

14.2、有序集合的结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 有序集合结构体
*/
typedef struct zset {
/*
* Redis 会将跳跃表中所有的元素和分值组成
* key-value 的形式保存在字典中
* todo:注意:该字典并不是 Redis DB 中的字典,只属于有序集合
*/
dict *dict;
/*
* 底层指向的跳跃表的指针
*/
zskiplist *zsl;
} zset;
  • 当使用 ziplist 作为底层存储结构时,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值。
  • 当使用 skiplist 作为底层存储结构时,使用 skiplist 保存元素及分值,使用一个字典来保存元素和分值的映射关系。

跳跃表的查找复杂度为平均 O(logN),最坏 O(N),效率堪比红黑树,却远比红黑树实现简单。跳跃表是在链表的基础上,通过增加索引来提高查找效率的。

字典和跳表会通过指针来共享相同元素的成员和分值,所以不会产生重复的成员和分支,也就不会造成空间浪费。

14.3、为什么有序集合需要引入字典?

其实有序集合单独使用字典或者跳跃表其中一种元素都可以实现,但是 Redis 将两种数据结构组合起来实现 Zset 。

  • 如果单独使用字典,那么我们虽然可以以 O(1) 的时间复杂度查找成员的分值,但是由于字典是以无需的方式来保存集合元素的,所以每次进行范围操作时我们都需要对无序的数据进行排序。
  • 如果单独使用跳跃表来实现,那么我们虽然可以很方便地进行范围操作,但是查找操作的时间复杂度变为了 O(logN),所以 Redis 使用了两个数据结构来共同实现有序集合。

14.4、为什么跳表不使用树形结构?

  • Zset 有一个核心操作:范围查找,而跳表效率比红黑树高,跳表可以做到在 O(logN) 时间复杂度内快速查找,我们只需要找到范围数据的起点,然后向后遍历即可。
  • 跳表的实现比红黑树简单易懂,且没有红黑树的 rebalance 操作