看门狗 | 分布式锁架构设计方案 -01

用户头像
高翔龙
关注
发布于: 2020 年 08 月 23 日
看门狗 | 分布式锁架构设计方案-01

前言

在实际的开发过程中,我们经常会使用到分布式锁来解决资源访问时的互斥性问题,并且在大部分场景下,我们往往都会选择基于Redis来实现一个分布式锁。尽管选择Zookeeper,甚至是基于RDBMS也能够快速实现一个满足业务场景的分布式锁,但为何我们却偏偏执着于Redis呢?简而言之,选择Redis主要是因为如下3个原因:

  • 高性能;

  • 原子操作

  • 实现方便、简单,以及成本低。



虽然使用Redis提供的SETNX命令就可以很轻松的实现一个分布式锁效果,但这样的做法却存在很大问题。假设某个节点在执行完SETNX命令后宕机了,那么锁资源将无法释放,这会导致其它节点永远无法加锁而hang住。为了解决这个问题,我们往往会选择在程序中执行完SETNX命令之后再紧跟EXPIRE或PEXPIRE命令来避免产生死锁;当然,在程序不出现任何异常,或者网络不发生抖动的时候,原则上是可行的,但作为一名架构师或技术专家,在设计方案时,永远需要把最坏的情况考虑进去。由于添加过期时间和加锁操作是2条命令,也就是说,我们需要顺序向Redis发送2次网络请求,因此,原子性得不到保证,一旦出现异常情况导致EXPIRE或PEXPIRE命令无法顺利执行,仍然会导致锁资源得不到释放。

单点锁方案

大家仔细思考下,是否还有别的方式能够解决上述问题。值得庆幸的是,Redis在2.6.x版本之后内置了Lua解释器,能够让开发人员以一种非常方便的方式将一些复杂命令统一合并到一个Lua脚本中通过EVAL或EVALSHA命令执行,在保证原子操作的同时还能够大幅减少网络开销提升执行性能,示例1-1:

If(redis.call(‘SETNX’,KEYS[1]) == 1) then
redis.call(‘EXPIRE,ARGV[1]);
return 1;
end;
return 0;



基于上述Lua脚本我们确实可以实现一个较为完善的分布式锁,但离投放生产使用却仍然存在较大差距。首先,业务上如果依赖重入锁场景,仅通过SETNX命令显然是无法支持的;其次,锁资源的过期时间设置是个难题,这不是简单通过拍脑袋就可以解决的,由于服务低峰期和高峰期的RT时间不尽相同,如果贸然设置一个过期时间,一旦获取锁的服务在业务未执行完之前就被迫释放锁,这必然会导致出现严重的业务问题,或许那个时候,你的老板早已泡好茶、磨好刀在办公室静候你。

重入锁问题

我们先来解决重入锁的问题。同一个线程,在锁资源释放之前,可以重复加锁,为了满足这样的业务场景,我们显然要对锁的数据结构进行重新设计,一般来说,重入锁的数据结构会被设计为如下形式:



lockName代表着锁资源的名称,相同的一把锁,名称固然一致;visitorId代表着具体的访问者,visitorId可由UUID+ThreadId构成,重入的关键就是visitorId;最后,reentrancyNum代表锁资源的具体重入次数,它本质上是个计数器,记录加锁次数。重入锁加锁脚本,示例1-2:

if (redis.call('EXISTS', KEYS[1]) == 0) then
redis.call('HINCRBY', KEYS[1], ARGV[1], 1);
redis.call('PEXPIRE', KEYS[1], ARGV[2]);
return -1;
end;
if (redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1) then
redis.call('HINCRBY', KEYS[1], ARGV[1], 1);
redis.call('PEXPIRE', KEYS[1], ARGV[2]);
return -1;
end;
return redis.call('PTTL', KEYS[1]);



当执行上述Lua脚本时,Redis首先会判断目标锁资源是否存在,不存在就通过HINCRBY命令记录锁资源的持有者并将重入次数设置为1,然后返回-1表示成功;如果锁存在,就check当前访问者的visitorId和锁资源持有者的visitorId是否匹配,匹配就对重入次数+1,并返回-1;反之,就代表加锁失败,返回当前锁资源的过期时间。

看门狗

在正式讲解锁资源的过期时间问题之前,大家首先思考下,如果锁的数据结构中不包含过期时间是否可行?仅记录lockName和visitorId虽然也能够在技术手段上实现可重入锁,但在实际开发过程中,假设方法A调用方法B,且都需要获取同一把锁时,下游方法一旦unlock便会引发业务异常。因此记录锁资源的重入次数就显得非常重要了,lock几次,自然就需要unlock几次,直至重入次数 < 1时才允许完全解锁。示例1-3:

if (redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1) then
redis.call('HINCRBY', KEYS[1], ARGV[1], -1);
redis.call('PEXPIRE', KEYS[1], ARGV[2]);
if (tonumber(redis.call('HGET', KEYS[1], ARGV[1])) < 1) then
redis.call('DEL', KEYS[1]);
redis.call('PUBLISH', KEYS[1], 1);
return 1;
end;
return 2;
end;
return 0;



回到锁资源的过期时间设置问题上。我们需要引入一种机制,当某个线程获取到锁资源后,在业务执行结束之前,需要定时对目标锁的过期时间持续延期,以此来确保自动解锁时的正确性,如图1所示:

通过watchdog实现自动延期

watchdog在程序中的体现实际上就是一个 < 锁过期时间执行的定时任务。假设缺省的过期时间为30s,watchdog的触发时间为10s,当获取锁资源的线程在unlock之前,目标锁资源的过期时间将永不过期,除非因宕机导致会话中断。在此大家需要注意,并发环境下,目标线程可能存在加锁成功(重入)但因网络原因解锁失败的情况,为了避免目标锁的重入次数永远不 < 1,watchdog不退出导致其它线程无法加锁的情况,我们务必需要在解锁异常时reset当前线程的visitorId,目标线程和其它线程等待孤锁自然失效后重新尝试获取。

一些优化建议

最后一个问题。并发环境下,同步加锁场景,对于那些加锁失败的线程,自然需要不停的重试,直至最终加锁成功为止,那么是否有相对优雅一点的做法,至少不用频繁的请求Redis,降低其负载压力?答案是肯定的,我们可以利用Redis提供的Pub/Sub机制,当线程加锁失败时,构建一个Sub监听变化,并休眠当前线程;线程的唤醒机制有主动和被动2种,所谓主动唤醒,是由Sub监听到变化后,主动唤醒所有休眠线程重新尝试取锁操作;被动唤醒就是设置自动唤醒时间,这个时间来源于目标锁的过期时间。



本章的结尾,大家思考下,如何提升分布式锁的容错性?因为无论Redis是采用单机还是集群架构,针对目标锁的lock和unlock操作实质都是单点的,容错性较差,一旦目标Redis节点宕机,则无法顺利获取到分布式锁而引发业务问题。关于分布式锁的高容错性设计,见下期。

项目地址

基于JedisClient的分布式锁,https://github.com/gaoxianglong/jedis-distributed-lock

码字不易,欢迎转发

发布于: 2020 年 08 月 23 日 阅读数: 189
用户头像

高翔龙

关注

The best way out is always through 2020.03.25 加入

《超大流量分布式系统架构解决方案》、《人人都是架构师》、《Java虚拟机精讲》作者,GIAC全球互联网架构大会讲师

评论

发布
暂无评论
看门狗 | 分布式锁架构设计方案-01