Redis八股

1.Redis 常见数据类型和应用场景

String(字符串)

String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value其实不仅是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M

内部实现

String 类型的底层数据结构实现主要是 int 和 SDS(简单动态字符串)。

如果字符串对象保存的是整数值,会将整数值保存在字符串对象结构的ptr属性里面。

如果字符串对象保存的是字符串,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为embstrraw

使用场景

  1. 缓存对象:直接缓存整个对象的JSON(一般对象用这种方式)、采用不同的key将对象属性进行分离

  2. 常规计数:计算访问次数、点赞、转发、库存数量等等

  3. 分布式锁

  4. 共享 Session 信息:使用同一个 Redis 存储分布式系统下的 Session 信息

List(列表)

List 列表是简单的字符串列表,按照「插入顺序」排序,可以从头部或尾部向 List 列表添加元素。

内部实现

List 类型的底层数据结构是由双向链表或压缩列表实现的:

  1. 如果列表中的元素个数比较少时,Redis 会采用「压缩列表」作为 List 类型的底层数据结构;
  2. 如果列表中的元素个数比较多时,Redis 会采用「双向链表」作为 List 类型的底层数据结构。

在 Redis 3.2 版本之后,List 类型的底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表

应用场景

消息队列:

消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息的可靠性

  1. 如何满足消息保序需求?

List 本身就是按先进先出的顺序对数据进行存取的,所以,如果使用 List 作为消息队列保存消息的话,就已经能满足消息保序的需求了。

List 可以使用 LPUSH + RPOP (或者反过来,RPUSH+LPOP)命令实现消息队列。

img

Redis提供了「BRPOP」命令。BRPOP命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序自己不停地调用RPOP命令相比,这种方式能节省CPU开销。

img
  1. 如何处理重复的消息?

消费者要实现重复消息的判断,需要满足 2 个方面的要求:

  • 每个消息都有一个全局的 ID。
  • 消费者要记录已经处理过的消息 ID。当收到一条消息后,消费者程序就可以对比收到的消息 ID 和已处理过的消息 ID,来判断当前收到的消息有没有经过处理。如果已经处理过了,那么消费者程序就不再进行处理了。
  1. 如何保证消息可靠性?

为了留存消息,List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存

这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。

  1. 总结

基于 List 类型的消息队列,满足消息队列的三大需求(消息保序、处理重复的消息和保证消息的可靠性):

  • 消息保序:使用 LPUSH + RPOP
  • 阻塞读取:使用 BRPOP
  • 重复消息处理:生产者自行实现全局唯一 ID
  • 消息的可靠性:使用 BRPOPLPUSH
  1. List 作为消息队列有什么缺陷?

List 不支持多个消费者消费同一条消息,因为一旦消费者拉取一条消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费。

要实现一条消息可以被多个消费者消费,那么就要将多个消费者组成一个消费组,使得多个消费者可以消费同一条消息,但是 List 类型并不支持消费组的实现

Hash(哈希)

Hash 是一个键值对(key - value)集合,其中 value 的形式如: value=[{field1,value1},...{fieldN,valueN}]。Hash 特别适合用于存储对象。

img

内部实现

Hash 类型的底层数据结构是由压缩列表或哈希表实现的:

  1. 如果哈希类型的元素个数较少时,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
  2. 如果哈希类型的元素个数较多时,Redis 会使用哈希表作为 Hash 类型的底层数据结构;

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了

应用场景

  1. 缓存对象:

Hash 类型的 (key,field, value) 的结构与对象的(对象id, 属性, 值)的结构相似,也可以用来存储对象。

  1. 购物车:

以用户 id 为 key,商品 id 为 field,商品数量为 value,恰好构成了购物车的3个要素。

Set(集合)

Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。

Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。

Set 类型和 List 类型的区别如下:

  • List 可以存储重复元素,Set 只能存储非重复元素;
  • List 是按照元素的先后顺序存储元素的,而 Set 则是无序方式存储元素的。

内部实现

Set 类型的底层数据结构是由哈希表或整数集合实现的:

  1. 如果集合中的元素较少时,Redis 会使用整数集合作为 Set 类型的底层数据结构
  2. 如果集合中的元素较多时,Redis 会使用哈希表作为 Set 类型的底层数据结构

应用场景

在主从集群中,为了避免主库因为 Set 做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计。

  1. 点赞:

Set 类型可以保证一个用户只能点一个赞,key 是文章id,value 是用户id。

  1. 共同关注:

Set 类型支持交集运算,所以可以用来计算共同关注的好友、公众号等。

key 可以是用户id,value 则是已关注的公众号id。

  1. 抽奖活动:

存储某活动中中奖的用户名 ,Set 类型因为有去重功能,可以保证同一个用户不会中奖两次。

key为抽奖活动名,value为员工名称,把所有员工名称放入抽奖箱。

如果允许重复中奖,可以使用 SRANDMEMBER 命令。

如果不允许重复中奖,可以使用 SPOP 命令。

Zset(有序集合)

Zset 类型(有序集合类型)相比于 Set 类型多了一个排序属性 score(分值),对于有序集合 ZSet 来说,每个存储元素相当于由两个值组成,一个是有序集合的元素值,一个是排序值。

有序集合保留了集合中不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。

内部实现

Zset 类型的底层数据结构是由压缩列表或跳表实现的:

  1. 如果有序集合中的元素较少时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构
  2. 如果有序集合中的元素较多时,Redis 会使用跳表作为 Zset 类型的底层数据结构

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

应用场景

Zset 类型(Sorted Set,有序集合) 可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。

  1. 排行榜:

有序集合比较典型的使用场景就是排行榜。例如学生成绩的排名榜、游戏积分排行榜、视频播放排名、电商系统中商品的销量排名等。

  1. 电话、姓名排序

使用有序集合的 ZRANGEBYLEXZREVRANGEBYLEX 可以帮助我们实现电话号码或姓名的排序(返回指定区间内的成员,按 key 正序排列,分数必须相同)

BitMap(位图)

Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为O(1)。

由于 bit 是计算机中最小的单位,使用它进行存储将非常节省空间,特别适合一些数据量大且使用二值统计的场景

img

内部实现

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。

String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示每个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组。

应用场景

  1. 签到统计

在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。

  1. 判断用户登录状态

HyperLogLog(统计基数)

HyperLogLog 提供不精确的去重计数,标准误算率是0.81%

每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数

应用场景

  1. 百万级网页 UV 计数

GEO(经纬度)

GEO 主要用于存储地理位置信息,并对存储的信息进行操作。

内部实现

GEO 本身并没有设计新的底层数据结构,而是直接使用了 Sorted Set 集合类型。

