2023.1.31 关于 Redis 分布式锁详解

目录

引言

分布式锁

引入分布式锁

引入 set nx

引入过期时间

引入校验机制

引入 lua 脚本

引入过期时间续约(看门狗)

引入 redlock 算法

结语


引言

  • 在一个分布式系统中,可能会涉及到多个节点访问同一个公共资源的情况
  • 此时就需要通过锁来进行互斥控制,从而避免出现类似于 线程安全 的问题
  • 而像 Java 的 synchronized 这样的锁都是只能在当前进程中生效,在分布式系统的多个进程多个主机的场景下就无能为力了
  • 此时就需要用到分布式锁

通俗理解:

  • 多个线程并发执行时,执行的先后顺序是不确定的,具有随机性
  • 从而我们可以引入 Java 的 synchronized 来解决多个线程并发执行的随机性
  • 但是 synchronized 本质上只能在一个进程内部生效
  • 在分布式系统中,具有很多进程,即每个服务器均为一个独立进程
  • 因此 synchronized 难以对 分布式系统中的多个进程之间产生制约
  • 再加之分布式系统中,多个进程之间的执行顺序也是不确定的, 具有随机性
  • 为了保证程序在任意执行顺序下,其执行逻辑都是正确的
  • 从而我们引入了 分布式锁

分布式锁

实例引入

  • 此处模拟一个购买车票的场景
  • 买票逻辑:先查询余票,如果余票 > 0,则设置余票 -= 1

2023.1.31 关于 Redis 分布式锁详解_第1张图片

  • 假设此时客户端A 先执行查询 车次1 的余票,发现仅剩余 1 张
  • 在 买票服务器A 即将执行剩余票数 -= 1  过程之前
  • 此时客户端B 也执行查询 车次1 的余票,发现也仅剩余 1 张
  • 此时 买票服务器B 也会执行剩余票数 -= 1  的过程

注意:

  • 上述过程就属于 超卖,即 1张票卖给了 2个人!

引入分布式锁

  • 所谓分布式锁,本质上是使用 一个或一组 单独的服务器程序,通过使用一个键值对来标识锁的状态,来给其他的服务器提供 加锁 这样的服务

注意:

  • Redis 是一种典型的可以用来实现分布式锁的方案,但是不是唯一的一种
  • 业界可能也会使用 MySQL / zookeeper 这样的组件来实现分布式锁的效果

2023.1.31 关于 Redis 分布式锁详解_第2张图片

  • 如上图所示,买票服务器在执行 剩余票数 -= 1 操作的过程中,就需要先进行加锁
  • 即往 Redis 上设置一个特殊的 key-value 来完成上述买票操作,再将这个 key-value 删除掉
  • 当其他服务器也想买票时,也会去 Redis 上尝试设置 key-value
  • 如果发现 key-value 已经存在,就认为加锁失败(加锁失败后具体是放弃 还是阻塞,就得看具体的实现策略了)
  • 此时便可以保证 第一个服务器执行 "查询 ——> 更新" 的过程中,第二个服务器不会执行 "查询" ,也就解决了上述 超卖 问题!

问题:

  • 刚才买票场景,也可使用 MySQL 的事务来批量执行 查询 + 修改 操作
  • 将事务级别修改为 串行化执行 即可 

回答:

  • 首先,在分布式系统中,要访问的共享资源不一定就是 MySQL,也可能是其他的存储介质,且这些存储介质可能没有事务
  • 其次,在某些情况下,可能需要通过同一台服务器来执行一段特定的操作,以确保操作的原子性

引入 set nx

  • set key value nx 命令:如果 key 不存在则进行设置,如果 key 存在则不设置

注意:

  • 使用 set nx 确实可以实现 加锁 效果
  • 针对解锁,我们便可使用 del 命令来完成

问题:

  • 某个服务器 加锁成功了,即 set nx 命令执行成功
  • 如果在执行后续逻辑过程中,程序崩溃了,此时将无法执行解锁操作

对于进程内的锁:

  • 其一,为了保证解锁操作能够执行到,可将解锁操作放到 finally 中
  • 即 Java 中的 try - catch - finally,try 里面的逻辑无论是否出现异常,最后都会执行finally 中的代码
  • 其二,如果进程直接异常退出,锁也会跟着销毁
  • 上述两种做法或情况仅针对进程内的锁有效,针对分布式锁无效!

