掘金 后端 ( ) • 2022-11-23 10:13

highlight: a11y-dark theme: github

本文源码解读基于Redisson 3.18.0 版本

Redisson分布锁实现原理

Redisson锁实现基本原理大致如下图所示: image.png

客户端执行Lua脚本去获取锁,如果获取失败,则订阅解锁消息,并挂起线程。

客户端解锁时执行一段Lua脚本,删除锁的同时往解锁消息通道发送解锁指令,Redis会广播解锁消息到所有订阅的客户端。

当客户端收到解锁消息或者线程挂起时间超过锁超时时间(leaseTime)时,客户端会重新尝试获取分布式锁,如果仍然获不到,则线程再度进入阻塞状态,等待解锁消息到达或者锁超时。

如果等待时间超出了最大可等待时间(waitTime),会直接返回锁获取失败。

无论成功还是失败,当前客户端线程最后都会取消订阅解锁消息。

Redisson客户端加锁流程

1.尝试获取分布式锁

以tryLock(long waitTime, long leaseTime, TimeUnit unit)方法为例:

  • waitTime 获取锁时最大等待时间
  • leaseTime 获得锁后,设置的锁超时时间

tryLock方法为尝试获取锁的方法入口,返回ttl表示锁剩余超时时间,如果返回null,表示无人占有锁,当前线程获取锁成功,如果获取锁成功,客户端会启动一个看门狗线程,来自动为锁续期(后面会讲到看门狗作用)。tryLock方法过程时序图如下:

image.png

核心为图中红色部份的tryLockInnerAsync方法,他通过evalWriteAsync执行了一段lua脚本如下.

  • KEYS[1]:锁名称
  • ARGV[1]:锁过期时间
  • ARGV[2]:客户端唯一标识(Client UUID+threadId).
# 如果锁不存在 
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订阅,获取锁失败。

image.png

3.收到解锁通知,重新竞争锁

此处的代码紧接上面,因为图片太大故切成两块了:

image.png 这里主要分析红色圆圈处的while代码块。这里的while代码是一个循环尝试,直到获取锁成功或者超时失败。 解锁消息是广播给所有锁竞争的客户端的,收到解锁消息后,所有的客户端进程都会有一个线程去重新竞争锁。

image.png

图1处:和第一步获取锁的代码一模一样,尝试执行lua脚本获取锁

图2处:此处使用信号量处理一个客户端进程中有多个线程竞争分布式锁的场景。

当有解锁消息到达时,不需要所有挂起线程都恢复,一起去竞争分布式锁,只需要唤醒一个线程去抢夺就可以了。Redisson使用信号量控制,每次收到解锁消息仅释放一个信号,只允许一个线程解除阻塞状态,去竞争锁(参考LockPubSub类的onMessage方法),当然,如果线程等待超时(超过锁过期时间)也会重新加入锁竞争行列。

image.png

由此代码分析可知,即时解锁消息通知失败。客户端也能在锁超时后重新尝试获取锁。实际上在比较旧一些的版本(3.13.1之前,参考 https://github.com/redisson/redisson/issues/3067 )中,如果解锁消息因为网络原因而丢失,客户端总会因等待超时而失败。

Redisson客户端解锁流程

解锁代码比较简单,其核心逻辑在RedissonLock.unlockInnerAsync(long threadId)方法里

image.png

unlockInnerAsync方法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针对这个问题的解决办法,是提供一个守护线程,定时检查锁状态。如果锁快要过期了,客户端还占有锁,那么就自动给锁续期,延长锁的过期时间。

image.png

守护线程轮询周期为:internalLockLeaseTime/3。internalLockLeaseTime的默认值由lockWatchdogTimeout来配置。默认值为30秒。也就是说默认情况下,守护线程每10秒检查续期。

image.png

续期靠执行下面这段lua脚本实现,每次续期时间由lockWatchdogTimeout配置项决定,默认30秒。 image.png

无PubSub锁-RedissonSpinLock

由于Redis PubSub的不可靠性,消息丢失几率是相对较高的。所以在RedissonLock的实现中,客户端等待解锁消息时都会设置超时时间,一旦超时客户端线程也会解除阻塞状态,重新进入锁竞争状态。

其实仔细想想,这里即使没有PubSub,锁的获取通过不断的自旋(重试)一样可以保证分布式锁的可靠性。只不过如果每次阻塞挂起的时间都设置为锁超时时间,会影响性能,因为大多数场景下业务处理时间要远远快于锁的超时时间。那么我们能不能考虑像RocketMQ消费者重试机制一样,一开始重试间隔时间很短,后续逐步增加重试间隔了?

其实官方已经有了对应的实现了,它就是RedissonSpinLock。来看看RedissonSpinLock是如何实现加锁的。

image.png 这代码看着可清爽多了...

RedissonSpinLock的核心就是红框处的代码,每次自旋竞争锁前先休眠一段时间,这个时间间隔由LockOptions.BackOffPolicy来指定。

官方目前提供了两种BackOffPolicy的实现:

  • ConstantBackOffPolicy 指定一个常量间隔,每次自旋休眠固定时长
  • ExponentialBackOffPolicy

image.png

每次按倍数multiplier增长,同时加个失败次数fails为种子的随机整数。最大值不能超过指定值-maxDelay 默认值:

  • initialDelay=1
  • multiplier = 2
  • maxDelay = 128

不要觉得自旋多重试了几次就会对性能有多大影响,要知道Redis每秒QPS可是能达到10W+级别的,区区几次重试完全可以忽略不计。不信可以瞅瞅官方的基准测试 https://redis.io/docs/management/optimization/benchmarks/

关于红锁

有一种极端场景,客户端A尝试在Redis Master节点上锁,客户端A成功获得锁的瞬间,锁数据还没有同步至Slave节点。这时Master挂了,于是发生主从切换,其它客户端连接到Slave节点尝试抢占锁,由于Slave没有客户端A的上锁信息。自然又会有一个新的客户端B抢到锁,此时就会出现两个客户端同时拥有分布式锁的奇葩现像。

针对上述问题,Redis作者曾经提出了Redlock方案。Redisson中也有相应的实现,不过现在最新的版本已经不再建议使用。

image.png

因为现在基础的加锁操作,会广播到所有从节点,等所有从节点同步了才算加锁成功。

参考CommandBatchService.executeAsync方法代码,底层实现是通过Redis Wait命令,实现Master-Slave同步复制。

image.png