Redis 分布式锁误删与提前过期:5 个坑排查修正
Redis 分布式锁误删与提前过期:5 个坑排查修正
一、先对号入座:5 个坑在线上长什么样
线上这 5 个坑里,只有坑 1 和坑 3 会抛异常,其余三个都是静默出错,靠业务日志和 redis-cli 反查。
| 坑 | 现象 | redis-cli 判断依据 |
|---|---|---|
| 1 误删他人锁 | 同一订单被两个线程并发处理;Redisson 抛 IllegalMonitorStateException: attempt to unlock lock, not locked by current thread |
GET lock:order:123 的 value 与当前线程持有的不一致 |
| 2 锁提前过期 | 无报错,偶发重复扣减库存、重复发消息,日志里两段相同业务日志时间重叠 | TTL lock:order:123 返回 -2,key 已不存在 |
3 SETNX + EXPIRE 分两步 |
两条命令之间进程宕机或抛异常,后续请求全部报获取锁超时 | TTL 返回 -1,key 存在但没有过期时间,永久死锁 |
| 4 释放锁非原子 | 与坑 1 同因不同写法:先 GET 比对 value 再 DEL,两步之间锁过期并被他人拿到,仍会误删 |
两步之间锁刚好过期,GET 时 value 还是自己的,DEL 时已不是 |
| 5 主从切换 | 现象与坑 2 类似,但主库写入锁后未同步就宕机,从库提升为新主库,锁凭空消失,两个客户端同时持锁 | 新主库上通常 GET 为 nil、TTL 为 -2;若 key 已被其他客户端重建,TTL 可能完整,但 GET 到的 value 与当前持有者不一致 |
区分方法:坑 2 与坑 5 都表现为锁失效,但坑 2 的 key 已不存在(TTL 为 -2);坑 5 在新主库上通常也查不到 key,若 key 被重建,TTL 可能完整但 value 已不是当前持有者,应看 GET 的 value 是否仍为当前持有者。坑 1 与坑 4 都删了别人的锁,看代码里有没有 value 比对。CLIENT LIST 只列出连接元信息,可作为连接空闲或阻塞的辅助观察,不能确认连接在抢哪把锁;要确认 GET/DEL 是否交错,用 MONITOR 短时观察,释放锁应改用 Lua 保证原子。
二、用 redis-cli 逐条定位是哪个坑
先用 TTL 判断锁是没设过期还是已经消失,再决定往坑 2、3、5 哪个方向查。
redis-cli TTL lock:order:123
- 返回
-1:key 存在但没有过期时间,典型的 SETNX 与 EXPIRE 分两步、第二步没执行,对应坑 3。 - 返回
-2:key 已不存在,可能是业务没跑完锁就到期被回收(坑 2),也可能是主从切换后锁丢失(坑 5)。 - 返回正数:拿剩余秒数与业务 P99 耗时比。剩余时间明显小于 P99,说明锁大概率会在业务结束前过期。
接着看 value 有没有持有者标识:
redis-cli GET lock:order:123
如果你写入的是固定值(如 "1"、"lock"),任何线程都能执行 DEL,坑 1(误删)和坑 4(释放非原子)基本已经成立。正常的设计应是 UUID:线程ID 这类唯一值,释放前用 GET 比对,比对与删除放在同一段 Lua 里。
观察连接空闲或阻塞情况:
redis-cli CLIENT LIST
CLIENT LIST 只列出连接元信息,不能确认连接在抢哪把锁;age 是连接存活时间,不是持锁或命令执行时长。它只能辅助观察连接是否长期空闲或阻塞。判断持锁与争抢要结合 MONITOR、锁 key 的 value + TTL,或业务日志。
释放锁的 GET 与 DEL 如果分成两步,中间锁可能过期并被他人拿到,必须改成 Lua 原子执行。要确认两步之间是否插入了其他命令,用 MONITOR 短时观察命令交错。
短时抓取命令序列,确认真实调用形态:
timeout 10 redis-cli MONITOR | grep -E 'SET|DEL|EVAL'
直接观察是否出现裸 DEL(前面没有 GET 比对),或 SETNX 与 EXPIRE 分成两条命令先后出现。示例输出如下:
"SET" "lock:order:123" "1" "NX" # 未带 PX,且 value 固定
"EXPIRE" "lock:order:123" "30"
"DEL" "lock:order:123" # 裸 DEL
三条命令同时出现,坑 1、3、4 需要一起修。MONITOR 会放大 Redis 负载,只在低峰或单实例上短时使用,不要在生产高峰期长时间挂着。
三、修正写法:一条命令加锁 + Lua 原子释放 + 续期
加锁用一条命令完成,不要 SETNX 后再 EXPIRE:
SET lock:order:123 "{uuid}:{threadId}" NX PX 30000
value 必须唯一(UUID + threadId),后面释放锁靠它判断归属。Spring Data Redis 对应写法:
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent("lock:order:123", uuid + ":" + Thread.currentThread().getId(),
Duration.ofSeconds(30));
Duration 重载是否可用以你的 Spring Data Redis 版本为准。
释放锁用 Lua 把「比对 + 删除」做成原子操作:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
调用 EVAL "<脚本>" 1 lock:order:123 "{uuid}:{threadId}"。返回 1 表示是自己删掉的;返回 0 说明锁已不属于自己(多半已过期并被别人持有),此时打日志告警,不要重试删除。
TTL 取值:锁超时时间 ≥ 业务 P99 耗时 × 3。如果使用手写 SET NX,必须设置 TTL;估不出耗时也不能省略,应设一个足够大的 TTL,并自行实现续期。只有 Redisson 的 lock()(带看门狗)等自续期机制,才可以在不手设 leaseTime 时依赖续期;否则进程崩溃后会留下没有过期时间的锁,导致永久死锁。
Redisson 的看门狗只在调用 lock()(不传 leaseTime)时启用,默认 lockWatchdogTimeout=30000ms,后台每 1/3 周期即 10s 续期一次。写成 lock(30, TimeUnit.SECONDS) 就关闭了看门狗,等于回到提前过期那个坑。续期只在业务未结束时发生,业务结束立即释放。
sequenceDiagram
participant C as "业务线程"
participant R as "Redis"
C->>R: "SET lock NX PX 30000"
R-->>C: "OK TTL 30s"
loop "每 10s 续期一次"
C->>R: "PEXPIRE lock 30000"
R-->>C: "TTL 回到 30s"
end
C->>R: "EVAL 释放脚本"
R-->>C: "返回 1"
四、主从切换与 Redlock:什么时候现有写法不够用
单实例 + 唯一 value + Lua 只解决「误删」,主从切换时锁仍可能丢。原因是复制异步:SET ... NX PX 返回 OK 时,锁只写在当前 master 内存里。如果这条命令还没同步到 replica,master 就宕机,故障转移后新 master 上没有这把锁,另一个客户端能拿到同名锁。
在写命令后紧跟 WAIT 并检查返回值,可以缓解:
SET lock:order 8f1c4a9e NX PX 30000
WAIT 1 100
WAIT numreplicas timeout 返回确认的副本数。若返回值 < 1,说明 100 ms 内没有副本确认,此时 SET 已成功但锁可能尚未复制,应视为加锁失败或走降级逻辑。WAIT 本身会增加阻塞和可用性风险,它只缩小窗口,不保证强一致:已确认的副本也可能随后故障,延迟也随网络上升,具体幅度取决于你的环境。
对丢锁零容忍时,把锁换到 CP 存储——ZooKeeper 临时顺序节点或 etcd lease,写需过半确认,会话断开后节点自动删除。
Redlock 需要 5 个独立 master,客户端逐个加锁,拿到多数派(≥3)且总耗时小于 TTL 才算成功,还要扣除时钟漂移。它对时钟假设敏感,要额外维护 5 个实例,适合多机房且能接受额外延迟的场景。
| 方案 | 一致性 | 性能 | 运维成本 | 适用场景 |
|---|---|---|---|---|
| 单实例 + Lua | 主从切换可能丢锁 | 最高,单次 RTT | 最低,复用现有 Redis | 绝大多数业务幂等与串行化 |
| Redisson | 同上,附带续期与可重入 | 高,多一次续期开销 | 低,仅引依赖 | 业务耗时不可预估、需要可重入 |
| Redlock | 较高,依赖时钟假设 | 低,5 次写入取多数派 | 高,需 5 个独立实例 | 跨机房强互斥 |
| ZooKeeper | 强一致,临时顺序节点 | 中,写需过半确认 | 中高,需单独维护集群 | 对丢锁零容忍的选主/关键串行 |
五、上线前检查清单与边界
加锁与释放自检,逐项过一遍:
- 加锁必须是单条命令:
SET lock:order:1001 "uuid:threadId" NX PX 10000。见到SETNX紧接EXPIRE就改掉。 - value 里带 UUID + 线程 ID。只用固定字符串(如
"1")会让 Lua 比对形同虚设,同机同线程重入时也会误判。 - 释放走 Lua:
GET比对 value 相等才DEL,比对失败直接返回 0,不做删除。 - Redisson 检查
tryLock(waitTime, leaseTime, unit)是否传了leaseTime。传了就等于关掉看门狗,锁不再自动续期。 - 获取锁失败要有等待上限,超时后返回 503(或走降级逻辑),不要无限阻塞占用请求线程。
- 业务异常路径同样要在
finally中释放,try里抛异常不释放会拖到过期。
监控两条:
避免周期性全量扫描所有 lock:*,大 keyspace 下有成本。监控具体锁 key 的 TTL 即可:
redis-cli TTL lock:order:1001
TTL 返回 -1 说明该 key 没有过期时间,立即告警——这正是坑 3 复活的信号。如果必须扫描全量锁 key,使用 --scan 时限定 COUNT 并低频执行。另一条是监控持锁时长 P99 与 TTL 的比值,以及续期是否失败。持锁 P99 接近 TTL 时,要么调大 TTL,要么确认续期机制在正常工作。
边界:
Redis 锁不能当唯一幂等手段。关键写操作仍要数据库唯一索引兜底,锁只是降低并发冲突。跨机房部署时,TTL 要显著大于网络抖动(RTT 波动 + 客户端停顿),否则到期误判会放大误删问题。
上线前验证: 压测把单次业务耗时打到当前 TTL 的 80% 以上,观察业务日志是否出现双写记录,再决定 TTL 与续期参数怎么调。