应用场景

滴滴打车:

可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。

Stream(消息队列)

Stream 是 Redis 专门为消息队列设计的数据类型。

Stream 用于完美地实现消息队列,它支持消息的持久化、支持自动生成全局唯一 ID、支持 ack 确认消息的模式、支持消费组模式等,让消息队列更加稳定和可靠。

常见命令

Stream 消息队列操作命令:

  • XADD:插入消息,保证有序,可以自动生成全局唯一 ID;
  • XLEN :查询消息长度;
  • XREAD:用于读取消息,可以按 ID 读取数据;
  • XDEL : 根据消息 ID 删除消息;
  • DEL :删除整个 Stream;
  • XRANGE :读取区间消息
  • XREADGROUP:按消费组形式读取消息;
  • XPENDINGXACK
    • XPENDING 命令可以用来查询每个消费组内所有消费者「已读取,但尚未确认」的消息;
    • XACK 命令用于向消息队列确认消息处理已完成

应用场景

消息队列:

生产者插入一条消息,成功后会返回全局唯一的 ID,由两部分组成:消息插入的时间、消息序号。

如果想要实现阻塞读(当没有数据时,阻塞住),可以在调用 XRAED 时设定 BLOCK 配置项,实现类似于 BRPOP 的阻塞读取操作。

Stream 可以使用 XGROUP 创建消费组,创建消费组之后,Stream 可以使用 XREADGROUP 命令让消费组内的消费者读取消息。

消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了,即同一个消费组里的消费者不能消费同一条消息

但是,不同消费组的消费者可以消费同一条消息(但是有前提条件,创建消息组的时候,不同消费组指定了从相同位置开始读取消息)

使用消费组的目的是让组内的多个消费者共同分担读取消息,所以,我们通常会让每个消费者读取部分消息,从而实现消息读取负载在多个消费者间是均衡分布的。

  1. 基于 Stream 实现的消息队列,如何保证消费者在发生故障或宕机重启后,仍然可以读取未处理完的消息?

Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Streams“消息已经处理完成”。

消费确认增加了消息的可靠性,一般在业务处理完成之后,需要执行 XACK 命令确认消息已经被消费完成,整个流程的执行如下图所示:

img

如果消费者没有成功处理消息,它就不会给 Streams 发送 XACK 命令,消息仍然会留存。此时,消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息

  1. 总结
  • 消息保序:XADD/XREAD
  • 阻塞读取:XREAD block
  • 重复消息处理:Stream 在使用 XADD 命令,会自动生成全局唯一 ID;
  • 消息可靠性:内部使用 PENDING List 自动保存消息,使用 XPENDING 命令查看消费组已经读取但未被确认的消息,消费者使用 XACK 确认消息;
  • 支持消费组形式消费数据

Redis 消息中间件会不会丢消息?

,Redis 在以下 2 个场景中,都会导致数据丢失:

  • AOF 持久化配置为每秒写盘,但这个写盘过程是异步的,Redis 宕机时会存在数据丢失的可能
  • 主从复制也是异步的,主从切换时,也存在丢失数据的可能

Redis Stream 消息可堆积吗?

Redis 的数据都存储在内存中,这就意味着一旦发生消息积压,则会导致 Redis 的内存持续增长,如果超过机器内存上限,就会面临被 OOM 的风险。

所以 Redis 的 Stream 提供了可以指定队列最大长度的功能,就是为了避免这种情况发生。

当指定队列最大长度时,队列长度超过上限后,旧消息会被删除,只保留固定长度的新消息。这么来看,Stream 在消息积压时,如果指定了最大长度,还是有可能丢失消息的。

能不能将 Redis 作为消息队列来使用,关键看你的业务场景:

  • 如果你的业务场景足够简单,对于数据丢失不敏感,而且消息积压概率比较小的情况下,把 Redis 当作队列是完全可以的。
  • 如果你的业务有海量消息,消息积压的概率比较大,并且不能接受数据丢失,那么还是用专业的消息队列中间件吧。

Redis 发布/订阅机制为什么不可以作为消息队列?

  1. 发布/订阅机制没有基于任何数据类型实现,所以不具备「数据持久化」的能力,也就是发布/订阅机制的相关操作,不会写入到 RDB 和 AOF 中,当 Redis 宕机重启,发布/订阅机制的数据也会全部丢失。
  2. 发布/订阅模式是“发后既忘”的工作模式,如果有订阅者离线重连之后不能消费之前的历史消息。
  3. 当消费端有一定的消息积压时,也就是生产者发送的消息,消费者消费不过来时,如果超过 一定程度,消费端会被强行断开。

2.Redis 数据结构

注意,Redis 数据结构并不是指 String(字符串)对象、List(列表)对象、Hash(哈希)对象、Set(集合)对象和 Zset(有序集合)对象,因为这些都是 Redis 键值对中值的数据类型,也就是数据的保存形式,这些对象的底层实现方式就用到了数据结构

img

键值对在 Redis 中是怎么实现的呢?

Redis 的键值对中的 key 就是字符串对象,而 value 可以是字符串对象,也可以是集合数据类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象。

为了实现从键到值的快速访问,Redis 使用一个哈希表来保存所有键值对(全局哈希表)。

一个哈希表其实就是一个数组,数组中的每个元素称为一个哈希桶。一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。

哈希桶中的元素保存的并不是键值对本身,而是指向具体键值的指针。也就是说,不管值是 String,还是集合类型,哈希桶中的元素都是指向它们的指针。

哈希桶中的 entry 元素保存了 key 和 value 指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过 value 指针被查找到。

image-20220905205349135

哈希表的最大特点是可以用 O(1) 的时间复杂度来快速查找到键值对。

我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问到相应的 entry 元素。

哈希冲突

为什么哈希表操作变慢了?

  • 当多个 key 进行哈希计算时,有可能不同的 key 计算出了相同的哈希值,这就产生了哈希冲突。
  • Redis 解决哈希冲突的方式,就是链式哈希。同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

举例:哈希冲突

entry1、entry2 和 entry3 都需要保存在哈希桶 3 中,这就导致了哈希冲突。

此时,entry1 元素会通过一个 next 指针指向 entry2,同样,entry2 也会通过 next 指针指向 entry3

这样一来,即使哈希桶 3 中的元素有 100 个,我们也可以通过 entry 元素中的指针,把它们连起来。这就形成了一个链表,也叫作哈希冲突链。

image-20220905212232753

如果哈希冲突越来越多,形成的冲突链表就会越来越长,导致在这个链表上的元素查找耗时较长,效率降低。

