2937 字
约 9 分钟
15
S12-X06 · Redis 分布式锁:从 SETNX 到 Redisson 看门狗

两个实例同时处理同一笔退款,库存扣了两次,两条日志的时间戳只差 3 毫秒。单机时代一个 synchronized 就结束的事,到了多副本部署就必须把互斥搬出 JVM。

Redis 锁的问题在于"三行代码看着能跑",真正决定成败的是三件事:加解锁是否原子、持有者怎么校验、锁过期与业务耗时怎么对齐。下面按"要求 → 错法 → 正法 → 边界"拆。


1. 四条要求,逐条对应实现

要求 含义 实现上的硬约束
互斥 同一时刻只有一个持有者 加锁必须是单条原子命令,不能先查后写
不死锁 持有者崩溃后锁最终能释放 锁必须带 TTL,且 TTL 由服务端时钟裁决
谁加谁解 只能删自己加的锁 value 存唯一标识,解锁前比对
可重入 同一线程再次加锁不阻塞自己 value 里存"持有者 + 重入计数",用 Hash

第五条"高可用"常被混进这四条:它是部署问题,不是锁语义问题,主从异步复制下锁可能丢,见第 8 节。


2. SETNX 版本的三处漏洞

// 错误示范:三行代码,三个洞
Boolean ok = redis.opsForValue().setIfAbsent("lock:order:8848", "1");
redis.expire("lock:order:8848", 30, TimeUnit.SECONDS);
// ... 业务逻辑
redis.delete("lock:order:8848");
漏洞 触发条件 后果
SETNX + EXPIRE 非原子 两条命令之间进程被杀、连接断 key 永久存在,锁永远不释放
解锁不校验持有者 A 超时后锁被 B 拿到,A 恢复执行完删 key 删掉 B 的锁,互斥失效
超时后误删别人的锁 业务耗时 > TTL,锁已易主 同上,且表现为"偶发"并发冲突

第二、三条的根因是同一个:删除动作没有身份校验。而且它表现为"偶发",测试环境很难复现。


3. 正确的加锁与解锁

加锁用一条命令把"不存在才写"和"设过期"绑在一起,这是唯一正确的加锁形态:

# 抢锁成功返回 OK,失败返回 nil
SET lock:order:8848 6f1c9a3e-2b71-4d0a-9c55-8e1f7  NX PX 30000

NX 不存在才写,PX 30000 即 30 秒过期,由服务端计时——客户端本地时间不可信。

解锁必须"比对后删除",两步要原子,只能走 Lua:

-- unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0
String UNLOCK = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK, Long.class);
redis.execute(script, List.of("lock:order:8848"), "6f1c9a3e-2b71-4d0a-9c55-8e1f7");

GET 与 DEL 之间只要有别的命令插入,校验就形同虚设。Lua 在 Redis 单线程里整段执行、不可打断,这才是"原子"的含义。


4. 锁超时定多久

这是整个设计里最没有标准答案的一格,但有一条底线:TTL 必须大于业务耗时的上界,否则互斥本身就破了。

业务耗时特征 建议 TTL 说明
稳定在 50–200ms 的写操作 3–5 秒 留 10 倍以上余量,容忍一次 GC 停顿
波动大、有外部调用(HTTP/MQ) 不设固定值,走看门狗 外部依赖的 P99 不可预测
批处理、跑几分钟 看门狗 + 分段提交 单段太长就该拆任务

把 TTL 调到 10 分钟来"保证不超时",等于把故障恢复时间也一起调到 10 分钟。TTL 是故障恢复参数,不是业务耗时参数,冲突时用续期解决。


5. 看门狗:把 TTL 变成动态的

Redisson 的做法是:加锁成功后起一个后台任务,在锁到期前定期把它续回原值,业务跑多久锁就活多久;业务结束主动解锁时停掉续期。

参数 默认值 说明
lockWatchdogTimeout 30000 ms 锁默认租期,也是看门狗的基准
续期间隔 租期 / 3 = 10000 ms 每次把过期时间重置为 30 秒
续期停止条件 主动 unlock / 客户端 shutdown / 续期连续失败 三种情况都会让锁自然过期
进程被 kill -9 续期任务随之消失 最长 30 秒后锁自动释放,这是设计意图
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
config.setLockWatchdogTimeout(30_000L);   // 单位毫秒
RedissonClient redisson = Redisson.create(config);

看门狗不是"防一切"。它防的是"业务没跑完锁先过期",防不了进程假死却仍在续期(网络分区时客户端以为自己续上了),也防不了业务里的死等。前提是续期任务与业务线程同 JVM,容器被驱逐时两者一起消失。


6. 可重入:Hash + 计数

同一个线程再次加锁,如果 key 已存在就判定失败,会把自己锁死。可重入的实现是把 value 从字符串换成 Hash:

-- 加锁:field = 客户端唯一标识(UUID:threadId)
if redis.call('EXISTS', KEYS[1]) == 0 then
  redis.call('HSET', KEYS[1], ARGV[1], 1)
  redis.call('PEXPIRE', KEYS[1], ARGV[2])
  return 1
end
if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1 then
  return redis.call('HINCRBY', KEYS[1], ARGV[1], 1)
end
return 0

解锁时 HINCRBY -1,归零才 DEL 并广播唤醒等待者。细节:每次重入都要重置过期时间,否则外层 TTL 会被内层节奏带偏。


7. tryLock 的参数决定行为

