分布式锁

86 阅读4分钟

分布式锁通常有两种实现方式:

  • 基于 Redis 实现分布式锁。
  • 基于 Zookeeper 实现分布式锁。

基于 Redis 实现

基于 setnx 实现分布式锁

方法一:使用 redis 的 setnx 命令。缺点:锁没有过期时间

方法二:使用 redis 的 set ex nx添加锁失效时间,使用一条命令保证原子性

但以上两个直接基于 redis-cli 命令实现的方法存在两个问题

  1. 时间到了后没能执行完操作,锁也会被释放

  2. 锁有可能会被别的线程释放,比如强制删除这个锁的 key。

解决方法

  1. 针对线程还没执行完任务锁就被超时释放的情况,可以开启一个守护线程,定期续期锁,Redisson 封装了这一操作,就是看门狗机制。

  2. 针对锁被别的线程释放,加锁时设置一个唯一 id 表明锁的持有者,释放锁时检查当前线程是否是锁的持有者, 其中,由于释放锁时要先 get 判断是否是锁的持有者,再 del 释放锁,因此使用 lua 脚本保证操作的原子性。

具体流程如下: image.png

  1. 加锁set $lock $unique_id ex $expire_time nx
  2. 操作共享资源:没操作完之前,定时给锁续期。
  3. 释放锁Lua 脚本,先 GET 判断锁是否属于自己,再 DEL 释放锁。

即便如此,基于 setnx 实现的分布式锁还是存在以下缺点:

  • 不可重入。
  • 不可重试:获取锁只尝试一次就返回 false,没有重试机制。

基于 Redisson 实现分布式锁

Redisson 是一个基于 Redis 的 Java 客户端,引入了看门狗的机制。

只要线程一加锁成功,就会启动一个 watch dog 看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁 key 的生存时间

Redisson 的加锁、设置过期时间等操作都是基于 Lua 脚本完成的,使用 lua 脚本可以保证命令执行的原子性。

可重入原理

Redisson 通过利用 Hash 结构实现锁的可重入。其中,key 是锁名称,field 是线程 idvalue 是一个数值,表示线程重入次数

在线程获取锁时先判断当前锁对应 value 的 field 是否是当前线程 id,如果是相同的线程,线程获得锁,并把 value 值加1,在线程释放锁时将 value 值减1,value 值为0时再删除锁。

这段逻辑在 Redisson 内部是通过 Lua 脚本实现的,保证多条命令操作的原子性。

可重试原理

在 API 层面,Redisson 通过trylock(long time, TimeUnit unit)方法实现可重试,该方法可以指定锁的等待时间,当线程获取不到锁时,会在等待时间内不断尝试获取锁。

RedLock

在真实环境中,一般都会使用集群部署 Redis,由于主从复制的延迟性,可能会出现问题:

主节点在加锁完成后宕机了,此时从节点还没同步这个锁信息,哨兵选出一个从节点作为新的主节点,此时这个锁信息就丢失了,于是其他客户端也可以再次获得锁操作共享资源,造成冲突。

因此引入了 RedLock 用于保证在集群模式下加锁的可靠性:加锁时在多个 Redis 节点上都尝试加锁,只有当超过一半的节点都加锁成功,并且加锁后的时间没有超过锁的过期时间,才算加锁成功

但使用 RedLock 的性能太低一般不推荐使用 RedLock。如果一定要保证数据的强一致性,可以使用 ZooKeeper 实现分布式锁。

基于 ZooKeeper 实现分布式锁

ZooKeeper 保证了数据的强一致性,即 ZooKeeper 是 CP 的。

核心思想:当客户端要获取锁,则创建节点,使用完锁,则删除该节点。

  1. 客户端获取锁时,在 lock 节点下创建临时顺序节点。
  2. 获取 lock 下的所有子节点,如果客户端发现自己创建的子节点序号最小,就认为该客户端获取到了锁,使用完锁后将该节点删除。
  3. 如果发现自己创建的子节点序号不是最小的,说明自己还没获取到锁,此时找到比自己小的那个子节点监听删除事件
  4. 如果发现比自己小的节点被删除,客户端的 Watcher 会收到通知,再次判断自己的节点是否是当前节点中最小的,如果是则获取到锁,如果不是重复上述步骤。

分布式锁选型

image.png