分布式锁通常有两种实现方式:
- 基于 Redis 实现分布式锁。
- 基于 Zookeeper 实现分布式锁。
基于 Redis 实现
基于 setnx 实现分布式锁
方法一:使用 redis 的 setnx 命令。缺点:锁没有过期时间。
方法二:使用 redis 的 set ex nx,添加锁失效时间,使用一条命令保证原子性。
但以上两个直接基于 redis-cli 命令实现的方法存在两个问题:
-
若时间到了后没能执行完操作,锁也会被释放。
-
锁有可能会被别的线程释放,比如强制删除这个锁的 key。
解决方法:
-
针对线程还没执行完任务锁就被超时释放的情况,可以开启一个守护线程,定期续期锁,Redisson 封装了这一操作,就是看门狗机制。
-
针对锁被别的线程释放,加锁时设置一个唯一 id 表明锁的持有者,释放锁时检查当前线程是否是锁的持有者, 其中,由于释放锁时要先 get 判断是否是锁的持有者,再 del 释放锁,因此使用 lua 脚本保证操作的原子性。
具体流程如下:
- 加锁:
set $lock $unique_id ex $expire_time nx。 - 操作共享资源:没操作完之前,定时给锁续期。
- 释放锁:Lua 脚本,先 GET 判断锁是否属于自己,再 DEL 释放锁。
即便如此,基于 setnx 实现的分布式锁还是存在以下缺点:
- 不可重入。
- 不可重试:获取锁只尝试一次就返回 false,没有重试机制。
基于 Redisson 实现分布式锁
Redisson 是一个基于 Redis 的 Java 客户端,引入了看门狗的机制。
只要线程一加锁成功,就会启动一个 watch dog 看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁 key 的生存时间。
Redisson 的加锁、设置过期时间等操作都是基于 Lua 脚本完成的,使用 lua 脚本可以保证命令执行的原子性。
可重入原理
Redisson 通过利用 Hash 结构实现锁的可重入。其中,key 是锁名称,field 是线程 id,value 是一个数值,表示线程重入次数。
在线程获取锁时先判断当前锁对应 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 的。
核心思想:当客户端要获取锁,则创建节点,使用完锁,则删除该节点。
- 客户端获取锁时,在 lock 节点下创建临时顺序节点。
- 获取 lock 下的所有子节点,如果客户端发现自己创建的子节点序号最小,就认为该客户端获取到了锁,使用完锁后将该节点删除。
- 如果发现自己创建的子节点序号不是最小的,说明自己还没获取到锁,此时找到比自己小的那个子节点,监听删除事件。
- 如果发现比自己小的节点被删除,客户端的 Watcher 会收到通知,再次判断自己的节点是否是当前节点中最小的,如果是则获取到锁,如果不是重复上述步骤。