RLock lock = redisson.getLock("lock:order:8848");
// 最多等 3 秒,租期交给看门狗(leaseTime 缺省 = -1)
boolean ok = lock.tryLock(3, TimeUnit.SECONDS);
if (ok) {
  try { /* 业务 */ } finally { lock.unlock(); }
}
调用形式 等待行为 租期行为
tryLock() 只试一次,立即返回 看门狗续期
tryLock(waitTime, unit) 在 waitTime 内重试 看门狗续期(关键)
tryLock(waitTime, leaseTime, unit) 同上 固定租期,看门狗关闭
lock() 无限等待 看门狗续期

最常见的误用是传了 leaseTime 却以为看门狗还在——传了就停止续期,业务超时锁照样丢。

等待机制本身也有两代做法:

方式 原理 代价
轮询(早期) 循环 SET NX + sleep 空转消耗连接与 CPU,唤醒延迟取决于 sleep 间隔
订阅发布(Redisson) SUBSCRIBE redisson_lock__channel:{lock},解锁时 PUBLISH 唤醒及时,但多一条连接与一个订阅关系

8. 主从切换下的锁失效,与 RedLock 的边界

Redis 主从是异步复制。加锁成功的 SET NX 写在主库,还没同步到从库时主库宕机,哨兵提拔从库——新主库里没有这把锁,另一个客户端立刻能加锁成功——这是异步复制的固有窗口,不是配置问题。

RedLock 的思路是向 5 个独立主节点依次加锁,拿到 ≥3 个才算成功。它的争议集中在两点:客户端 GC 停顿会让"我以为还在锁内"的假设失效;多个节点的时钟漂移会侵蚀有效租期。业界的结论是务实的:

场景 建议
锁只为"减少重复计算" 单实例锁足够,丢了最多多做一次
保护金额、库存等强一致写 不能只靠 Redis 锁,用 DB 唯一约束 / 条件更新兜底
需要严格互斥(如选主、配置变更) 用 ZooKeeper / etcd 这类有共识的协调服务
已上 RedLock 且能接受其前提 可用,但必须配"锁内操作幂等"作为第二道防线

锁的正确用法是"降低并发冲突的概率",不是"保证互斥的数学必然"。凡是把正确性完全押在锁上的设计,都会在故障转移那天暴露。


9. 与其他互斥方案的对照

方案 释放机制 一致性 主要代价
Redis 单实例锁 TTL 到期 异步复制可能丢锁 简单、快;需要幂等兜底
Redisson 看门狗 续期 + TTL 同左 续期流量;依赖客户端存活
数据库乐观锁 UPDATE ... WHERE version = ? 强(单库) 冲突时重试;热点行锁竞争
数据库悲观锁 SELECT ... FOR UPDATE 强 连接被占住,长事务拖垮连接池
ZooKeeper 临时顺序节点 会话断开自动删 强(ZAB) 运维成本、写性能低于 Redis

乐观锁和 Redis 锁不是替代关系:前者管"更新是否基于最新版本",后者管"这段时间内别让两个人同时进来"。前者能兜住后者的失效,写金额的代码里两样都该有。


10. 排错与常见坑

现象 原因 处理
偶发并发重复写 TTL 小于业务耗时,锁已易主 上 tryLock(wait, unit) 让看门狗接管
解锁报 IllegalMonitorStateException 当前线程不是持有者,或锁已过期 检查是否跨线程 unlock;确认 TTL
大量请求卡在同一把锁上 锁粒度过粗(如 lock:order 锁全站订单) 收细到 lock:order:{id}
加锁成功但业务串行严重 把锁套在长事务 / 外部 HTTP 调用外层 锁只包住临界区那几行
重启后立刻并发冲突 kill -9 后旧锁还在 TTL 内 新实例走 tryLock 等待,别假设锁不存在
解锁脚本偶发返回 0 value 不匹配,说明锁已过期被别人拿走 属于正常路径,需记录并告警业务超时

三条值得单独说:锁粒度过粗把并发热点变成排队;长事务里持锁让 TTL 与事务时长互相拉扯;解锁没走 Lua在低并发下永远测不出来,压测才现形。


11. 边界:这些情况别硬上 Redis 锁

需求 更合适的做法
只需要"同一秒内不重复提交" 幂等 token + 唯一索引,不需要锁
跨行、跨表的复杂一致性 数据库事务 + 隔离级别
任务调度只跑一份 调度框架的选主(XXL-Job / Quartz 集群)
秒杀级热点扣减 Lua 原子扣减 + 异步落库,锁即瓶颈
严格 FIFO 排队 队列 / 信号量,不是互斥锁

Redis 锁的甜区是临界区毫秒级、冲突不密集、失败可容忍重试。离开这个区间,要么换协调服务,要么改成"根本不需要锁"的设计。


12. 自测

  1. SETNX 之后紧跟 EXPIRE,在哪一个时刻崩溃会造成死锁?
  2. 为什么解锁必须用 Lua,而不是先 GET 比对再 DEL?
  3. tryLock(3, 10, TimeUnit.SECONDS) 这个调用里,看门狗还生效吗?
  4. 看门狗在哪些条件下会停止续期?停止之后锁靠什么释放?
  5. 主从切换导致锁失效的根因是什么?RedLock 解决了它,又引入了什么假设?
  6. 一笔扣减库存的写操作,为什么"Redis 锁 + DB 乐观锁"要同时存在?
S12-X06 · Redis 分布式锁:从 SETNX 到 Redisson 看门狗
https://www.clxhxhhr.top/posts/5804/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。