1. 引言
在分布式系统中,协调多个节点对共享资源的访问是一个经典难题。分布式锁作为一种解决方案,能够确保在分布式环境下,同一时刻只有一个节点可以执行关键代码段。Redisson 作为基于 Redis 的 Java 客户端,不仅提供了丰富的分布式对象和服务,其分布式锁的实现更是被广泛使用。本文将深入剖析 Redisson 分布式锁(RLock)的核心实现原理,从基本使用到源码细节,帮助你理解其高可用、可重入、自动续期等特性的背后机制。
2. Redisson 分布式锁核心特性
在深入原理之前,我们先了解 Redisson 锁提供的关键特性,这些特性直接影响了其实现设计:
- 可重入性:同一个线程可以多次获取同一把锁,避免死锁。
- 锁自动续期(Watchdog):为了防止业务执行时间超过锁的过期时间而导致锁被意外释放,Redisson 提供了"看门狗"机制,在锁持有期间自动续期。
- 公平锁与非公平锁:支持两种锁模式,满足不同场景下的调度需求。
- 高可用 :支持单节点、主从、哨兵、集群等多种 Redis 部署模式,并能与 Redisson 的
RedissonRedLock(红锁)结合,提供更高的一致性保证(尽管红锁存在争议)。 - 异步与响应式支持 :提供异步(
RLockAsync)和响应式(RReactiveLock)接口。
3. 实现原理剖析
Redisson 分布式锁的核心实现依赖于 Redis 的原子操作和 Lua 脚本。我们以最常用的可重入锁为例进行分析。
3.1 加锁流程与 Lua 脚本
当我们调用 lock.lock() 时,最终会执行一段 Lua 脚本。这是保证原子性的关键。
lua
-- 参数说明:KEYS[1] 锁的key,ARGV[1] 锁的过期时间(毫秒),ARGV[2] 客户端唯一标识(UUID + 线程ID)
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
-- 锁存在,且是当前客户端/线程持有,重入次数+1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 锁存在,但被其他客户端持有,返回锁的剩余生存时间
return redis.call('pttl', KEYS[1]);
脚本解读:
- 锁不存在 :使用
HSET结构,key为锁名称,field为客户端标识(UUID:threadId),value为重入次数(初始为1)。并设置键的过期时间。 - 锁已存在且为自己持有 :对
field对应的重入次数加1,并刷新过期时间。 - 锁被他人持有:返回锁的剩余存活时间(TTL),客户端会根据这个时间进行等待和重试。
数据结构:锁在 Redis 中是一个 Hash 结构。
Key: 锁的名称,例如myLock。Field: 客户端唯一标识,格式为客户端UUID:线程ID。Value: 整数,表示该线程的重入次数。
3.2 看门狗(Watchdog)自动续期机制
如果加锁时未指定 leaseTime,Redisson 会启动一个看门狗线程。其工作原理如下:
- 锁默认过期时间 :默认 30 秒(
lockWatchdogTimeout可配置)。 - 续期时机:看门狗线程以过期时间的 1/3(即 10 秒)为间隔定时执行续期任务。
- 续期逻辑 :同样是 Lua 脚本,检查锁的
field是否仍由当前客户端持有。如果是,则将锁的过期时间重置为初始值(如30秒)。 - 停止条件 :当锁被释放(
unlock)或持有锁的线程中断时,看门狗线程会被取消。
这个机制有效防止了因为业务执行时间过长而导致的锁"过期释放"问题。注意 :如果指定了 leaseTime,则不会启动看门狗,锁将在指定时间后自动释放。
3.3 解锁流程
解锁操作 lock.unlock() 同样通过 Lua 脚本保证原子性。
lua
-- 参数说明:KEYS[1] 锁的key,KEYS[2] 解锁消息的channel,ARGV[1] 消息内容,ARGV[2] 过期时间,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
-- 重入次数仍大于0,说明还未完全释放,刷新过期时间
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
-- 重入次数为0,彻底删除锁
redis.call('del', KEYS[1]);
-- 发布解锁消息,通知其他等待的客户端
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil;
脚本解读:
- 检查当前客户端是否持有锁。
- 重入次数减1。
- 如果减1后次数仍大于0,则刷新过期时间并返回。
- 如果减1后次数等于0,则删除整个锁 Key,并通过 Redis 的 Pub/Sub 机制发布一条解锁消息,唤醒其他正在等待这个锁的客户端。
3.4 锁竞争与订阅机制
当锁被其他客户端持有时,当前客户端并不会持续轮询(避免给 Redis 带来压力),而是采用订阅-通知的机制:
- 客户端在尝试获取锁失败后,会订阅一个与锁 Key 对应的 Channel。
- 当锁被释放时(见解锁脚本中的
publish),会向该 Channel 发布消息。 - 订阅者收到消息后,被唤醒并再次尝试获取锁。
- 同时,客户端也会设置一个超时(基于之前 Lua 脚本返回的 TTL),防止消息丢失导致的永久等待。
4. 不同锁类型简介
- 公平锁(FairLock):基于 Redis 的 List 结构和 Lua 脚本,按照客户端请求的顺序(FIFO)授予锁,避免了"饥饿"现象。
- 读写锁(RReadWriteLock) :实现了
ReadWriteLock接口,允许多个读锁同时持有,但写锁独占。同样基于 Hash 结构,用不同的field区分读锁和写锁。 - 联锁(MultiLock) :将多个
RLock对象关联为一个锁,只有成功获取所有底层锁才算成功,用于需要同时锁定多个资源的场景。 - 红锁(RedLock) :Redisson 也实现了 RedLock 算法,它尝试在多个独立的 Redis 主节点上获取锁,当在大多数节点上成功时才认为获取成功。注意:该算法在分布式系统社区存在较大争议(如 Martin Kleppmann 的质疑),需谨慎评估使用。
5. 关键注意事项与最佳实践
- 锁命名:使用具有业务意义的唯一名称,避免冲突。
- 避免死锁 :确保锁最终能被释放。使用
tryLock并设置超时时间,或在finally块中释放锁。 - 看门狗与超时 :理解
leaseTime参数的作用。对于执行时间不确定的长任务,不要指定leaseTime以启用看门狗;对于短任务或需要严格超时的场景,可以指定leaseTime。 - 网络与时钟:分布式锁的安全性依赖于 Redis 服务的可用性和系统时钟的相对稳定。在哨兵或集群模式下,主从异步复制可能导致锁状态不一致(极端情况)。
- 非阻塞获取 :优先使用
tryLock()而非lock(),并提供合理的等待时间,以提高系统弹性。
6. 总结
Redisson 分布式锁通过精心设计的 Lua 脚本、可重入的 Hash 数据结构、自动续期的看门狗机制以及基于 Pub/Sub 的高效等待策略,在 Redis 基础上构建了一个功能完备、高可用的分布式锁实现。理解其原理不仅能帮助开发者正确、高效地使用它,也能在遇到问题时进行有效的排查和调优。在选择分布式锁方案时,需要根据业务的一致性要求、Redis 的部署架构以及性能需求进行综合权衡。