针对于上面存在的问题,Redis 引入了 rehash 操作

  • rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。
  • 为了使 rehash 操作更加高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。
  • 一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:
    • 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;
    • 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
    • 释放哈希表 1 的空间。
  • 至此,我们就可以从哈希表 1 切换到哈希表 2,用增大的哈希表 2 保存更多的数据,而原来的哈希表 1 留作下一次 rehash 扩容备用。

把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中 这一步又会存在问题,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。

  • 为了避免这个问题,Redis 采用了渐进式rehash
  • 在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,就从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;
  • 等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries
  • 如下图所示:

Untitled

渐进式 rehash 技术使用了离散的概念,将迁移数据的工作量均摊到每次操作中,避免一次性迁移造成 Redis 不可用。代价就是在较长的时间内会存在两个哈希表。

渐进式 rehash 执行时,除了根据键值对的操作来进行数据迁移,Redis 本身还会有一个定时任务在执行 rehash;

如果没有键值对操作时,这个定时任务会周期性地(例如每100ms一次)迁移一些数据到新的哈希表中,这样就可以缩短整个 rehash 的时间。

什么情况下会触发 rehash 操作呢?

rehash 的触发条件跟**负载因子(load factor)**有关。

img

  1. 当负载因子大于等于 1 时,并且 Redis 没有在执行 RDB 快照或 AOF 重写的时候,就会进行 rehash 操作。
  2. 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。

SDS(简单动态字符串)

img

  1. len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量的值就行了,时间复杂度只需要 O(1)
  2. alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出的问题。
  3. flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
  4. buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。

链表

链表节点结构设计:

img

链表结构设计:

list 结构为链表提供了链表头指针 head、链表尾节点 tail、链表节点数量 len、以及可以自定义实现的 dup、free、match 函数。

下面是由 list 结构和 3 个 listNode 结构组成的链表

img

压缩列表

压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应的编码,这种方式可以有效地节省内存开销。

压缩列表结构设计:

压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。

img

  • zlbytes,记录整个压缩列表占用的内存字节数;
  • zltail,记录压缩列表「尾部」节点距离起始地址有多少字节,也就是列表尾节点的偏移量;
  • zllen,记录压缩列表包含的节点数量;
  • zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段直接定位,时间复杂度是 O(1)。而在查找其他元素时,就没那么高效了,只能逐个查找,此时的时间复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素

压缩列表节点(entry)的构成如下:

img

  • prevlen,记录了「前一个节点」的长度,目的是为了实现从后向前遍历;
  • encoding,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。
  • data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

当我们往压缩列表中插入数据时,压缩列表就会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlenencoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的

连锁更新

压缩列表除了查找时间复杂度较高的问题,还存在「连锁更新」的问题。

压缩列表新增某个元素或修改某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。

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

  • 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值;
  • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值;

现在假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,如下图:

img

因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。

这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点,如下图:

img

因为 e1 节点的 prevlen 属性只有 1 字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间进行重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。

多米诺牌的效应就此开始。

img

压缩列表的缺陷

空间扩展操作也就是重新分配内存,因此连锁更新一旦发生,就会导致压缩列表占用的内存空间需要多次重新分配,这将会直接影响到压缩列表的访问性能

虽然压缩列表紧凑型的内存布局能节省内存开销,但如果保存的元素数量增加了,或者是元素变大了,就会导致内存重新分配,最糟糕的是会有「连锁更新」的问题。

因此,压缩列表只会用于保存节点数量不多的场景,只要节点数量足够小,即使发生连锁更新,也是能接受的。

整数集合

跳表

Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。

Zset 结构体里有两个数据结构:一个是跳表,一个是哈希表。这样设计的好处是既能支持高效的范围查询,也能支持高效的单点查询。

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

Zset 对象在执行数据插入和数据更新的过程中,会依次在跳表和哈希表中插入和更新相应的数据,从而保证了跳表和哈希表中记录的一致性。

Zset 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引。

跳表结构设计

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

img

图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:

  • L0 层级共有 5 个节点,分别是节点 1、2、3、4、5;
  • L1 层级共有 3 个节点,分别是节点 2、3、5;
  • L2 层级只有 1 个节点,也就是节点 3 。

如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次。

而使用了「跳表」后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。

这个查找的过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度是 O(logN)

跳表节点层数设置

跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)

下图的跳表就是,相邻两层的节点数量的比例是 2 : 1。

img

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

跳表在创建节点的时候,会生成一个范围在 [0-1] 的随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。

这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64

为什么 Zset 的实现用跳表而不用平衡树(如 AVL树、红黑树等)?

  1. 从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
  2. 在做范围查找的时候,跳表比平衡树操作更简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。
  3. 从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。

quicklist

在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或压缩列表。在 Redis 3.2 之后,List 对象的底层改由 quicklist 数据结构实现。

其实 quicklist 就是「双向链表 + 压缩列表」的组合,因为 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。

压缩列表有「连锁更新」的风险,而 quicklist 的解决办法是:通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表的元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。

quicklist 结构设计

img

在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表中,如果不能容纳,才会新建一个新的 quicklistNode 结构。

quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的「连锁更新」的风险,但是这并没有完全解决连锁更新的问题。

listpack

Redis 在 5.0 新设计了一个数据结构叫做 listpack,目的是替代压缩列表,它最大的特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表正因为每个节点需要保存前一个节点的长度字段,就会有「连锁更新」的隐患。

listpack 结构设计

listpack 是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存开销,listpack 节点会采用不同的编码方式来保存不同大小的数据。

img

每个 listpack 的节点结构如下:

img

  • encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
  • data,实际存放的数据;
  • len,encoding + data的总长度;

可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的「连锁更新」问题。

3.Redis 过期删除策略

Redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。

expire <key> <n>:设置 key 在 n 秒后过期

设置键值对的时候,同时指定过期时间(精确到秒):(下面两个命令都可以)

  1. set <key> <value> ex <n>

  2. setex <key> <n> <valule>

TTL <key>:查看某个 key 剩余的存活时间

如何判断一个 key 已过期了?

对一个 key 设置了过期时间,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。

过期字典的数据结构如下:

  • 过期字典的 key 是一个指针,指向某个键对象;
  • 过期字典的 value 是一个 long long 类型的整数,这个整数保存了 key 的过期时间;

字典实际上是哈希表,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找。当我们查询一个 key 时,Redis 首先检查该 key 是否存在于「过期字典」中:

  • 如果不在,则正常读取键值;
  • 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。

过期删除策略有哪些?

常见的三种过期删除策略:

  • 定时删除
  • 惰性删除
  • 定期删除

定时删除策略是怎样的?

在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。

优点:

可以保证过期 key 会被尽快删除,也就是内存可以被尽快释放,因此定时删除策略对内存是最友好的

