Redisson分布式锁-锁的可重入、可重试、WatchDog超时续约、multLock联锁(一文全讲透,超详细!!!)
本文涉及到使用Redis实现基础分布式锁以及Lua脚本的内容,如有需要可以先参考博主的上一篇文章:Redis实现-优惠卷秒杀(基础版本)
一、功能介绍
(1)前面分布式锁存在的问题
- 在JDK当中就存在一种可重入锁ReentrantLock,可重入指的是在同一线程当中可以多次获取同一把锁;
- 之前实现的分布式锁是一种非阻塞式不可重试的锁,而在很多业务中第一时间获取锁失败是可以进行等待再重试的;
- 超时释放的逻辑不严谨,不可以简单设定一个超时时间即可;
- Redis的主从模式也可以理解为读写分离模式,也就是说Redis会有一个主节点与多个从节点,执行写操作时是访问主节点,执行读操作时访问的是从节点,同时需要主节点同步数据给从节点,保证主从数据一致性,而执行set获取锁操作就是一种写操作,当我们在主节点执行该动作时假如主节点宕机,没有完成数据同步,这时其他线程再从另一个节点上获取锁,可能就会出现线程安全问题。
(2)Redisson介绍
"在Redis基础上实现的Java驻内存虚拟网格"意思是:在Redis基础上实现的一个分布式工具集合,也就是在分布式系统下可能用到的各种各样的工具。
二、可重入锁快速入门
tryLock方法是一个阻塞式动作,执行该方法就可以尝试去获取锁,而在设置的最大等待时间内,若发生获取锁失败,就会等待一小段时间并重试,在超过该最大等待时间后都没有拿到锁才会返回false,也就是一种重试机制。
(1)代码改造
三、可重入锁的实现原理
在使用原来自行实现的分布式锁时,数据类型是String,仅可存放锁名称lock以及线程id的key-value数据值,不能实现可重入功能是因为我们获取锁主要依靠setnx命令,所以即便value中的线程id相同,也不能去重复set
要想实现可重入就可以参考JDK当中的可重入锁ReentrantLock的实现原理:简单来说就是在首次获取锁是使用setnx命令进行设置,在后续获取锁时若发现锁已存在则使用get命令去判断线程id是否相同,若相同则也可以成功取到,并且同时去记录锁的重入次数。
那么现在使用的String类型数据结构就无法满足再去存放锁的重入次数的数据的要求,就可以改用hash类型。
这种可重入锁的释放动作不能去直接删除整个数据,而是要去将重入次数减一,当重入次数减至0时就可以删除该锁,也就需要在每次释放锁时都去判断一下锁的重入次数是否为0了。
注意:hash类型的命令与先前String类型的命令不同,也就是不能直接使用setnx ex命令,需要去改变。
(1)完整执行流程
因为这里流程较为复杂,使用的Redis命令较多,为了保证逻辑的原子性就要使用Lua脚本来编写。
(2)Lua脚本编写
①获取锁
②释放锁
四、锁重试和WatchDog机制
(1)源码分析
①锁重试机制
在使用tryLock方法进行传参时发现同时存在两种实现方式,一种是直接指定超时时间与锁自动释放时间;一种是只指定超时时间。
跟入tryLock内的tryAcquire获取锁方法
当我们调用tryLock方法时不去指定锁自动释放时间,只指定超时时间,那么在源码中就会自动为leaseTime值赋默认值为-1。
在该方法中首先会去判断leaseTime是否为-1,若不是则会去走默认获取锁的方法tryLockInnerAsync;若是则会借助getLockWatchdogTimeout方法 (WatchDog意为看门狗)来为该锁初始化一个超时时间–30s。
跟入该tryLockInnerAsync方法,可以看到当获取锁成功时返回的是null,获取失败时返回的是锁的剩余时间ttl
获取到返回的锁剩余时间ttl后,若ttl不为null,那么就会去计算超时剩余时间,若时间仍有余则可以继续去尝试获取锁。
而在这里使用了subscribe订阅方法,用于订阅其他线程释放锁的信息,在Redis中的publish命令就是用于发布消息通知。
假设在超时剩余时间结束后还没有接收到锁释放通知,就会去取消订阅,并且返回false;
相反假设在超时剩余时间前接收到了锁释放通知,且超时剩余时间仍有余,就会再次去执行跟上面类似的重试获取锁的逻辑。在每次执行完这段逻辑后假设还没有获取到锁,但是超时剩余时间仍有余,那么就可以再次循环执行。
但是这里与上方的差别在于:这里采用的是监听信号量的方案,假设在其他线程中进行了锁释放,那么就会发出一个信号,并且在这边去尝试获取信号。
但是尝试获取信号也会存在一个最大等待时间,如果超过这个时间依然没有拿到锁则会返回false。我们也通过subscribeFuture这个Future对象来实现定时获取,也就是在等待指定时间后再去尝试获取锁,类似阻塞的原理,避免无效尝试,降低CPU消耗。
②锁超时续约
假设我们获取锁成功并得到锁的剩余有效期ttl,但是此时有业务阻塞了,导致ttl到期,其他线程捕捉到这个信号就会立刻再去获取锁,那么就会出现线程安全问题了。也就是说我们必须要确定锁是因为业务执行完释放的,而不是因为阻塞释放。
前面我们已经了解到当不直接指定锁超时时间时,会利用WatchDog看门锁机制来为该锁加上一个默认超时时间为30s。那么当获得到这个Future对象后,就会去判断锁的剩余有效期是否为null,若为null则会对锁剩余时间进行续约动作。
在该方法中每隔锁内部施放时间的三分之一 (internalLockLeaseTime / 3L,在这里可以理解为看门狗时间的三分之一,也就是30 / 3 = 10s),就会去自动刷新一次有效期。
也就是重置锁的有效期时间
因为在在方法该方法中又去递归调用自身,所以实现的是无限续约,也就可以理解为永不过期。
当锁释放时,才会去取消这个锁自动更新任务。
(2)总结
五、multLock
(1)主从一致性问题的产生原因
主从一致性导致的锁失效问题:
Redis的主从模式也可以理解为读写分离模式,也就是说Redis会有一个主节点与多个从节点,执行写操作时是访问主节点,执行读操作时访问的是从节点,同时需要主节点同步数据给从节点,保证主从数据一致性。而执行set获取锁操作就是一种写操作,当我们在主节点获取到锁后假如主节点发生宕机,没有完成数据同步,那么Redis的哨兵机制就会从其他从节点中选出一个新的主节点,但是这时从节点中没有该锁的数据,就相当于发生了锁失效,其他线程再来获取锁也是同样可以获取成功的,也就会出现线程安全问题。
Redis中解决该问题的思路:打破主从节点的思路,在每个节点上都保存该锁,并且当Java应用想要去获取锁时必须依次向每个节点都去获取锁,必须从每个节点处都能获取到锁、都保存了锁的标识,才算获取锁成功。
而且这种方案还同时保留了主从一致性机制,每个节点都可以去形成自己的主从关系,即便在一个主从节点上发生了不一致,只要其他两个节点上不发生问题那么最后都是可以健康运行的。
这套方案保留了主从一致性机制,确保了整个Redis集群的高可用特性,同时避免了主从一致性引发的锁失效问题。
所以multLock又称为"联锁"。
(2)代码实现
首先去准备多台Redis节点,并在Java应用中完成配置客户端
注入三个Redis客户端并获取联锁
运行测试,发现在三个节点上都保存了锁