对于分布式锁:

  • 服务器直接掉电、 进程直接异常终止,这样的情况将直接导致 Redis 上设置的 key 无人删除也就导致其他服务器无法获取到锁
  • 因为 Redis 服务器 和 买票服务器 属于两个不同的服务器,买票服务器挂了,Redis 服务器上设置的 key 自然就无人删除了!

引入过期时间

  • 针对上述 "还没来得及解锁,服务器便宕机" 的情况,我们可以给 key 设置过期时间
  • 通过 set ex nx 这样的命令来完成设置,时间一到 key 便会被自动删除

具体理解:

  • 比如设置 key 的过期时间为 1000ms ,那么意味着即使该服务器出现了极端情况挂掉了,无法释放掉锁,这个锁最多保持 1000ms ,也就自动释放了

注意:

  • set nx + expire 这种设置方式是不行的!务必需要使用 set ex nx 这样的方式来设置
  • 因为 Redis 上多个命令之间,无法保证着多个命令执行的原子性!
  • 此时就可能出现这两个命令,一个成功,一个失败的情况
  • 相比之下,直接使用一条命令设置,便显得更加稳妥!

问题:

  • 所谓的加锁,就是给 Redis 上设置一个 key-value
  • 所谓的解锁,就是给 Redis 上的这个 key-value 给删除掉
  • 是否可能会出现,服务器A 执行了加锁,服务器B 执行了解锁呢?

回答:

  • 这种情况是可以存在的!
  • 正常来说,服务器2 肯定不是故意的,但是代码总会有 bug,从而不小心就执行到了解锁操作,因此就可能进一步的给整个系统带来更严重的问题(比如像超卖)

引入校验机制

  • 针对上述 "服务器A 执行加锁,服务器B 执行解锁" 的情况,我们可以引入校验机制

具体思路:

  1. 给服务器编号,每个服务器均有一个自己的身份标识
  2. 进行加锁时,设置 key-value 对应着要针对哪个资源加锁(比如车次),value 便可以存储用来存储 服务器编号,标识出当前这个锁是哪个服务器加上的
  3. 后续在解锁时,先查询一下这个锁对应的服务器编号,然后判定一下这个编号是否就是当前执行解锁的服务器编号,如果是,则真正执行 del,如果不是,就失败

注意点一:

  • 上述的校验操作为 服务器 需要完成的逻辑
  • 通过上述校验,便可以有效避免 "误解锁"

注意点二:

  • 上述服务器都是我们自己写的代码
  • 这些代码的初心,当然是为了避免出现上述问题,而不会说服务器故意搞破坏

引入 lua 脚本

  • 在解锁时,先查询判定,再进行 del
  • 此时这两步操作就可能会出现问题,因为这两步操作不是原子的
  • 一个服务器内部,是可以同时处理多个请求的
  • 此时就可能出现同一个服务器,两个线程均在执行上述解锁操作

2023.1.31 关于 Redis 分布式锁详解_第3张图片

  • 如上图所示,看起来重复执行 DEL 好像问题不大
  • 因为使用 DEL 命令删除一个不存在的 key 时,会直接返回 0,表示没有删除任何键
  • 是 Redis 的一种正常行为,不会引发错误

2023.1.31 关于 Redis 分布式锁详解_第4张图片

  • 如上图所示,此时引入一个新服务器,要来执行加锁,就可能出现问题了
  • 在线程A 执行完 DEL 之后,线程B 执行 DEL 之前
  • 服务器2 的线程C 正好要执行加锁操作(set nx ex)
  • 此时由于线程A 已经将锁给释放掉了,线程C 的加锁是能够执行成功的!
  • 但是紧接着,线程B 的 DEL 就到来了,直接将刚刚服务器2 的加锁操作给解锁了!

问题:

  • 为啥上文引入的校验机制没起作用呢?

回答:

  • 上述场景中,服务器1 的线程B 已经执行完 get 操作后,即已经判定完 Redis 上的 key 就是服务器1 所设置的,可以执行 del 操作
  • 此时服务器2 的线程C 便穿插在线程B 的 get 和 del 命令之间,往 Redis 中设置 key-value,进行加锁
  • 从而紧接着服务器1 的线程B 直接执行 del 操作,将服务器2 线程C 在 Redis 中设置的 key 给直接删掉了
  • 归根结底,都是因为 get 和 del 不是原子的所产生的问题

注意:

  • 使用事务,可以解决上述问题,虽然 Redis 的事务比较弱,但还是能够避免插队的
  • 然而在实践中,往往使用更好的方案,即 lua 脚本

