Redis 锁的“续命”艺术:看门狗机制与原子性陷阱

张开发
2026/5/12 7:32:45 15 分钟阅读

分享文章

Redis 锁的“续命”艺术:看门狗机制与原子性陷阱
各位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 分布式锁客户端。它最核心的黑科技就是WatchDog1. 什么是看门狗看门狗是一个后台定时任务。当你调用lock()方法且没有指定租约时间 (leaseTime)时Redisson 会自动启动这个机制。默认锁时长30 秒 (lockWatchdogTimeout)。续期间隔每 10 秒 (lockWatchdogTimeout / 3) 检查一次。逻辑只要当前线程还持有锁看门狗就会把锁的过期时间重置为 30 秒。生活化比喻就像那个贴心的酒店服务员。你入住时给了 30 分钟。过了 10 分钟服务员敲门“先生您还在吗”你答“在”服务员立刻把退房时间顺延到从现在起的 30 分钟后。只要你一直在洗澡业务没结束服务员就一直帮你续费。只有当你退房主动 unlock或者你突发疾病失联宕机看门狗线程也死了服务员才会停止续费30 分钟后房间自动释放。2. Redisson 源码级逻辑拆解 (简化版)让我们潜入RedissonLock的核心逻辑看看它是如何实现的。A. 加锁流程 (tryLockInnerAsync)// 伪代码Redisson 内部加锁逻辑 RFutureLong 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)一旦加锁成功且没有指定 leaseTimeRedisson 会调用renewExpirationprivate 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:1001Value:{ 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:ThreadIDValue 是重入次数Counter。加锁时如果 Field 不存在设值为 1如果存在值 1。释放时值 -1。只有当值减为 0 时才删除整个 Key并停止看门狗。

更多文章