缺点:

在过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分的 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。

惰性删除策略是怎样的?

不主动删除过期键,每次从数据库访问 key 时,都会检测 key 是否过期,如果过期则删除该 key。

优点:

因为在每次访问时才会检查 key 是否过期,所以此策略只会占用很少的系统资源。因此,惰性删除策略对 CPU 时间最友好。

缺点:

如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问到,它所占用的内存就不会被释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存空间不友好。

定期删除策略是怎样的?

每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

优点:

通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响。同时也能删除一部分过期的key,减少了过期key对空间的无效占用。

缺点:

  1. 内存清理方面没有定时删除策略的效果好,同时也没有惰性删除策略使用的系统资源少。
  2. 难以确定删除操作执行的时长和频率。如果执行的太频繁,定期删除策略就会变得和定时删除策略一样,对CPU不友好;如果执行的太少,那又和惰性删除策略一样了,过期 key 占用的内存空间不会及时得到释放。

Redis 过期删除策略是什么?

每一种策略都有其优缺点,仅使用其中一种策略都不能满足实际需求。

Redis 选择「惰性删除+定期删除」这两种策略配合使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。

关于定期删除策略

  1. 这个间隔检查的时间是多长呢?

默认每秒进行10次过期检查,每次检查数据库并不是遍历过期字典中的所有key,而是从数据库中随机抽取一定数量的 key 进行过期检查。

  1. 随机抽查的数量是多少呢?

数据库每轮抽查时,会随机选择 「20」 个 key 判断是否过期。

Redis 定期删除的完整流程:

  1. 从数据库中随机抽取 20 个key;
  2. 检查这 20 个key是否过期,并删除已过期的key;
  3. 如果本轮检查已过期 key 的数量超过 5 个,也就是「已过期 key 」的比例大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。

Redis 规定定期删除的循环流程默认不会超过 25ms。

do {
    //已过期的数量
    expired = 0//随机抽取的数量
    num = 20;
    while (num--) {
        //1. 从过期字典中随机抽取 1 个 key
        //2. 判断该 key 是否过期,如果已过期则进行删除,同时对 expired++
    }
    
    // 超过时间限制则退出
    if (timelimit_exit) return;

  /* 如果本轮检查的已过期 key 的数量,超过 25%,则继续随机抽查,否则退出本轮检查 */
} while (expired > 20/4); 

img

4.Redis 内存淘汰策略

当 Redis 的运行内存超过 Redis 设置的最大内存时,会使用内存淘汰策略删除符合条件的 key,以此来保障 Redis 的高效运行。

如何设置 Redis 最大运行内存?

在配置文件 redis.conf 中,可以通过参数 maxmemory <bytes> 来设定最大运行内存,只有在 Redis 的运行内存达到了我们设置的最大运行内存时,才会触发内存淘汰策略

  1. 在64位操作系统中,maxmemory 的默认值是 0,表示没有内存大小限制,那么不管用户存放多少数据到 Redis 中,Redis 也不会对可用内存进行检查,直到 Redis 实例因内存不足而崩溃。
  2. 在 32 位操作系统中,maxmemory 的默认值是 3 GB,因为 32 位的机器最大只支持 4GB 的内存,而系统本身就需要一定的内存资源来支撑运行,所以 32 位操作系统限制最大 3 GB 的可用内存是非常合理的,这样可以避免因为内存不足而导致 Redis 实例崩溃。

Redis 内存淘汰策略有哪些?

Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。

  1. 不进行数据淘汰的策略

noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。

  1. 进行数据淘汰的策略

针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。

在设置了过期时间的数据中进行淘汰:

  • volatile-random:随机淘汰设置了过期时间的任意键值;
  • volatile-ttl:优先淘汰更早过期的键值;
  • volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
  • volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值。

在所有数据范围内进行淘汰:

  • allkeys-random:随机淘汰任意键值;
  • allkeys-lru:淘汰所有键值中,最久未使用的键值;
  • allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有键值中,最少使用的键值。

查看当前 Redis 使用的内存淘汰策略:config get maxmemory-policy

修改 Redis 内存淘汰策略

  1. 通过 config set maxmemory-policy <策略> 命令设置。它的优点是设置之后立即生效,不需要重启 Redis 服务,缺点是重启 Redis 服务之后,设置就会失效。

  2. 通过修改 Redis 配置文件,设置 maxmemory-policy <策略> 。它的优点是重启 Redis 服务后配置不会丢失,缺点是必须重启 Redis 服务,设置才能生效。

LRU 算法和 LFU 算法有什么区别?

什么是 LRU 算法?

LRU 全称是:最近最少使用,会选择淘汰最近最少使用的数据。

传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。

Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题:

  1. 需要用链表管理所有的缓存数据,这会带来额外的内存开销;
  2. 当有数据被访问时,需要在链表上把该数据移动到表头,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 的缓存性能。

Redis 是如何实现 LRU 算法的?

Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间

当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个

优点:

  1. 不用为所有数据维护一个大链表,节省空间占用。
  2. 不用在每次数据访问时都移动链表项,提升了缓存的性能。

缺点:无法解决缓存污染问题

比如应用一次性读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。

什么是 LFU 算法?

LFU 全称是:最不经常使用的,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是:如果数据过去被访问多次,那么将来被访问的频率也更高。

所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。

Redis 是如何实现 LFU 算法的?

LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。Redis 对象的结构如下:

typedef struct redisObject {
    ...
      
    // 24 bits,用于记录对象的访问信息
    unsigned lru:24;  
    ...
} robj;

Redis 对象头中的 lru 字段,在 LRU 算法和 LFU 算法下的使用方式并不相同。

  1. 在 LRU 算法中,Redis 对象头中的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis 可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间,从而淘汰最久未被使用的 key。
  2. 在 LFU 算法中,Redis 对象头中的 24 bits 的 lru 字段会被分成两段来存储,高 16bit 存储 ldt,低 8bit 存储 logc

ldt 是用来记录 key 的访问时间戳。

logc 是用来记录 key 的访问频率,它的值越小表示使用频率越低,越容易淘汰,每个新加入的 key 的 logc 初始值为 5。

Redis 在访问 key 时,对于 logc 是这样变化的:

  1. 先按照上次访问距离当前的时长,来对 logc 进行衰减(差距越大,衰减的值越大)
  2. 然后,再按照一定的概率增加 logc 的值

img

5.缓存雪崩、击穿、穿透

缓存雪崩

图片

大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求都无法在 Redis 中进行处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的话会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是「缓存雪崩」的问题。

图片