具体理解:

  • lua 是一个编程语言,作为 Redis 内嵌的脚本
  • MySQL8 支持 Javascript 作为内嵌语言
  • Vim 支持使用 vumscript / python 作为内嵌语言
  • 但是 lua 语言特别轻量,即实现一个 lua 解释器,其消耗的体积是非常小的
  • 我们可以使用 lua 编写一些逻辑,并将该脚本上传到 Redis 服务器
  • 然后就可以让客户端来控制 Redis 执行上述脚本了

注意:

  • Redis 执行 lua 脚本的过程也是原子的,相当于执行一条命令一样
  • Redis 官方文档也明确说,lua 就属于是 事务 的替代方案
if redis.call('get',KEYS[1]) == ARGV[1] then 
    return redis.call('del',KEYS[1]) 
else 
    return 0 
end;
  • 通过上方 lua 脚本,便能使得 get 和 del 命令执行的原子性
  • ARGV[1]:表示调用脚本给定的参数,此处需传入一个服务器的 id
  • 如果 id 和 get 到的参数能够匹配相等,则进行删除操作

引入过期时间续约(看门狗)

  • 当某一服务器进行加锁时,我们应该给 key 设置多长的过期时间呢?
  • 如果设置的太短,就可能业务逻辑还没执行完,就把锁给释放了
  • 如果设置的太长,就可能导致 锁释放不及时 问题
  • 所以此处我们引入 动态续约

具体理解:

  • 初始情况下,设置一个过期时间(比如设置 1s)就提前在还剩 300s 时,且如果当前任务还没执行完,就把过期时间再续上 1s
  • 等到时间又快到了,如果任务还没执行完,就再续

注意点一:

  • 也不一定就是提前 300ms,此处的数值可灵活调整

注意点二:

  • 如果服务器中途崩溃了,自然就没人负责续约了
  • 此时,锁便能在较短的时间内被自动释放!

注意点三:

  • 这种动态续约 往往需要服务器这边有一个专门的线程来负责
  • 而这个负责的线程就叫做看门狗
  • 看门狗 也是一个比较广义的概念,很多场景均会涉及到这种针对过期时间的操作,从而引入 看门狗

问题:

  • 使用 Redis 作为分布式锁,有没有可能 Redis 本身自己挂掉了呢?

回答:

  • 是可能存在的该情况的
  • 为了确保 Redis 的高可用性,便需要制定一系列的预案和应急措施,通过预案演习,可以在发生问题时更快地进行故障切换和恢复

注意:

  • 在使用 Redis 作为分布式锁的场景中,通常仅涉及到少量的数据,因为锁的目的是控制对共享资源的访问,而不是存储大量数据
  • 因此,备份和恢复 Redis 中的锁相关数据就相对较为轻量

具体理解:

2023.1.31 关于 Redis 分布式锁详解_第5张图片

  • 服务器进行加锁,就是将 key 设置到主节点上
  • 如果主节点挂了,就会有哨兵自动将从节点升级为主节点,进一步的保证刚才的锁仍然可用
  • 但是主节点和从节点之间的数据同步存在一定的延时
  • 可能主节点收到了 set 请求,还没来得及同步给从节点,主节点就先挂了
  • 即使从节点升级成了主节点,但是刚才的加锁对应的数据也不存在

引入 redlock 算法

  • 这是 Redis 作者给出的一个方案,用来解决 Redis 节点挂掉所引发的问题

2023.1.31 关于 Redis 分布式锁详解_第6张图片

  • 此处加锁,就是按照一定的顺序,针对多个独立的 Redis 节点都进行加锁操作!
  • 如果某个节点加不上锁没关系,可能是 Redis 挂掉了,继续给下一个节点加锁即可
  • 如果写入 key 成功的节点个数超过总数的一半,就视为加锁成功
  • 同理进行解锁的时候,也就会把上述节点都设置一遍解锁

注意:

  • 此处需跟 Redis 集群给区分开来
  • Redis 集群主要是用来解决存储空间不足问题,即拓展存储空间
  • 而且因为锁的目的是控制对共享资源的访问,而不是存储大量数据,毫无必要设置集群

结语

  • 上文介绍的只是一个简单的 互斥锁,锁这里还涉及到一些其他的情况
  1. 读写锁
  2. 公平锁(遵守先来后到)
  3. 可重锁
  • 基于 Redis 也可以实现上述这些锁的特性

你可能感兴趣的:(Redis,redis,分布式,数据库)