Skip to content

分布式锁 ​

Java 架构师 · 服务治理

在并发写共享资源(库存扣减、定时任务防重、防重复提交、幂等防重)时,分布式锁把"互斥"从单机 synchronized 扩展到多实例。它的难点不在"加锁"本身,而在"锁的可靠性、自动续期与释放时机"——一个没设计好的分布式锁,比没有锁更危险(因为给你"安全"的错觉却在高并发下失效)。本章深入 Redisson 看门狗的续期机制、ZooKeeper 临时顺序节点抢锁的全过程、释放锁的误删问题,以及 RedLock 到底为什么被质疑。

一、分布式锁必须满足的三性 ​

  1. 互斥(Mutual Exclusion):同一时刻唯一持有者。
  2. 不死锁(No Deadlock):持有者宕机也能自动释放(超时 / 会话失效)。
  3. 容错(Fault Tolerance):多数节点可用即可工作,不依赖单点。

常见实现基于 Redis(高性能)或 ZooKeeper/etcd(强一致),二者在可靠性与性能上各有取舍。

二、Redis 原生锁的陷阱:SETNX + 过期 ≠ 安全 ​

新手写法:SETNX lock 1 成功则拿锁,再 EXPIRE lock 30。问题:

  • SETNX 与 EXPIRE 非原子:若之间进程崩溃,锁永不释放 → 死锁。
  • 正确写法:SET lock uuid NX PX 30000(一条命令原子加锁+过期)。
  • 释放锁的误删:直接用 DEL 释放,可能删掉别人的锁(你超时了,别人刚拿到,你 DEL 把别人的删了)。必须用 Lua 脚本先比对 value(唯一标识)再删:
lua
-- 释放锁: 只有 value 是自己才删 (原子)
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

value 必须是当前线程/请求的唯一标识(如 UUID+threadId),释放前校验归属。

三、Redisson 看门狗:自动续期的工程解 ​

手写锁最大的麻烦是"业务没跑完锁就过期了"。Redisson 用**看门狗(Watchdog)**解决:

java
RLock lock = redisson.getLock("stock:1001");
try {
    // 未指定 lease 时, 默认 30s, 看门狗每 10s(30/3) 续期到 30s
    lock.lock();
    int stock = dao.getStock(1001);
    if (stock > 0) dao.decrease(1001);   // 业务务必快
} finally {
    if (lock.isHeldByCurrentThread()) lock.unlock();  // 校验持有者再释放
}

看门狗原理:加锁成功后,客户端启动一个后台定时任务(Netty 时间轮),每 leaseTime/3(默认 10s)发送 PEXPIRE 把锁续到 30s。只要客户端还活着且持锁,锁就不过期;客户端宕机 → 看门狗任务没了 → 30s 后锁自动释放 → 不会死锁。这是"用后台续期换业务不确定时长"的优雅方案。

看门狗的前提与坑

  • 必须 unlock 在 finally:否则业务异常后锁一直被看门狗续期,变成"永久锁"。
  • 指定 lease 则看门狗失效:lock(10, TimeUnit.SECONDS) 明确租约时无看门狗,到时必过期——若业务可能超过 10s,会"超卖"。
  • 锁内不能调慢远程接口:看门狗只续期不加速业务,锁内逻辑慢 = 长时间持锁 = 并发骤降。
  • unlock 次数要匹配:可重入锁 lock 两次要 unlock 两次,否则锁提前释放。

四、ZooKeeper 锁:临时顺序节点抢锁 ​

ZK 用临时节点(Ephemeral)+ 顺序编号实现公平、可靠的锁:

  1. 在 /locks/resource 下创建临时顺序节点 /locks/resource/seq-0000001。
  2. 获取该资源下所有子节点,若自己是最小序号 → 获得锁。
  3. 否则,对前驱节点注册 Watcher 监听其删除。
  4. 前驱释放(会话断开→临时节点自动删)→ 触发自己,重新判断最小 → 拿到锁。