发生「缓存雪崩」有两个原因:

  1. 大量缓存数据同时过期
  2. Redis 故障宕机

大量缓存数据同时过期,应该怎么解决呢?

  1. 均匀设置过期时间

如果要给缓存数据设置过期时间,应该避免将大量的数据设置成相同的过期时间。

如果业务层的确要求有些数据需要同时失效,可以在用 EXPIRE 命令给每个数据设置过期时间时,增加一个较小的随机数(例如,随机增加 1~3 分钟)

这样一来,不同数据的过期时间有所差别,但差别又不会太大,既避免了大量数据同时过期,也保证了这些数据基本在相近的时间失效,仍然能满足业务需求。

  1. 通过服务降级来应对缓存雪崩

所谓服务降级,是指发生缓存雪崩时,针对不同的数据采取不同的处理方式:

  • 当业务应用访问的是非核心数据(例如电商商品属性)时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息;
  • 当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存。如果缓存缺失,也可以继续通过数据库进行读取。
image-20220827171401181

Redis 故障宕机,应该怎么解决呢?

  1. 在业务系统中实现服务熔断请求限流机制

在因为 Redis 故障宕机而发生缓存雪崩时,我们可以启动「服务熔断」机制,暂停业务应用对缓存系统的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存系统。

服务熔断虽然可以保证数据库的正常运行,但是暂停了整个缓存系统的访问,对业务应用的影响范围很大。

为了减少对业务的影响,我们可以启用「请求限流」机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口处直接拒绝服务,等到 Redis 恢复正常并把缓存预热后,再解除「请求限流」的机制。

  1. 构建 Redis 缓存高可靠集群

「服务熔断」或「请求限流」机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群

如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的「缓存雪崩」问题。

缓存击穿

我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类频繁访问的数据被称为「热点数据」。

如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,因此直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。

图片

针对「热点数据」过期导致的缓存击穿,应该怎么解决呢?

对于访问特别频繁的热点数据,不设置过期时间。

这样一来,对热点数据的访问请求,都可以在缓存中进行处理,而 Redis 数万级别的高吞吐量可以很好地应对大量的并发请求访问。

缓存穿透

当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据来服务后续的请求。

那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。

图片

缓存穿透的发生一般有两种情况:

  1. 业务层误操作,缓存中的数据和数据库中的数据被误删除了,导致缓存和数据库中都没有数据
  2. 黑客恶意攻击,专门访问数据库中没有的数据

针对于「缓存穿透」有三种解决方案:

  1. 可以在请求入口的前端进行请求检测

缓存穿透的一个原因是有大量的恶意请求访问不存在的数据,所以一个有效的应对方案是在请求入口的前端,对业务系统接收到的请求进行合法性检测。

把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和数据库。

这样一来,也就不会出现缓存穿透问题了。

  1. 缓存空值或缺省值

当我们的线上业务发现「缓存穿透」的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以直接从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。

  1. 使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力

布隆过滤器是由一个初值都为 0 的 bit 数组和 N 个哈希函数组成的,可以用来快速判断某个数据是否存在。

当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:

  • 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值。
  • 第二步,将第一步得到的 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
  • 第三步,把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。

如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过该数据,那么 bit 数组对应 bit 位的值仍然为 0。

当需要查询某个数据时,我们就执行刚刚说的计算过程,先得到这个数据在 bit 数组中对应的 N 个位置。

紧接着,我们查看 bit 数组中这 N 个位置上的 bit 值。

只要这 N 个 bit 值有一个不为 1,这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。

image-20220827192806597

正是基于布隆过滤器的快速检测特性,我们可以在把数据写入数据库时,使用布隆过滤器做个标记。

当缓存缺失后,应用查询数据库时,可以先通过查询布隆过滤器快速判断数据是否存在。如果不存在,就不用再去数据库中查询了。

这样一来,即使发生缓存穿透了,大量请求只会查询 Redis 和布隆过滤器,而不会积压到数据库,也就不会影响到数据库的正常运行。

布隆过滤器可以使用 Redis 实现,本身就能承担较大的并发访问压力。

6.主从复制是如何实现的?

Redis 提供了主从复制模式,可以保证多台服务器的数据一致性,且主从服务器之间采用的是「读写分离」的方式。

所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。

第一次同步(全量复制)

从服务器通过执行 replicaof <主服务器的 IP 地址> <主服务器的 Redis 端口号> 命令,与主服务器建立连接,然后就可以和主服务器进行第一次同步。

主从服务器间的第一次同步的过程可以分为三个阶段:

图片

  1. 第一阶段:建立连接、协商同步

执行了 replicaof 命令后,从服务器就会给主服务器发送 psync 命令,表示要进行数据同步。

psync 命令包含两个参数,分别是主服务器的 runID复制进度 offset

runID:每个 Redis 服务器在启动时都会自动生成一个随机的 ID 来唯一标识自己,因为是第一次同步,还不知道主服务器的 runID,所以将其设置为 “?”。

offset:表示复制进度,第一次同步时,其值为 -1。

主服务器收到 psync 命令后,会用 FULLRESYNC 作为响应命令返回给对方。

并且这个响应命令会带上两个参数:主服务器的 runID主服务器目前的复制进度 offset。从服务器收到响应后,会记录这两个值。

FULLRESYNC 响应命令的意图是采用全量复制的方式,也就是主服务器会把所有的数据都同步给从服务器。

  1. 第二阶段:主服务器同步数据给从服务器

接着,主服务器会执行 bgsave 命令来生成 RDB 文件,然后把文件发送给从服务器。

从服务器收到 RDB 文件后,会先清空当前的数据,然后再载入 RDB 文件

注意:主服务器生成 RDB 文件这个过程是不会阻塞主线程的,因为 bgsave 命令会创建一个子进程来做生成 RDB 文件的工作,是异步的,这样 Redis 依然可以正常处理命令。

但是这期间的写操作命令并没有记录到刚刚生成的 RDB 文件中,这时主从服务器间的数据就不一致了。为了保证主从服务器的数据一致性,主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer 缓冲区里。

  • 主服务器生成 RDB 文件期间
  • 主服务器发送 RDB 文件给从服务器期间
  • 「从服务器」加载 RDB 文件期间
  1. 第三阶段:主服务器发送新的写操作命令给从服务器

「从服务器」加载完 RDB 文件后,主服务器再将 replication buffer 缓冲区中记录的写操作命令发送给从服务器,然后「从服务器」重新执行这些操作,至此主从服务器的数据就一致了。

第一次同步的工作就完成了。

主从级联模式(主从从)分担全量复制时的主库压力

主从库第一次数据同步时,对主库来说有两个耗时的操作:

  1. 生成 RDB 文件

