
sn: 17batch: 4round: 9topic: 分布式锁 Redisson 源码分析先说结论性的一个坑我们结算系统有个同事给 Redisson 的锁加了leaseTime 30理由很朴素——怕死锁30 秒自动释放更安全。三个月后大促一批慢结算任务跑了 40 秒锁在第 30 秒被自动释放另一个实例拿到锁并发执行两条结算流水同时落库对账差了 17 万。排查了一整晚最后发现凶手就是那个看起来更安全的参数。这个事故的根源是对 Redisson 看门狗机制的理解停留在听说过层面。这篇我把 Redisson 加锁的源码路径走一遍把看门狗的触发条件、续期逻辑、以及我总结的 3 条使用红线讲透。版本基于 Redisson 3.27.xJDK 8 兼容线。从 tryLock 入口看起参数不同命运不同Redisson 加锁的入口是RLock.tryLock()同一把锁参数怎么传决定了完全不同的执行路径// 用法一不传 leaseTime —— 看门狗生效这是默认推荐 boolean r1 lock.tryLock(5, TimeUnit.SECONDS); // 用法二传了 leaseTime —— 看门狗失效到点强制释放无论业务是否执行完 boolean r2 lock.tryLock(5, 30, TimeUnit.SECONDS); // 用法三waitTime0 的非阻塞抢锁 boolean r3 lock.tryLock();逐行说清楚区别第 1 行只给了 waitTime5 秒最多等 5 秒去抢锁没给 leaseTime这时 Redisson 会启用看门狗默认锁过期时间 30 秒且只要业务没执行完就每 10 秒自动续期第 2 行给了 leaseTime30走的是完全不同的路径——锁就 30 秒后过期没有任何续期上面的结算事故就出在这第 3 行是 tryLock 的无参重载等价于 waitTime0 看门狗抢不到立即返回 false。所以第一个红线出来了传了 leaseTime 就等于亲手关掉看门狗。这个行为在方法名上完全看不出来只能读源码确认。加锁的原子性Lua 脚本里藏着 HASH 结构Redisson 加锁不是简单的SET key value NX EX而是一段 Lua 脚本。我摘出核心逻辑org.redisson.executor.Lua 脚本的等价 Java 语义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]);这段脚本值得逐行读。KEYS[1] 是锁名ARGV[1] 是过期毫秒数ARGV[2] 是客户端标识:线程ID格式的持有者 hash field。第 1 行判断锁不存在第 2 行用hincrby往一个 HASH 结构里写入持有者并计数为 1——注意用的是 HASH 不是 STRING这个设计是为了支持可重入第 3 行设置过期时间。第二段 if 判断 hash field 是否已存在也就是当前线程是否已持有这把锁存在则计数加 1 并刷新过期时间这就是可重入的实现同一线程第二次加锁只是把计数值从 1 改成 2。最后返回 pttl即锁的剩余存活时间调用方拿这个值做后续等待计算。为什么用 Lua 而不是两条 Redis 命令因为判断存在 写入 设过期三步必须原子。假如先 exists 后 hset两个客户端可能同时通过 exists 判断各自写入锁就形同虚设。这个原子性要求和缓存击穿里的 setnxex 合并是同一个道理只是 Redisson 把它做得更完备。看门狗续期一个 Netty 定时任务在偷偷工作看门狗的核心在renewExpiration()方法。加锁成功且未指定 leaseTime 时Redisson 会启动一个定时任务private void renewExpiration() { ExpirationEntry ee EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ee null) { return; } // 1. 内部定时器 10 秒后执行续期任务默认 30s 锁1/3 周期续期 Timeout task commandExecutor.getConnectionManager().newTimeout(new TimerTask() { Override public void run(Timeout timeout) throws Exception { // 2. 检查本地续期队列里是否还有这个锁的记录 ExpirationEntry ent EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ent null) { return; } Long threadId ent.getFirstThreadId(); if (threadId null) { return; } // 3. 发 Lua 脚本到 Redis只有持有者还是自己才把 TTL 重置回 30 秒 RFutureBoolean future renewExpirationAsync(threadId); future.whenComplete((res, e) - { if (e ! null) { log.error(Cant update lock {} expiration, getRawName(), e); // 4. 续期异常会取消后续调度——这是风险点后面细说 cancelExpirationRenewal(null); return; } if (res) { // 5. 续期成功递归调度下一次 10 秒后的续期 renewExpiration(); } }); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); ee.setTimeout(task); }逐行看重点第 1 行续期周期是internalLockLeaseTime / 3默认 30000ms 的三分之一即 10 秒——也就是说每 10 秒续一次每次把 TTL 重置回 30 秒连续 3 次续期失败锁才会真正过期这个冗余设计挺扎实第 3 行续期的 Lua 语义是检查 HASH 里的持有者还是不是我是才 pexpire 30 秒避免给别人的锁续命第 4 行是我要重点敲黑板的续期时如果 Redis 连接异常Redisson 会直接取消这个锁的续期调度锁会在最迟 30 秒后过期而此时你的业务可能还在跑——Redis 抖动期间用 Redisson 锁的场景必须意识到这个隐含行为第 5 行续期成功后递归调用自己形成持续续期的循环。还有一条容易忽略看门狗任务存在EXPIRATION_RENEWAL_MAP这个 JVM 内的 Map 里而它的清理发生在 unlock 时。如果你的业务代码异常了却没在 finally 里 unlock续期任务会一直跑下去锁永远不过期——这才是真正意义的死锁比忘设 leaseTime 严重得多。3 条使用红线踩坑换来的第一条不传 leaseTime用看门狗但必须在 finally 里 unlock。传了 leaseTime 就是手动挡锁死在固定时长业务超时必出并发事故。我们后来把结算系统的锁统一改成无 leaseTime 写法配合 try-finally再没出过并发重复结算。第二条unlock 前校验持有者身份。直接调lock.unlock()如果当前线程不持有锁Redisson 会抛IllegalMonitorStateException但更隐蔽的是误删场景A 实例业务超时后锁自动过期B 实例拿到锁此时 A 恢复过来调 unlock如果 Redisson 版本较老或用了错误的解锁姿势可能解掉 B 的锁。用unlock()的正确姿势Redisson 内部会校验 hash field不要自己封装 del 命令。第三条别用 Redisson 锁保护长事务。锁的持有时间横跨一次 DB 事务甚至 RPC 调用链续期机制再多也有失控面。我的原则锁内只做抢到执行资格这个动作真正的长操作放到拿到锁之后的队列里异步做或者干脆用数据库乐观锁替代。场景我的选择理由秒级短临界区Redisson 看门狗锁简单可靠异常路径清晰百级 QPS 的防重复提交数据库唯一索引不引入额外组件天然幂等跨机房长任务调度ZooKeeper / 数据库任务表Redis 锁的续期依赖网络稳定长任务风险高解锁源码里藏着的持有者校验解锁路径比加锁更值得细看因为误删事故都发生在这里。Redisson 的 unlock 底层 Lua 语义还原成 Java 逻辑大致是// unlock 的 Lua 语义还原org.redisson.RedissonLock.unlockInnerAsync public void unlockSafely(RLock lock) { long threadId Thread.currentThread().getId(); // 1. Lua 里先 hexists 检查 HASH 中是否存在当前 threadId 的 field // 不存在直接返回 null不会动别人的锁 // 2. 计数减 1hincrby -1减到 0 才真正 del 并广播解锁消息 // 3. 计数 0 说明是可重入的内层锁只减计数不释放 lock.unlock(); }第 1 行的持有者校验就是防误删的核心锁 HASH 里的 field 是客户端UUID:线程ID只有匹配的持有者才能减计数。这解释了为什么不能自己写redis.del(lockKey)——裸 del 会把别人刚拿到的锁直接删掉B 实例再拿锁时 A 实例也在跑互斥彻底失效。第 2 行的计数逻辑同时解释了可重入锁的解锁行为嵌套加锁 3 次就要解锁 3 次才真正释放少解一次锁会一直挂着看门狗一直续期多解一次会抛 IllegalMonitorStateException。还有一个反直觉的行为值得记一笔lock.forceUnlock()会无视持有者直接删锁并取消续期任务。这个 API 是给运维应急用的比如锁持有方进程假死业务代码里出现 forceUnlock 就是设计味道我们的代码评审规则里它属于必须给出书面理由的 API。思考题看门狗续期依赖 Redis 连接而第 4 行显示续期异常会直接取消调度。假设你是 Redisson 的设计者你会选择续期失败立即放弃续期还是失败重试 N 次再放弃两种选择各会导致什么故障形态评论区聊聊。