Skip to content

分布式锁

https://github.com/javagrowing/JGrowing/blob/master/%E5%88%86%E5%B8%83%E5%BC%8F/%E5%86%8D%E6%9C%89%E4%BA%BA%E9%97%AE%E4%BD%A0%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81%EF%BC%8C%E8%BF%99%E7%AF%87%E6%96%87%E7%AB%A0%E6%89%94%E7%BB%99%E4%BB%96.md

为何需要分布式锁

  • 效率:使用分布式锁可以避免不同节点重复相同的工作,这些工作会浪费资源。比如用户付了钱之后有可能不同节点会发出多封短信。
  • 正确性:加分布式锁同样可以避免破坏正确性的发生,如果两个节点在同一条数据上面操作,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失。

在分布式定时任务实现原理中最重要的就是分布式锁,而分布式锁是控制分布式系统之间同步访问共享资源的一种方式,在分布式系统中常常需要协调它们的动作。
如果在不同的系统或者同一个系统的不同主机之间共享一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下便需要用到分布式锁

为什么需要锁? 在分布式场景中,任何操作都可能跨网络,在并发场景下,如果无法保证顺序性,则会产生冲突。
分布式系统的复杂之处在于,在不同进程需要互斥地访问共享资源时的问题

典型的冲突如下:
丢失更新: 一个事务的更新覆盖了其他事务的更新结果,就是所谓的更新丢失
脏读: 当一个事务读取其他完成一半事务的记录时,就会发生脏读取。为了解决这些并发带来的问题,我们需要引入并发控制机制

ZooKeeper 分布式锁

ZooKeeper 是一个分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定在同一个目录下只能有一个唯一文件名。
ZooKeeper 的节点有如下几种类型

  • 永久节点: 节点创建后,不会因为会话失效而消失
  • 临时节点: 与永久节点相反,如果客户端连接失效,则立即删除节点
  • 顺序节点: 在指定创建这类节点时,ZooKeeper 会自动在节点名后加一个数字后缀,并且是有序的

在创建一个节点时,还可以注册一个该节点的监视器(watcher),在节点状态发生改变时,监视器会被触发,同时 ZooKeeper 会向客户端发送一条通知(仅会发送一次)

根据 ZooKeeper 的这些特性,实现分布式锁的步骤如下。

(1) 创建一个锁目录 lock
(2) 如果线程 A 需要获得锁,就在 lock 目录下创建临时顺序节点
(3) 再查询锁目录下所有的子节点,寻找比自己小的兄弟节点,如果不存在,则说明当前线程的顺序号最小,因此可以获得锁
(4) 线程 B 如果也想获取锁,则同样需要查询所有节点,判断自己是不是最小的节点,如果不是,则设置监听比自己值小的节点(只关注比自己值小的节点)
(5) 线程 A 在处理完后,删除自己的节点,线程 B 监听到变更事件,判断自己是不是最小的节点,如果是,则获得锁。

Redis分布式锁

基于 ZooKeeper 实现的分布式锁毕竟吞吐量有限,而基于 Redis 实现的分布式锁的吞吐量至少可以提升一个数量级

Redis 单节点方式实现

这基于 Redis 的 setnx 命令实现的,当缓存里的 key 不存在时,才会设置成功,并且返回 true,否则直接返回 false。
如果返回 true,则表示获取到了锁,否则获取锁失败。
为了防止死锁,我们再使用 expire 命令对这个 key 设置一个超时时间

利用 Redis 实现分布式锁,最简单的是单节点方式。
通过单点实现分布式锁的核心是围绕 SETNX(SET if Not eXists)展开的,当 key 不存在时返回 1,当 key 存在时返回 0.

因为 Redis 是单线程的,所以在 Redis 服务侧不会有线程安全问题。
当返回 1 的时候认为获得锁成功,可以进行相应的业务处理,处理完成后,删除 key,释放锁;当返回 0 时表示失败

很明显,这里存在很多问题

(1) 超时问题如何解决?

如果线程 A 拿到了锁,在处理业务的过程中发生阻塞,例如数据库执行比较慢,或者 Service1 发生故障了,这时候如果没有超时限制,系统将永久锁死,所以 SETNX 可以接受第三个参数,也就是超时时间

这里面存在问题,因为你并不知在超时的情况下,业务到底有没有处理成功,还有没有在继续进行,只是客户端认为超时了,服务端并不知道,所以这里的锁并不是绝对的

(2) 如何释放锁?

直接删除可以吗?答案是否定的。这里要注意另一个问题,因为超时时间是放在 Redis 服务端计算的,如果 Service 1 超时了,但是它自己是不知道自己超时的,除非不断轮询 Redis 确认,不断轮询也是有问题的,因为轮训是有时间差的。
例如,你请求 Redis 的时候还没超时,删除的时候恰好超时了,Service 2 刚拿到锁,Service 1 就误删了 Service 2 的锁造成锁失效

解决方案就是,使用 SETNX 的时候,value 值可以在客户端生成一个随机值,例如 set lock_name random_value
删除的时候根据 key 获取 value,如果相同就删除。
当然,这个地方必须是原子的,否则判断到删除之前还是有可能发生变化的。我们可以通过 Lua 脚本来实现

(3) 单点问题如何解决?

还有另外一个问题,Redis 是单点的,如果 Redis 挂掉了,那么整个系统就会全部崩溃。

有人认为可以用 Master-Slave,但是 Master-Slave 之间是异步传输数据的,也就是不能设定为 Master 和 Slave 都写成功了才返回
Redis-Cluster 也是异步的

RedLock 实现方案

Redlock 是 Redis 的作者 Antirez 在 Redis 官网中给出的一种基于 Redis 的分布式锁方案。
直白点说,就是 N(通常是 5)个独立的 Redis 节点同时使用 SETNX。
如果多数节点成功,就拿到了锁,这样就可以允许少数(如 2)个节点不可用。整个取锁、释放锁的操作和单节点类似

是不是这样就完美了呢?当然不是

(1) 重启问题导致锁失效

假设一共有 5 个节点(A/B/C/D/E),Service 1 成功获取了锁,注意这里 Service 1 在 A/B/C 上 SETNX 成功,但是并没有在 D/E 上成功。
假设 C 节点挂掉后又恰巧重启了,C 节点并没有持久化,这时候 Service 2 也可能已经锁住 C/D/E, 导致锁失效

有人说,那 C 持久化不就行了吗?实际上设置为同步的持久化方式对性能影响比较大,也就是常说的始终追加同步,如果是机械硬盘,吞吐量可能会从 MB 级降低到 KB 级。
有人说用固态硬盘不就解决问题了吗?使用固态硬盘,吞吐量确实能到万级,但是大量而频繁的写入容易导致写入放大

这个问题的解决方案也非常简单,就是延迟重启(Delayed Restarts),即等到挂掉节点上的所有锁都过期后再重启。
重启后,以前的锁都已经失效了,参考租约机制原理

(2) 任何时候都需要全部删除

如果一个节点获取锁成功了,那么四个节点都 SETNX 成功。
一个失败了,失败的情况如果是发起请求成功,在返回 ACK 的时候失败,这时客户端会认为是失败了,而删除锁的时候没有删除这个节点,这就导致过期之前,这个节点获取锁一直失败,所以正确的做法是无论 SETNX 成功还是失败,都应该执行一次删除操作

基于数据库的实现方式

该实现方式完全依靠数据库的唯一索引来实现,当想要获得锁时,便向数据库中插入一条记录,成功插入则获得锁,执行完成后删除对应的行数据来释放锁