fork 子进程这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。

  1. 传输 RDB 文件

传输RDB文件会占用主库的网络带宽,同样会给主库的资源使用带来压力。

针对上面两个耗时操作,“主-从-从” 模式可以有效分担主库的压力

通过 “主 - 从 - 从” 模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。

在部署主从集群的时候,可以手动选择一个内存资源配置较高的从库,用于级联其他的从库。

然后,可以再选择一些从库,在这些从库上执行如下命令,让它们和刚才所选的「从库」建立起主从关系。

replicaof 所选从库的IP 6379

这样一来,这些从库就会知道,在进行同步时,不用再和主库进行交互了,只要和级联的从库进行写操作同步就行了,这就可以减轻主库上的压力,如下图所示:

image-20220827210551432

“主从从” 模式能够减少主库同步给所有从库的压力。

一旦主库和从库完成全量复制,它们之间就会一直维护一个网络连接,主库会把之后收到的命令通过这个连接同步给从库,这个过程就称为基于长连接的命令传播。

增量复制

如果主从服务器间的网络连接断开了,那么就无法进行命令传播了,这时从服务器的数据就没办法和主服务器保持一致了,客户端就可能从「从服务器」读到旧的数据。

图片

如果此时断开的网络又恢复正常了,要怎么继续保证主从服务器的数据一致性呢?

在 Redis 2.8 之前,如果主从服务器在命令同步时出现了网络断开又恢复的情况,从服务器就会和主服务器重新进行一次全量复制,很明显这样的开销太大了,必须要改进一波。

在 Redis 2.8 之后,网络断开又恢复,主从服务器会采用增量复制的方式继续同步,也就是只会把网络断开期间主服务器接收到的写操作命令,同步给从服务器。

图片

主要有三个步骤:

  1. 从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令中的 offset 参数不是 -1
  2. 主服务器收到该命令后,通过 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据
  3. 然后主服务器将主从断开期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令

主服务器怎么知道要将哪些增量数据发送给从服务器呢?

repl_backlog_buffer,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据

replication offset,标记「环形」缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「写」到的位置,从服务器使用 slave_repl_offset 来记录自己「读」到的位置。

repl_backlog_buffer 缓冲区是什么时候写入的呢

在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到 repl_backlog_buffer 「环形」缓冲区中,因此这个缓冲区会保存着最近传播的写命令。

网络断开后,当从服务器重新连上主服务器时,从服务器会通过 psync 命令将自己的复制偏移量 slave_repl_offset 发送给主服务器,主服务器根据自己的 master_repl_offsetslave_repl_offset 之间的差距,来决定对从服务器执行哪种同步操作:

  1. 如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用增量复制的方式
  2. 如果判断出从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用全量复制的方式

当主服务器在 repl_backlog_buffer 「环形」缓冲区中找到主从库差异(增量)的数据后,就会将增量的数据写入到 replication buffer 缓冲区,这个缓冲区就是缓存将要传播给从服务器的命令。

图片

repl_backlog_buffer 「环形」缓冲区的默认大小是 1M,并且由于它是一个环形缓冲区,当缓冲区写满后,主服务器继续写入的话,就会覆盖之前的数据。

在网络恢复时,如果从服务器想读的数据已经被覆盖了,那么主服务器就会采用全量复制的方式,这个方式比增量复制的性能损耗要大很多。

为了避免在网络恢复时,主服务器频繁地使用全量复制的方式,我们应该调整下 repl_backlog_buffer 「环形」缓冲区的大小,减少从服务器要读的数据被覆盖的概率

repl_backlog_buffer 「环形」缓冲区大小的计算公式:

缓冲空间大小 = 主库写入命令的速度 * 每个操作的大小 - 主从库间网络传输命令的速度 * 每个操作的大小

在实际应用中,考虑到可能存在一些突发的请求压力,我们通常把这个缓冲空间扩大一倍,即 repl_backlog_size = 缓冲空间大小 * 2

举个例子,如果主库每秒写入 2000 个操作每个操作的大小为 2KB网络每秒能传输 1000 个操作,那么,有 1000 个操作需要缓冲起来,这就至少需要 2MB 的缓冲空间。否则,新写的命令就会覆盖掉旧操作了。为了应对可能的突发压力,我们最终把 repl_backlog_size 设置为 4MB。

7.Redis性能问题排查和调优

图片

8.AOF 持久化机制

AOF 日志

img

这种保存写操作命令到日志的持久化方式,就是 Redis 里的 AOF(Append Only File) 持久化功能。

在 Redis 中 AOF 持久化功能默认是不开启的,需要我们修改 redis.conf 配置文件中的以下参数:

img

三种写回策略

将命令写入到日志的这个操作也是在主进程中完成的(执行命令也是在主进程),也就是说这两个操作是同步的。

img

Redis 写入 AOF 日志的过程,如下图:

img

Redis 提供了 3 种写回硬盘的策略,在 redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:

  • Always,每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
    • 可以最大程度保证数据不丢失,但是由于它每执行一条写操作命令就同步将 AOF 内容写回硬盘,所以是不可避免会影响主进程的性能。
  • Everysec,每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
    • 如果上一秒的写操作命令日志没有写回到硬盘,服务器发生了宕机,这一秒内的数据自然也会丢失。
  • No,每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
    • 操作系统写回硬盘的时机是不可预知的,如果 AOF 日志内容没有写回硬盘,一旦服务器宕机,就会丢失不定数量的数据。

需要根据自己的业务场景进行选择:

  • 如果要高性能,就选择 No 策略;
  • 如果要高可靠,就选择 Always 策略;
  • 如果允许数据丢失一点,但又想性能高,就选择 Everysec 策略。
img

这三种策略只是在控制 fsync() 函数的调用时机

img

如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以调用 fsync() 函数,这样内核就会将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。

  • Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
  • Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
  • No 策略就是由操作系统来控制何时执行 fsync() 函数。

AOF 重写机制

Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

假设前后执行了「set name xiaolin」和「set name xiaolincoding」这两个命令

img

在使用重写机制后,就会读取 name 最新的 value(键值对) ,然后用一条 「set name xiaolincoding」命令记录到新的 AOF 文件,之前的第一个命令就没有必要记录了。

重写机制的妙处在于,尽管某个键值对被多条写命令反复修改,最终也只需要根据这个「键值对」当前的最新状态,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这样就减少了 AOF 文件中的命令数量。最后在重写工作完成后,将新的 AOF 文件覆盖现有的 AOF 文件。

为什么重写 AOF 的时候,不直接复用现有的 AOF 文件,而是先写到新的 AOF 文件再覆盖过去呢?

