多进程环境下,为避免多节点重复工作,以减少不必要的资源浪费或保证业务的正确性,常使用分布式锁来实现。在分布式锁的多种实现方案(基于数据库、基于 Zookeeper 、基于 Chubby、基于缓存等)中,基于缓存组件实现的分布式锁成为大多数业务的选择。(相比数据库、Zookeeper 、Chubby等组件,缓存组件具有更高的性能,且大多数业务均已引入缓存组件)。在缓存组件的选择上,Redis已成为主流选择。本文梳理下基于Redis实现分布式锁的常见问题,避免在同一个问题上重复踩坑,方便后学者。
基于setnx命令实现分布式锁
在基于Redis实现分布式锁时,有两种方式方案,一种是基于setnx命令实现分布式锁,另一种是基于hset数据结构实现分布式锁,两种实现遇到的问题类似,这里优先介绍第一种实现,后面会介绍第二种实现。
使用setnx命令
实现分布式锁,要求同时只能有一个客户端加锁成功,也就是说,在多个客户端同时请求获取锁时,只有一个客户端加锁成功,其余客户端加锁失败(这些客户端同时获取一把锁)。Redis 提供 setnx 命令(SET if Not eXists)可以很好的支持这种场景。(当 key 不存在时,为 key 设置指定的值,并返回1。这种情况下等同 SET 命令。当 key 存在时,什么也不做,并返回 0。) 这样,客户端加锁的伪代码如下:
if(setnx(key, value) == 1) {
// 加锁成功
// 执行业务逻辑
} else {
// 加锁失败
}
使用setnx命令 + expire命令
如果只考虑加锁,上述代码没有任何问题。但是,客户端在执行完业务逻辑后,还需保证锁能正常释放。所以,还得提供解锁的代码。Redis支持多种方式实现解锁,如使用DEL命令删除key或给该key设置过期时间。两种方式的差异是,DEL命令依赖客户端主动释放锁,而设置过期时间依赖Redis释放锁。相比客户端,Redis的可靠性更高,所以推荐使用设置过期时间的方式(expire 命令)释放锁。使用过期时间释放锁后,客户端加解锁的伪代码如下:
if(setnx(key, value) == 1) {
// 加锁成功
// 设置过期时间
expire(key, seconds)
// 执行业务逻辑
} else {
// 加锁失败
}
使用set 命令+ ex参数
使用上述的实现方式,可以很好的解决因客户端崩溃后锁无法释放的情况。但是上面的代码仍有问题。setnx和expire是一个两步操作,无法保证操作的原子性。如果客户端在执行setnx后和执行expire前期间故障(尽管只有很短的一段时间),则仍然存在锁无法释放的问题。(setnx和expire仍没有解决因客户端崩溃后锁无法释放,只是降低了这种情况发生的概率)所以,需要保证setnx和expire两个操作可以作为一个原子操作。一种方式是使用lua脚本(Redis支持基于lua脚本实现原子操作)。还有一种方式是使用Redis提供的set 命令+ ex参数。使用这种方式后,客户端加解锁的伪代码如下:
if("OK".equals(set(key, value, EX, seconds))) {
// 加锁并设置过期时间成功
// 执行业务逻辑
} else {
// 加锁失败
}
主动释放锁
使用过期时间释放锁,存在的一个问题是,由于过期时间必须要长于业务逻辑执行时间(否则加锁会失效)使用这种方式后,客户端加解锁的伪代码如下:
try {
if("OK".equals(set(key, value, EX, seconds))) {
// 加锁并设置过期时间成功
// 执行业务逻辑
} else {
// 加锁失败
}
} finally {
del(key)
}
避免释放他人锁
上述代码看似已经没问题,但是在释放锁的时候,仍然会出现释放他人锁的问题。释放他人锁的问题具体可参考这篇博客。简单来说,如果持有锁的客户端(假设是客户端1)在过期时间后才释放锁(如GC时间过长,OS阻塞、业务处理时间过长等),且在过期时间后释放锁前,该锁已经被其他客户端(假设是客户端2)持有,则该客户端会释放其他客户端的锁。
为避免释放他人锁,在释放锁前,还需要检查是否是当前客户端加锁。这里可以通过value校验。客户端加解锁的伪代码如下:
try{
if("OK".equals(set(key, currentUuid, EX, seconds))) {
// 加锁并设置过期时间成功
// 执行业务逻辑
} else {
// 加锁失败
}
} finally {
if (get(key).equals(currentUuid)) {
del(key);
}
}
解决非原子性操作问题
使用上述代码可以保证每个客户端只释放自己持有的锁,但是在删除锁的时候,仍有必要保证get和del操作的原子性,为此可以使用lua脚本保证原子性。对应lua脚本如下:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这里 currentUuid 作为 ARGV[1]的值传进去,key 作为 KEYS[1]的值传进去。
锁超时问题
尽管上述代码看起来已经没有什么问题,但是仔细分析,仍然可能会存在锁超时的问题。具体场景可参考这篇博客。简单来说,如果客户端(假设是客户端1)在持有锁成功后,如果由于业务处理、GC、操作系统等原因导致处理时间过长,以至于超过加锁的时间,这时 Redis 会自动释放锁。如果在Redis释放锁后,其他客户端(假设是客户端2)加锁并执行了业务逻辑代码,且客户端1又开始执行业务逻辑代码(此时客户端1认为仍然持有锁,所以可以执行),这时客户端1的业务逻辑代码可以破坏数据的一致性。
针对这个问题,一种补偿方案是定时器实现自动续期的功能。也就是说,客户端1在持有锁后,主动去启动一个定时器以实现自动续期。但是,这种补偿方案仅能解决客户端业务处理时间长于缓存超期时间的场景,如果是GC、操作系统等处理时间长于缓存超期时间的场景,则定时器仍然无法及时补偿。需要说明的是,GC、操作系统等处理时间长于缓存超期时间的场景出现概率极低,基于定时器的自动续期功能仍有实际意义。
基于hash结构实现分布式锁
除了可以基于setnx命令实现分布式锁,还可以基于hash结构实现分布式锁。这里主要参考redission(Redis的客户端实现之一)实现分布式锁的代码。
加锁实现
Redission在实现分布式锁时,实现了JDK的Lock接口,可以让Java开发者像使用本地锁一样使用分布式锁。这里介绍下Redission如何实现加锁逻辑。关键源码如下:
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
分析源码可知,KEYS[1]表示key,ARGV[1]表示expireTime,ARGV[2]表示lockField。因为使用lua脚本执行上述操作(使用字符串拼接的lua代码),所以可以保证该过程的原子性。
上述代码除了使用hincrby递增lockValue外,还支持锁重入。具体处理逻辑是:如果发现当前线程已经持有锁,就用hincrby命令将value值加1,value的值将决定释放锁的时候调用解锁命令的次数,达到实现锁的可重入性效果。
解锁实现
分析完了加锁的核心代码,接下来分析解锁的核心代码。关键源码如下:
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"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;",
Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
分析源码可知,KEYS[1]表示key,KEYS[2]表示lockField 对应 topic(引入pub-sub机制),ARGV[1]表示向topic推送的消息,ARGV[2]表示过expireTime,ARGV[3]表示lockField。
注意,这里只是分析了Redission在实现分布式锁的核心代码,其他实现细节,比如watch避免锁超时,公平锁、异步锁等,还需读者自行查找资料学习。
Redis分布式锁的安全性
介绍完了Redis分布式锁的两种实现后,那么采用上面的方案实现的分布式锁是不是就没有其他问题了?答案是否定的。上面的实现只考虑了单机场景。在实际应用中,为保证Redis的可用性,通常需要至少部署一主一从两个节点(不同的可用性级别,从节点的数量不同),而 Redis 默认的主从复制是异步的。由于异步操作无法保证强一致性,所以存在主节点数据不能同步从节点的情况。(可以假设在主节点写入数据后在主节点将数据同步到从节点前,主节点挂掉了)。如果这部分丢失的数据是分布式锁的数据,则会导致分布式锁失效。
针对上面的问题,Redis 之父 antirez 设计了 Redlock 算法。RedLock的实现,可以参考Redission源码。需要说明的是,Redission已经放弃使用 Redlock 算法保证主从模式下分布式锁的正确性,转而基于pub-sub机制实现。(笔者查看的源码是Redission 3.17.7)
关于 Redlock 算法保证主从模式分布式锁的正确性,Martin Kleppmann 与 Antirez 有过激烈的讨论,这里不展开讨论,有兴趣的同学可以细细阅读下这两篇博文:基于Redis的分布式锁到底安全吗(上)?,基于Redis的分布式锁到底安全吗(下)?。
总结
基于Redis实现分布式锁已经成为分布式锁的主流实现,在使用基于Redis实现的分布式锁时,要充分考虑操作原子性、死锁、释放他人锁、锁超时等问题。对于需要保证正确性的业务,还要考虑Redis主从部署场景下的安全性。尽管 Redis 官方推荐基于 RedLock 算法保证主从部署场景下的锁安全性,但是该算法存在以下问题:(1) RedLock 算法建立在了 Time 是可信的基础上,所以时间被破坏的情况下,它无法实现锁的绝对安全;(2) RedLock 算法实现比较复杂,并且性能比较差;(3) RedLock 需要恰当的运维保障它的正确性,故障-崩溃之后需要一套延迟重启的机制。Redission已经放弃使用 Redlock 算法保证主从模式下分布式锁的正确性,笔者也不推荐 Redlock 算法(笔者的观点是,RedLock 算法基于可信的Time模型在实际生产中需要单独维护,代价太大,且无法保证性能)。所以,在保证正确性的场景下使用基于Redis实现的分布式锁时,要进行充分的验证,避免不必要的财产损失。
参考
https://redis.com.cn/commands/setnx.html SETNX 命令
https://redis.com.cn/commands/expire.html EXPIRE 命令
https://blog.csdn.net/ByteDanceTech/article/details/125814670 聊聊分布式锁
https://www.cnblogs.com/barrywxx/p/8563284.html Redis结合Lua脚本实现高并发原子性操作
https://zhuanlan.zhihu.com/p/350153428 Redisson分布式锁的源码分析
http://zhangtielei.com/posts/blog-redlock-reasoning.html 基于Redis的分布式锁到底安全吗(上)?
http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html 基于Redis的分布式锁到底安全吗(下)?