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 与续期参数怎么调。


相关阅读