目录
引言
- 在一个分布式系统中,可能会涉及到多个节点访问同一个公共资源的情况
- 此时就需要通过锁来进行互斥控制,从而避免出现类似于 线程安全 的问题
- 而像 Java 的 synchronized 这样的锁都是只能在当前进程中生效,在分布式系统的多个进程多个主机的场景下就无能为力了
- 此时就需要用到分布式锁
通俗理解:
- 多个线程并发执行时,执行的先后顺序是不确定的,具有随机性
- 从而我们可以引入 Java 的 synchronized 来解决多个线程并发执行的随机性
- 但是 synchronized 本质上只能在一个进程内部生效
- 在分布式系统中,具有很多进程,即每个服务器均为一个独立进程
- 因此 synchronized 难以对 分布式系统中的多个进程之间产生制约
- 再加之分布式系统中,多个进程之间的执行顺序也是不确定的, 具有随机性
- 为了保证程序在任意执行顺序下,其执行逻辑都是正确的
- 从而我们引入了 分布式锁
分布式锁
实例引入
- 此处模拟一个购买车票的场景
- 买票逻辑:先查询余票,如果余票 > 0,则设置余票 -= 1
- 假设此时客户端A 先执行查询 车次1 的余票,发现仅剩余 1 张
- 在 买票服务器A 即将执行剩余票数 -= 1 过程之前
- 此时客户端B 也执行查询 车次1 的余票,发现也仅剩余 1 张
- 此时 买票服务器B 也会执行剩余票数 -= 1 的过程
注意:
- 上述过程就属于 超卖,即 1张票卖给了 2个人!
引入分布式锁
- 所谓分布式锁,本质上是使用 一个或一组 单独的服务器程序,通过使用一个键值对来标识锁的状态,来给其他的服务器提供 加锁 这样的服务
注意:
- Redis 是一种典型的可以用来实现分布式锁的方案,但是不是唯一的一种
- 业界可能也会使用 MySQL / zookeeper 这样的组件来实现分布式锁的效果
- 如上图所示,买票服务器在执行 剩余票数 -= 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 执行解锁" 的情况,我们可以引入校验机制
具体思路:
- 给服务器编号,每个服务器均有一个自己的身份标识
- 进行加锁时,设置 key-value 对应着要针对哪个资源加锁(比如车次),value 便可以存储用来存储 服务器编号,标识出当前这个锁是哪个服务器加上的
- 后续在解锁时,先查询一下这个锁对应的服务器编号,然后判定一下这个编号是否就是当前执行解锁的服务器编号,如果是,则真正执行 del,如果不是,就失败
注意点一:
- 上述的校验操作为 服务器 需要完成的逻辑
- 通过上述校验,便可以有效避免 "误解锁"
注意点二:
- 上述服务器都是我们自己写的代码
- 这些代码的初心,当然是为了避免出现上述问题,而不会说服务器故意搞破坏
引入 lua 脚本
- 在解锁时,先查询判定,再进行 del
- 此时这两步操作就可能会出现问题,因为这两步操作不是原子的
- 一个服务器内部,是可以同时处理多个请求的
- 此时就可能出现同一个服务器,两个线程均在执行上述解锁操作
- 如上图所示,看起来重复执行 DEL 好像问题不大
- 因为使用 DEL 命令删除一个不存在的 key 时,会直接返回 0,表示没有删除任何键
- 是 Redis 的一种正常行为,不会引发错误
- 如上图所示,此时引入一个新服务器,要来执行加锁,就可能出现问题了
- 在线程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 中的锁相关数据就相对较为轻量
具体理解:
- 服务器进行加锁,就是将 key 设置到主节点上
- 如果主节点挂了,就会有哨兵自动将从节点升级为主节点,进一步的保证刚才的锁仍然可用
- 但是主节点和从节点之间的数据同步存在一定的延时
- 可能主节点收到了 set 请求,还没来得及同步给从节点,主节点就先挂了
- 即使从节点升级成了主节点,但是刚才的加锁对应的数据也不存在
引入 redlock 算法
- 这是 Redis 作者给出的一个方案,用来解决 Redis 节点挂掉所引发的问题
- 此处加锁,就是按照一定的顺序,针对多个独立的 Redis 节点都进行加锁操作!
- 如果某个节点加不上锁没关系,可能是 Redis 挂掉了,继续给下一个节点加锁即可
- 如果写入 key 成功的节点个数超过总数的一半,就视为加锁成功
- 同理进行解锁的时候,也就会把上述节点都设置一遍解锁
注意:
- 此处需跟 Redis 集群给区分开来
- Redis 集群主要是用来解决存储空间不足问题,即拓展存储空间
- 而且因为锁的目的是控制对共享资源的访问,而不是存储大量数据,毫无必要设置集群
结语
- 上文介绍的只是一个简单的 互斥锁,锁这里还涉及到一些其他的情况
- 读写锁
- 公平锁(遵守先来后到)
- 可重锁
- 基于 Redis 也可以实现上述这些锁的特性