因为如果 AOF 重写过程中失败了,现有的 AOF 文件就会造成污染,可能无法用于恢复使用。

所以 AOF 重写过程,先重写到新的 AOF 文件,重写失败的话,就直接删除这个文件就好,不会对现有的 AOF 文件造成影响。

AOF 后台重写

当 AOF 文件大于 64M 时,就会对 AOF 文件进行重写,这时是需要读取所有缓存的键值对数据,并为每个键值对生成一条命令,然后将其写入到新的 AOF 文件,重写完后,就把现在的 AOF 文件替换掉。

这个过程其实是很耗时的,所以重写的操作不能放在主进程里。

Redis 的重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的

  • 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;
  • 子进程带有主进程的数据副本(数据副本怎么产生的后面会说),创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本。

子进程是怎么拥有和主进程一样的数据副本呢?

主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。

img

这样一来,子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读

不过,当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发缺页中断,这个缺页中断是由于违反权限导致的,然后操作系统会在「缺页异常处理函数」中进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为 写时复制(Copy On Write)

img

写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。

当然,操作系统复制父进程页表的时候,父进程也是阻塞中的,不过页表的大小相比于实际的物理内存要小很多,所以通常复制页表的过程是比较快的。

不过,如果父进程的内存数据非常大,那自然页表也会很大,这时父进程在通过 fork 创建子进程的时候,阻塞的时间也越久。

有两个阶段会导致阻塞父进程:

  1. 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;

  2. 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;

触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。

但是在子进程重写过程中,主进程依然可以正常处理命令。

如果此时主进程修改了已经存在的 key-value,就会发生「写时复制」,注意这里只会复制主进程修改的物理内存数据,没修改的物理内存还是和子进程共享的

如果这个阶段修改的是一个 bigkey,,这时复制物理内存数据的过程就会比较耗时,有阻塞主进程的风险。

此外,Redis 还设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程后开始使用。

在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」

在这里插入图片描述

bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:

  1. 执行客户端发来的命令;
  2. 将执行后的写命令追加到 「AOF 缓冲区」;
  3. 将执行后的写命令追加到 「AOF 重写缓冲区」。

当子进程完成 AOF 重写工作后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。

主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:

  • 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
  • 将新的 AOF 文件进行改名,覆盖现有的 AOF 文件。

信号函数执行完后,主进程就可以继续像往常一样处理命令了。

在整个 AOF 后台重写过程中,除了发生「写时复制」会对主进程造成阻塞,还有信号处理函数执行时也会对主进程造成阻塞,在其他时候,AOF 后台重写都不会阻塞主进程。

9.RDB 持久化机制

AOF 和 RDB 有什么区别?

  1. AOF 文件的内容是操作命令
  2. RDB 文件的内容是二进制数据

RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。

因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。

因为 RDB 快照是全量快照的方式,所以执行的频率不能太频繁,否则会影响 Redis 性能,因此在服务器发生故障时,丢失的数据会比 AOF 持久化的方式更多(AOF 日志可以以秒级的方式记录操作命令)。

快照怎么用?

Redis 提供了两个命令来生成 RDB 文件,分别是 savebgsave,他们的区别在于是否在「主线程」中执行:

  1. 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程
  2. 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞

Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置:

save 900 1
save 300 10
save 60 10000

只要满足上面条件的任意一个,就会执行 bgsave:

  • 900 秒之内,对数据库进行了至少 1 次修改;
  • 300 秒之内,对数据库进行了至少 10 次修改;
  • 60 秒之内,对数据库进行了至少 10000 次修改。

Redis 的快照是全量快照,也就是说每次执行快照时,都是把内存中的「所有数据」都记录到磁盘上。

执行快照时,数据能被修改吗?

执行 bgsave 命令时,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是同一个。

图片

只有在发生修改内存数据的情况时,物理内存才会被复制一份。

图片

这样做的目的是为了减少创建子进程时的性能损耗,从而加快创建子进程的速度,毕竟创建子进程的过程中,是会阻塞主线程的。

创建 bgsave 子进程后,由于共享父进程的所有内存数据,于是就可以直接读取主线程(父进程)里的内存数据,并将数据写入到 RDB 文件。

如果主线程(父进程)要修改共享数据里的某一块数据(比如键值对 A)时,就会发生「写时复制」,于是这块数据的物理内存就会被复制一份(键值对 A',然后主线程在这个数据副本(键值对 A')进行修改操作。与此同时,bgsave 子进程可以继续把原来的数据(键值对 A)写入到 RDB 文件

就是这样,Redis 使用 bgsave 对当前内存中的所有数据做快照,这个操作是由 bgsave 子进程在后台完成的,执行时不会阻塞主线程,这就使得主线程同时可以修改数据。

bgsave 快照过程中,如果主线程修改了共享数据,发生了写时复制后,RDB 快照保存的是原本的内存数据,而主线程刚修改的数据,是没办法在这一时间写入 RDB 文件的,只能交由下一次的 bgsave 快照。

如果系统恰好在 RDB 快照文件创建完毕后崩溃了,那么 Redis 将会丢失主线程在快照期间修改的数据。

写时复制的时候会出现这么个极端的情况:

在 Redis 执行 RDB 持久化期间,刚 fork 时,主进程和子进程共享同一物理内存,但是途中主进程处理了写操作,修改了共享内存,于是当前被修改的数据的物理内存就会被复制一份。

那么极端情况下,如果所有的共享内存都被修改,则此时的内存占用是原先的 2 倍。

所以,针对写操作多的场景,我们要留意快照过程中的内存变化,防止内存被占满了。

RDB 和 AOF 合体

尽管 RDB 比 AOF 的数据恢复速度快,但是快照的频率不好把握:

  • 如果频率太低,两次快照间一旦服务器发生宕机,就可能会丢失比较多的数据;
  • 如果频率太高,频繁写入磁盘和创建子进程会带来额外的性能开销。

有没有什么方法不仅有 RDB 恢复速度快的优点,又有 AOF 丢失数据少的优点呢?

当然有,那就是将 RDB 和 AOF 合体使用,这个方法是在 Redis 4.0 提出的,该方法叫混合使用 AOF 日志和内存快照,也叫「混合持久化」。

如果想要开启混合持久化功能,可以在 Redis 配置文件中将下面这个配置项设置成 yes:

aof-use-rdb-preamble yes

混合持久化工作在 AOF 日志重写过程。

  1. 当开启了混合持久化,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,
  2. 然后主线程处理的写操作命令会被记录在重写缓冲区中,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,
  3. 写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的 AOF 文件。

也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据

图片

这样做的好处在于,重启 Redis 加载数据时,由于前半部分是 RDB 内容,这样加载的时候速度会很快