为什么 ZK 锁更可靠? 锁的释放由会话(Session)保证:客户端宕机 → 会话超时 → 临时节点被 ZK 自动删除 → 锁释放,不存在 Redis 的"时钟/GC 导致锁提前过期"问题。且顺序节点天然公平(按申请顺序获锁),还能避免惊群(只监听前驱,不监听所有人)。

五、RedLock 争议:它到底安全吗? ​

Redis 作者 antirez 提出 RedLock:向 N 个独立 Redis 主节点依次加锁,超过半数(≥ N/2+1)成功且总耗时 < 锁有效期 → 视为拿到锁,以此规避"单主故障丢锁"。

但 Martin Kleppmann 发文质疑其理论安全性不足,核心攻击点:

  • 依赖时钟:判断"锁有效"基于"获取耗时 < TTL",但系统时钟可能跳变(NTP 校正、GC 停顿导致线程暂停远超 TTL),出现两个客户端同时持锁的窗口。
  • GC/暂停:客户端 A 拿到锁,发生长时间 GC 停顿,锁在 Redis 侧已过期,客户端 B 拿到锁;A 恢复后两人同时持锁写同一资源 → 数据损坏。
  • 红锁用"多个独立节点"缓解的是"节点故障",没有缓解"客户端时钟/暂停",而后者才是真实风险。

工程结论

  • 强一致 / 金融级场景(错锁=资损):倾向 ZooKeeper / etcd(基于共识,不依赖客户端时钟)。
  • 高并发、可容忍极小概率重入(如幂等防重、非资损):用 Redis + Redisson 看门狗,简单高性能。
  • 不要盲目信 RedLock 的"多数派 = 安全",它解决可用性而非正确性。

六、Redis vs ZK 选型对比 ​

维度Redis(Redisson)ZooKeeper
性能高(内存,~ms)较低(写需 ZAB 多数派)
可靠性弱 CP(主从切换可能丢锁)强一致(ZAB)
锁释放超时 / 看门狗续期会话失效自动释放
公平性需 RedLock/队列,默认非公平顺序节点天然公平
惊群需 Pub/Sub 通知只监听前驱,无惊群
适用高并发、非资损临界强一致、金融级

七、最佳实践与落地 ​

  • 优先用 Redisson 看门狗,避免手写 SETNX+Lua(易错)。
  • 锁粒度要小:锁 stock:1001(商品)而非整张库存表,提升并发。
  • 锁内逻辑务必快,不调用远程慢接口;必要时拆为"锁 + 异步"。
  • 高一致用 ZK/etcd,性能敏感且可容忍极小风险用 Redis。
  • 锁不替代幂等:即使有锁,下游仍可能因重试产生重复写,二者配合。

本节小结

分布式锁的本质是"跨实例互斥 + 可靠的释放机制"。手写 SETNX+EXPIRE 非原子会死锁,必须用 SET NX PX + Lua 校验 value 释放。Redisson 看门狗用后台续期解决了"业务时长不确定"难题,但前提是 unlock 在 finally 且不指定固定 lease。ZK 临时顺序节点靠会话自动释放锁、天然公平无惊群,更可靠。RedLock 多数派只解决可用性不解决正确性,强一致场景仍倾向 ZK/etcd。最后牢记:锁保护临界资源,但不替代业务幂等——重试与消息重复仍会让下游重复写。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 编程题:用 Redisson 实现一个"每日报表只跑一次"的分布式定时任务锁,写出关键代码与失败处理(含看门狗与释放校验)。
  2. 推导题:为什么 RedLock 仍被质疑不安全?构造一个"客户端 GC 停顿 + 时钟跳变"导致双持锁的时序场景,并说明为什么 RedLock 没挡住。给出工程上的替代方案。
  3. 设计题:库存扣减场景,除了分布式锁,你还能用什么无锁方案(Redis Lua 原子扣减、CAS、分段库存)?比较各自优劣与适用边界。
  4. 辨析题:用 ZK 锁时,为什么"只监听前驱节点"比"监听所有兄弟节点"更好?这解决了什么问题(惊群/公平/可靠性)?