各位sir,咱们一步步来到了深水区,没有学游泳的抓紧啦
上回说到"为什么需要分布式锁",这一节咱们要把显微镜调到最大,直击 Redis 锁最核心、最容易翻车的两个命门:原子性 与续期。
核心痛点:当"时间"成为你的敌人
在分布式系统中,时间是最不可靠的变量。网络延迟、Full GC、下游服务卡顿,都可能让一个原本只需 100ms 的业务逻辑,突然跑上 30 秒;这时候,如果你的锁有一个固定的"过期时间",灾难就开始了~
场景一:死锁的阴霾
你(线程 A)住进酒店(加锁),前台规定最多住 30 分钟(过期时间)。
结果你在洗澡时睡着了,睡了 40 分钟。
第 30 分钟,前台以为你走了,把房间清理了,放进了新客人(线程 B)。
第 40 分钟,你洗完出来,顺手把房门锁砸了(删除 Key),心想"我退房了"。
结局:新客人(线程 B)正洗着澡,门被你砸了,隐私泄露(数据错乱)!
"线程 A 超时 -> 锁释放 -> 线程 B 拿到锁 -> 线程 A 醒来误删 B 的锁"事故
场景二:原子性缺失
生活化比喻 :
你想进房间,规则是"先检查门锁,再挂上我的牌子"。
如果你分两步走:
- 检查门锁(
SETNX)。 - 挂牌子设时间(
EXPIRE)。
就在你刚检查完门锁(成功),还没来得及挂牌子时,你突然心脏病发作(宕机)了。
结局:门锁上了,但没人知道什么时候该开。这个房间永远被锁死(死锁),直到管理员手动清理。
// 【反面教材】千万不要在生产环境这么写!
public void wrongLock() {
String key = "lock:order:1001";
String value = UUID.randomUUID().toString();
// 1. 尝试加锁 (非原子!)
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value);
if (success) {
// 2. 设置过期时间 (非原子!如果这一步之前宕机,就死锁了)
redisTemplate.expire(key, 30, TimeUnit.SECONDS);
try {
// 业务逻辑...
// 假设这里卡顿了 40 秒
Thread.sleep(40000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 3. 释放锁 (极度危险!如果不判断 value,可能删掉别人的锁)
// 即使加了判断,如果 A 超时后 B 进来了,A 醒来依然可能删掉 B 的锁
if (value.equals(redisTemplate.opsForValue().get(key))) {
redisTemplate.delete(key);
}
}
}
}
- 非原子性 :
setIfAbsent和expire是两次网络请求。中间宕机 = 死锁。 - 锁超时:业务执行时间 > 30s,锁自动失效,并发冲突。
- 误删风险 :虽然加了
value判断,但如果 A 超时,B 进来并设置了新的 value。A 醒来时,发现 key 还在,但 value 变了(这是好的)。但是,如果 A 在 B 进来之前就已经拿到了锁的引用,或者逻辑判断有细微漏洞,依然风险巨大。更可怕的是,如果 A 和 B 的 value 碰巧一样(概率极低但理论存在),或者逻辑顺序问题,都会导致误删。
正确姿势:Lua 脚本的原子魔法
为了解决 原子性 问题,Redis 提供了 Lua 脚本 。
核心原理:Redis 是单线程处理命令的,Lua 脚本一旦开始执行,中间不会插入其他客户端的命令。它将"加锁 + 设过期"打包成一个原子操作。
-- KEYS[1]: 锁的 key
-- ARGV[1]: 唯一标识 (UUID + ThreadID)
-- ARGV[2]: 过期时间 (毫秒)
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
return 1
else
return 0
end
-- 只有当锁的 value 等于当前线程的标识时,才删除
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
但是! Lua 脚本解决了原子性和误删,依然解决不了"业务执行时间过长导致锁自动过期"的问题 。这就需要我们的主角登场了:看门狗 (WatchDog)。
深度解析:Redisson 的看门狗机制
Redisson 是 Java 领域事实标准的 Redis 分布式锁客户端。它最核心的黑科技就是 WatchDog
1. 什么是看门狗?
看门狗是一个后台定时任务。当你调用 lock() 方法且没有指定租约时间 (leaseTime) 时,Redisson 会自动启动这个机制。
- 默认锁时长 :30 秒 (
lockWatchdogTimeout)。 - 续期间隔 :每 10 秒 (
lockWatchdogTimeout / 3) 检查一次。 - 逻辑 :只要当前线程还持有锁,看门狗就会把锁的过期时间重置为 30 秒。
生活化比喻 :
就像那个贴心的酒店服务员。你入住时给了 30 分钟。
过了 10 分钟,服务员敲门:"先生,您还在吗?"
你答:"在!"
服务员立刻把退房时间顺延到从现在起的 30 分钟后。
只要你一直在洗澡(业务没结束),服务员就一直帮你续费。
只有当你退房(主动 unlock)或者你突发疾病失联(宕机,看门狗线程也死了),服务员才会停止续费,30 分钟后房间自动释放。
2. Redisson 源码级逻辑拆解 (简化版)
让我们潜入 RedissonLock 的核心逻辑,看看它是如何实现的。
A. 加锁流程 (tryLockInnerAsync)
// 伪代码:Redisson 内部加锁逻辑
RFuture<Long> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
// 如果用户指定了 leaseTime,就不启用看门狗
if (leaseTime != -1) {
// 直接设置固定过期时间
return commandExecutor.evalWriteAsync(..., SET_LOCK_SCRIPT, ...);
}
// 如果没指定 leaseTime (默认 -1),则使用默认看门狗时间 (30s)
// 并且尝试加锁
return commandExecutor.evalWriteAsync(..., SET_LOCK_SCRIPT, ...);
}
B. 启动看门狗 (renewExpiration)
一旦加锁成功,且没有指定 leaseTime,Redisson 会调用 renewExpiration
private void renewExpiration() {
// 创建一个定时任务
ee.scheduleAtFixedRate(() -> {
// 获取当前锁的剩余时间,或者直接续期
// 核心逻辑:只要锁还在,就把它重置为 30s
redisCommands.expire(lockName, 30, TimeUnit.SECONDS);
}, 10, 10, TimeUnit.SECONDS); // 每 10 秒执行一次
}
- 这个定时任务是绑定在当前 JVM 进程里的。
- 如果当前 JVM 宕机,这个定时任务也就停止了。
- 因为没有新的续期指令发出,30 秒后 Redis 会自动删除 Key。
- 完美平衡:既解决了长业务导致的超时,又保证了宕机后锁能自动释放(不会死锁)。
C. 可重入性 (Reentrant)
Redisson 的锁是可重入的。底层使用 Hash 结构 存储:
- Key:
lock:order:1001 - Value:
{ "threadId:uuid": 1 }(Field: 线程标识,Value: 重入次数)
每次重入,计数 +1;每次释放,计数 -1。只有计数归零,才真正删除 Key 并停止看门狗。
代码实战:手写简易版看门狗 vs Redisson
为了彻底理解,先手撕 一个简易版,再展示工业级写法
public class ManualWatchDogLock {
private final Jedis jedis;
private final String lockKey;
private final String requestId;
private final int LOCK_TIME = 30000; // 30s
private ScheduledExecutorService scheduler;
private Future<?> future;
public ManualWatchDogLock(Jedis jedis, String lockKey) {
this.jedis = jedis;
this.lockKey = "lock:" + lockKey;
this.requestId = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
}
public boolean lock() {
// 1. 原子加锁 (Lua)
String script = "if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then return 1 else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Arrays.asList(requestId, String.valueOf(LOCK_TIME)));
if ("1".equals(result)) {
// 2. 启动看门狗
startWatchDog();
return true;
}
return false;
}
private void startWatchDog() {
scheduler = Executors.newSingleThreadScheduledExecutor();
// 每 10 秒检查并续期
future = scheduler.scheduleAtFixedRate(() -> {
// 检查锁是否还是自己的
String currentVal = jedis.get(lockKey);
if (requestId.equals(currentVal)) {
// 续命!
jedis.pexpire(lockKey, LOCK_TIME);
System.out.println("🐶 [WatchDog] 锁已续期: " + lockKey);
} else {
// 锁已经没了(被别人抢走或自己释放了),停止看门狗
stopWatchDog();
}
}, LOCK_TIME / 3, LOCK_TIME / 3, TimeUnit.MILLISECONDS);
}
public void unlock() {
// 1. 停止看门狗
stopWatchDog();
// 2. 原子释放 (Lua)
String script = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end";
jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
}
private void stopWatchDog() {
if (future != null) future.cancel(true);
if (scheduler != null) scheduler.shutdown();
}
}
这个版本实现了核心逻辑,但缺少了很多生产级特性(如断线重连、锁的可重入计数、多节点 RedLock 支持等)。
2. 工业级写法:Redisson (生产推荐)
@Autowired
private RedissonClient redissonClient;
public void businessProcess() {
RLock lock = redissonClient.getLock("lock:order:1001");
// 关键:不传 leaseTime 参数,默认启用看门狗!
// 如果传了 lock.tryLock(5, 10, TimeUnit.SECONDS),则 10 秒后自动过期,不看门狗
boolean isLocked = false;
try {
// 尝试加锁,等待 5 秒,不指定租约时间 (启用 WatchDog)
isLocked = lock.tryLock(5, -1, TimeUnit.SECONDS);
if (isLocked) {
// 业务逻辑,哪怕跑 1 个小时,锁也不会过期(只要机器活着)
doHeavyBusiness();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (isLocked && lock.isHeldByCurrentThread()) {
lock.unlock(); // 自动停止看门狗并释放锁
}
}
}
彩蛋
Q1: Redis 分布式锁的过期时间怎么设置?设多少合适?
- 如果使用原生
setnx,很难设置,设短了业务没跑完,设长了宕机死锁。 - 最佳实践 是使用 Redisson 并不指定过期时间 (即启用 WatchDog 机制)。
- WatchDog 默认 30 秒过期,每 10 秒自动续期。只要业务线程活着,锁就不会过期;一旦线程宕机,看门狗停止,30 秒后自动释放。这完美平衡了安全性 和可用性。
Q2: 看门狗机制有什么缺点?
- 缺点 1 :依赖客户端进程存活。如果客户端发生 Stop-The-World (STW) 长时间的 Full GC,看门狗线程也可能无法按时发送续期指令,导致锁意外过期。(虽然概率低,但存在)。
- 缺点 2:增加了 Redis 的网络交互压力(每 10 秒一次写操作)。
- 缺点 3:如果是集群模式,主从切换瞬间,看门狗的续期指令可能丢失,导致锁失效(这是 Redis AP 模型的通病,需配合 RedLock 或数据库兜底)。
Q3: 如果业务执行时间真的非常长(比如几小时),用看门狗合适吗?
- 不合适。长时间持有分布式锁是架构设计的反模式(Anti-Pattern)。
- 解决方案 :
- 异步化:将长耗时任务拆分为"提交任务" + "异步执行"。锁只保护"提交"这个动作,后续长任务通过消息队列异步处理,不需要持锁。
- 状态机 :利用数据库的状态字段(如
status = PROCESSING)来控制并发,而不是依赖内存锁。
Q4: Redisson 的可重入锁底层是怎么实现的
- 底层使用 Hash 数据结构。
- Key 是锁的名称,Field 是
UUID:ThreadID,Value 是重入次数(Counter)。 - 加锁时:如果 Field 不存在,设值为 1;如果存在,值 +1。
- 释放时:值 -1。只有当值减为 0 时,才删除整个 Key,并停止看门狗。