Redisson分布锁原理分析及源码解读
本文源码解读基于Redisson 3.18.0 版本
Redisson分布锁实现原理
Redisson锁实现基本原理大致如下图所示:
先明确两个时间定义:
- waitTime 等待时间。客户端尝试获取锁时最大等待时间,超过这个等待时间必然返回获取锁失败。
- leaseTime 锁的租期。客户端可持有锁的时间,超过这个时间锁自动过期。
竞争锁的客户端执行Lua脚本获取锁,如果获取失败,则订阅解锁消息,并挂起线程。
持有锁的客户端执行Lua脚本解锁,删除锁的同时往解锁消息通道发送解锁指令,Redis会广播解锁消息到所有订阅者。
等待锁的客户端收到解锁消息或者线程挂起时间超过锁超时时间(leaseTime)时,客户端会重新尝试获取分布式锁,如果仍然获不到,则线程再度进入阻塞状态,等待下一次解锁消息(新的锁持有者释放锁)到达或者锁超时。
等待锁的客户端如果等待时间超出了最大可等待时间(waitTime),会直接返回锁获取失败。
无论成功还是失败,等待锁的客户端线程最后都会取消订阅解锁消息。
Redisson客户端加锁流程
RedissonLock.tryLock(long waitTime, long leaseTime, TimeUnit unit)
1.尝试获取分布式锁
tryLock方法为尝试获取锁的方法入口,返回ttl表示锁剩余超时时间,如果返回null,表示无人占有锁,当前线程获取锁成功,如果获取锁成功,客户端会启动一个看门狗线程,来自动为锁续期(后面会讲到看门狗作用)。tryLock方法过程时序图如下:
加锁核心为图中红色字体部份的tryLockInnerAsync方法,他通过evalWriteAsync执行了一段lua脚本.
加锁Lua脚本参数说明
- KEYS[1]:锁名称
- ARGV[1]:锁过期时间
- ARGV[2]:客户端唯一标识(Client UUID+threadId).
加锁Lua脚本代码分析
# 如果锁不存在
if (redis.call('exists', KEYS[1]) == 0) then
# hash结构,锁名称为key,线程唯一标识为itemKey,itemValue为一个计数器。支持相同客户端线程可重入,每次加锁计数器+1.
redis.call('hincrby', KEYS[1], ARGV[2], 1);
# 设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
# 成功获取锁返回null
return nil;
end ;
#如果是当前线程占有分布式锁,允许重入锁
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
# 将锁重入计数器自增1.
redis.call('hincrby', KEYS[1], ARGV[2], 1);
# 设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
# 成功获取锁返回null
return nil;
end ;
#如果获取不到锁,返回锁剩余过期时间,方便后续代码设置等待超时时间
return redis.call('pttl', KEYS[1]);
2.获取锁失败,订阅解锁消息channel
图1处:判断获取锁是否等待(waitTime)超时,如果等待超时则直接返回获取锁失败。
图2处:如果等待未超时,则尝试订阅解锁channel。
图3处:获取ReissonLockEntry(获取成功表示订阅成功),超时时长设置为当前剩余的等待时间(waitTime)。 如果获取ReissonLockEntry超时,终止并取消订阅解锁消息channel,获取锁失败。
3.收到解锁通知,重新竞争锁
此处的代码紧接上面,因为图片太大故切成两块了:
这里主要分析红色圆圈处的while代码块。这里的while代码是一个循环尝试,直到获取锁成功或者超时失败。
解锁消息广播给所有锁竞争的客户端,收到解锁消息后,客户端会有一个线程去重新竞争锁。
图1处:和第一步获取锁的代码一模一样,尝试执行lua脚本获取锁
图2处:此处使用信号量(java.util.concurrent.Semaphore)处理一个客户端进程中多个线程竞争分布式锁的场景。
当有解锁消息到达时,不需要恢复所有挂起的线程一起去竞争分布式锁,只需要唤醒一个线程去和集群中其它节点抢夺就可以了。这样好处是显而易见的,避免了大量的无效Redis请求,因为锁在集群中同一时刻只会有一个线程能持有。
Redisson使用信号量控制,每次收到解锁消息仅释放一个信号,只允许一个线程解除阻塞状态,去竞争锁(参考LockPubSub类的onMessage方法)。
当然,如果线程等待超时(超过当前锁剩余过期时间ttl或者超过剩余等待时间time),那么线程也会重新加入锁竞争行列,重新尝试获取锁。这点很重要,这样可以保证即时解锁消息通知失败,客户端也能在超时后重新尝试获取锁或者快速失败,避免无效等待。
实际上在比较旧一些的版本(3.13.1之前,参考 github.com/redisson/re… )中,如果解锁消息因为网络原因而丢失,客户端总会因等待超时而最终失败。
Redisson客户端解锁流程
RedissonLock.unlockInnerAsync(long threadId)
解锁代码相对简单,需要关注的是下图红框中的代码,解锁成功后,会广播解锁消息。
解锁lua脚本代码说明如下:
解锁脚本参数说明
- KEYS[1]:锁名称
- KEYS[2]:解锁消息通道名称
- ARGV[1]:解锁消息
- ARGV[2]:锁续租时间(看门狗超时时间,默认为30秒)
- ARGV[3]:客户端唯一标识
解锁脚本代码分析
# 判断锁是否为自己持有,不为自己持有则不允许解锁。
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end ;
# 由于支持可重入,所以这里需要判断是否完全解锁,每解一次锁重入计数器减1.
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
# 如果锁还没有完全解除,则延长锁租用时间
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
# 删除锁
redis.call('del', KEYS[1]);
# 广播解锁消息
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end ;
return nil;
Redisson看门狗机制
如果锁中的业务处理时间比较长,那么可能一种异常情况:即业务还未处理完毕,锁就提前过期了。Redisson针对这个问题的解决办法,是提供一个守护线程,定时检查锁状态。如果锁快要过期了,客户端还占有锁,那么就自动给锁续期,延长锁的过期时间。
守护线程轮询周期为:internalLockLeaseTime/3。internalLockLeaseTime的默认值由lockWatchdogTimeout来配置。默认值为30秒。也就是说默认情况下,守护线程每10秒检查续期。
续期靠执行下面这段lua脚本实现,每次续期时间由lockWatchdogTimeout配置项决定,默认30秒。
无PubSub锁-RedissonSpinLock
由于Redis PubSub的不可靠性,消息丢失几率是相对较高的。所以在RedissonLock的实现中,客户端等待解锁消息时都会设置超时时间,一旦超时客户端线程也会解除阻塞状态,重新进入锁竞争状态。
其实仔细想想,这里即使没有PubSub,锁的获取通过不断的自旋(重试)一样可以保证分布式锁的可靠性。只不过如果每次阻塞挂起的时间都设置为锁超时时间,会影响性能,因为大多数场景下业务处理时间要远远快于锁的超时时间。那么我们能不能考虑像RocketMQ消费者重试机制一样,一开始重试间隔时间很短,后续逐步增加重试间隔了?
其实官方已经有了对应的实现了,它就是RedissonSpinLock。来看看RedissonSpinLock是如何实现加锁的。
这代码看着可清爽多了...
RedissonSpinLock的核心就是红框处的代码,每次自旋竞争锁前先休眠一段时间,这个时间间隔由LockOptions.BackOffPolicy来指定。
官方目前提供了两种BackOffPolicy的实现:
- ConstantBackOffPolicy 指定一个常量间隔,每次自旋休眠固定时长
- ExponentialBackOffPolicy 指定初始delay时长,每次按倍数multiplier增长,同时加个以失败次数fails为种子的随机整数(减少本地线程之间竞争)。最大休眠时长不能超过maxDelay
该策略默认值:
- initialDelay=1
- multiplier = 2
- maxDelay = 128
不要觉得自旋多重试了几次就会对性能有多大影响,要知道Redis每秒QPS可是能达到10W+级别的,区区几次重试完全可以忽略不计。不信可以瞅瞅官方的基准测试 redis.io/docs/manage…
关于主从切换锁丢失问题与红锁RedLock
有一种极端场景,客户端A尝试在Redis Master节点上锁,客户端A成功获得锁的瞬间,锁数据还没有同步至Slave节点。这时Master挂了,于是发生主从切换,其它客户端连接到Slave节点尝试抢占锁,由于Slave没有客户端A的上锁信息。自然又会有一个新的客户端B抢到锁,此时就会出现两个客户端同时拥有分布式锁的奇葩现像。
针对上述问题,Redis作者曾经提出了Redlock方案。Redisson中也有相应的实现,不过现在最新的版本已经不再建议使用。
因为现在加锁操作实现,会等所有从节点数据同步了才算加锁成功。这样的话就可以保证主从切换锁不会丢失了。那么Redisson是怎么实现主从同步复制得了?对Redis比较了解的同学应该很快想到了Wait命令。
Redis WAIT 命令用来阻塞当前客户端,直到所有先前的写入命令成功传输并且至少由指定数量的从节点复制完成。如果执行超过超时时间(以毫秒为单位),则即使尚未完成指定数量的从结点复制,该命令也会返回。
跟踪到CommandBatchService.executeAsync方法,发现果然通过在加锁命令后添加Redis的Wait命令,实现Master-Slave同步复制。
Wait命令基本语法:WAIT numreplicas timeout
上图中红框两处即为同步从节点数量和超时时间。 跟踪源代码可知,同步节点数量为全部可用Slave的数量,所以必须所有存活的Slave都同步复制完成。而超时时间设置则为1秒。
转载自:https://juejin.cn/post/7168802584684134413