Redis互斥锁死锁的根本原因是锁释放缺乏兜底机制:线程在setIfAbsent成功后、DEL执行前崩溃,导致锁Key长期残留,后续请求全部阻塞。

Redis互斥锁为什么会在业务异常时变成死锁
根本原因不是锁本身,而是锁释放逻辑没兜底:只要持有锁的线程在 setIfAbsent 成功后、DEL 执行前崩溃(比如 OOM、GC 停顿超时、网络中断、未捕获异常),锁 Key 就会一直留在 Redis 中。后续所有请求调用 setIfAbsent 都返回 false,全部进入等待/重试分支——没人能进临界区,也没人能删锁,形成事实上的“分布式死锁”。
这不是理论风险。线上常见于以下场景:
- 查库 SQL 超时或连接池耗尽,导致业务代码卡在数据库层,锁超时前无法执行
delete - 重建缓存时触发远程 HTTP 调用,对方响应慢或失败,线程 hang 住
- 使用
try-finally但 finally 块里又抛了新异常(比如 Redis 连接断开),delete被跳过
setIfAbsent 必须带过期时间,且不能只靠 try-finally
加过期时间是防死锁的第一道保险,但仅靠 try-finally 删除锁远远不够。关键点在于:过期时间必须比业务最大预期耗时长,但又不能太长(否则阻塞太久)。一般按「DB 查询 P99 + 缓存写入 + 网络抖动」估算,建议设为 3~10 秒。
示例中这个写法是危险的:
redisTemplate.opsForValue().setIfAbsent("lock:user:123", "1", 10, TimeUnit.SECONDS);
// ... 业务逻辑
redisTemplate.delete("lock:user:123"); // 没有兜底,一旦这行失败就完蛋
更安全的做法是:锁 Key 的过期时间必须独立生效,不依赖任何删除动作;同时,业务逻辑里要避免在 finally 外部做不可靠操作。
如何避免递归重试引发线程池打满或栈溢出
很多示例用 Thread.sleep(100); return getUserWithLock(id); 实现重试,这是典型陷阱。高并发下,上千线程同时陷入自旋+递归调用,极易吃光线程栈或耗尽 Tomcat 线程池。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
推荐做法是「轮询 + 降级」而非「递归重试」:
- 拿到锁失败后,最多休眠 1~3 次(如 50ms、100ms、150ms),之后直接查一次缓存并返回结果(哪怕为空)
- 若缓存仍空,可返回预设兜底值(如
"default_config"),或抛出轻量级异常由上层熔断 - 绝对不要在 Web 请求线程里做无上限 sleep 或递归调用
伪代码示意:
for (int i = 0; i < 3; i++) {
Object cached = redis.get(key);
if (cached != null) return cached;
Thread.sleep((long) Math.pow(2, i) * 50); // 指数退避
}
return fallbackValue; // 不再重试,快速失败
双重检查为什么不能省,以及它在哪失效
双重检查(Double-Check)指:抢锁成功后,再次查缓存。这步不能跳过,否则可能造成「缓存覆盖」——比如线程 A 抢锁后查库耗时久,线程 B 在 A 写缓存前也抢到了锁(因 A 的锁已过期被自动释放),两者都写缓存,但旧数据后写入,覆盖了新数据。
但它只在「锁自动过期」场景下有效。如果锁过期时间设得太短(比如 1 秒),而查库要 1.2 秒,那双重检查就形同虚设:A 的锁已失效,B 又进来了。
所以真正关键的是:锁过期时间 > 最大可能业务耗时,且必须配合原子性写缓存(如用 set(key, value, expire) 一步完成,别先 set 再 expire)。
最易被忽略的一点:互斥锁只解决「单个热点 key 击穿」,对「多个 key 同时过期」(雪崩)或「恶意构造不存在 id」(穿透)完全无效。别指望一个锁包打天下。

