加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的写操作命令,可以使得数据更少的丢失

10.如何保证缓存和数据库一致性?

先更新数据库,再删除缓存

如何保证两步操作都执行成功?

订阅数据库变更日志,再操作缓存

我们的业务应用在修改数据时,「只需」修改数据库,无需操作缓存。

那什么时候操作缓存呢?这就和数据库的「变更日志」有关了。

当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。

图片

订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal,使用这种方案的优点在于:

  • 无需考虑写消息队列失败情况:只要写 MySQL 成功,Binlog 肯定会有
  • 自动投递到下游队列:canal 自动把数据库变更日志「投递」给下游的消息队列

当然,与此同时,我们需要投入精力去维护 canal 的高可用和稳定性。

主从库延迟和延迟双删问题

在「先删除缓存,再更新数据库」方案下,2 个线程要并发「读写」数据,可能会发生以下场景:

  1. 线程 A 要更新 X = 2(原值 X = 1)
  2. 线程 A 先删除缓存
  3. 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
  4. 线程 A 将新值写入数据库(X = 2)
  5. 线程 B 将旧值写入缓存(X = 1)

最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。

解决方案:在线程 A 删除缓存、更新完数据库之后,先「休眠一会」,再「删除」一次缓存。

延迟时间要大于线程 B 读取数据库 + 写入缓存的时间

在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」其实也会导致不一致:

  1. 线程 A 更新主库 X = 2(原值 X = 1)
  2. 线程 A 删除缓存
  3. 线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 X = 1)
  4. 从库「同步」完成(主从库 X = 2)
  5. 线程 B 将「旧值」写入缓存(X = 1)

最终 X 的值在缓存中是 1(旧值),在主从库中是 2(新值),也会发生不一致。

解决方案:线程 A 可以生成一条「延时消息」,写到消息队列中,消费者延时「删除」缓存。

延迟时间要大于「主从复制」的延迟时间

重点

  1. 引入缓存后,需要考虑缓存和数据库一致性问题,可选的方案有:「更新数据库 + 删除缓存」
  2. 在更新数据库 + 删除缓存的方案中,「先删除缓存,再更新数据库」在「并发」场景下依旧有数据不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估,所以推荐用「先更新数据库,再删除缓存」的方案
  3. 在「先更新数据库,再删除缓存」方案下,为了保证两步都成功执行,需要配合「消息队列」或「订阅变更日志」的方案来做,本质上是通过「重试」的方式保证数据一致性
  4. 在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率

11.Redis 分布式锁

// 一条命令保证原子性执行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK

设想这样一种场景:

  1. 客户端 1 加锁成功,开始操作共享资源
  2. 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
  3. 客户端 2 加锁成功,开始操作共享资源
  4. 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)

这里存在两个严重的问题:

  1. 锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
  2. 释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁

锁被别人释放怎么办?

客户端在加锁时,可以设置一个只有自己知道的「唯一标识」进去。

这个唯一标识,可以是自己的线程ID,也可以是一个UUID(随机且唯一)

// 锁的VALUE设置为UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK

在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:

// 锁是自己的,才释放
if redis.get("lock") == $uuid:
    redis.del("lock")

这里释放锁使用的是 GET + DEL 两条命令,可能会存在原子性问题:

  1. 客户端 1 执行 GET,判断锁是自己的(然后客户端 1 加的锁在 GET 之后过期了)
  2. 客户端 2 执行 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性问题)
  3. 客户端 1 执行 DEL,却释放了客户端 2 的锁

所以,这两个命令还是必须要原子执行才行。

使用 Lua 脚本解决原子性问题

我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。

因为 Redis 处理每一个请求都是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。

图片

安全释放锁的 Lua 脚本如下:

// 判断锁是自己的,才释放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
    return redis.call("DEL",KEYS[1])
else
    return 0
end

小结一下,基于 Redis 实现的分布式锁,一个严谨的的流程如下:

  1. 加锁:SET lock_key $unique_id EX $expire_time NX
  2. 操作共享资源
  3. 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁

图片

锁过期时间不好评估怎么办?

加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。

Java 的 Redisson 库已经封装了上面的解决方案

Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般把它叫做「看门狗」线程。

图片

小结一下,基于 Redis 实现的分布式锁,对应的解决方案:

  • 死锁:设置过期时间
  • 过期时间评估不好,锁提前过期:守护线程,自动续期
  • 锁被别人释放:锁写入唯一标识,释放锁先检查标识,再释放

Redlock 锁

之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。

而我们在使用 Redis 时,一般会采用 主从集群 + 哨兵 的模式部署,这样做的好处是,当主库发生异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证高可用性。

当「主从发生切换」时,设想这样一种场景:

  1. 客户端 1 在主库上执行 SET 命令,加锁成功
  2. 此时,主库发生异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
  3. 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!
图片

可见,当引入 Redis 副本后,分布式锁还是可能会受到影响。

针对上面的问题,Redis 作者提出了一种解决方案:Redlock(红锁)

Redlock 的方案基于 2 个前提:

  1. 不再需要部署从库哨兵实例,只部署主库
  2. 但主库要部署多个,官方推荐至少 5 个实例

也就是说,想要使用 Redlock,至少需要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。

注意:不是部署 Redis Cluster,而是部署 5 个简单的 Redis 实例。

图片

Redlock 整体流程,一共分为五步:

  1. 客户端先获取「当前时间戳T1」
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  3. 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时认为客户端加锁成功,否则认为加锁失败
  4. 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  5. 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

总结一下重点:

  1. 客户端在多个 Redis 实例上申请加锁
  2. 必须保证大多数节点加锁成功
  3. 大多数节点加锁的总耗时,要小于锁设置的过期时间
  4. 释放锁,要向全部节点发起释放锁请求

为什么要在多个实例上加锁?

本质上是为了「容错」,部分实例异常宕机,剩下的实例加锁成功,整个锁服务依旧可用。

为什么大多数加锁成功,才算成功?

多个 Redis 实例一起使用,其实就组成了一个「分布式系统」。

在分布式系统中,总会出现「异常节点」,所以在讨论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。

这是一个分布式系统「容错」问题,这个问题的结论是:如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正常服务的。

为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?

因为操作的是多个 Redis 节点,所以耗时肯定会比操作单个实例耗时更久,而且因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。

所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那么此时有些实例上的锁可能已经失效了,这个锁就没有意义了。

为什么释放锁,要操作所有节点?

在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。

例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,可能因为「网络问题」导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。

所以在释放锁时,不管之前有没有加锁成功,都需要释放「所有节点」的锁,以保证清理某些节点上「残留」的锁。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猿小羽

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值