Redis分布式锁本身不防击穿和死锁,必须靠业务层协同解决:SETNX+EXPIRE非原子操作易致死锁;锁值未校验、过期时间不合理或粒度不匹配会引发击穿与误删。

Redis 分布式互斥锁本身不防击穿,也不自动防死锁;它只提供基础互斥能力。击穿和死锁必须靠业务层加锁逻辑、超时控制、值校验释放三者协同解决。
为什么 SETNX + EXPIRE 会引发死锁和击穿
常见错误是把 SETNX 和 EXPIRE 拆成两条命令执行。如果 SETNX 成功但 EXPIRE 失败(如网络中断、Redis 写入后主从同步失败),锁就永远不释放——这就是典型死锁源头。同时,这种锁不绑定缓存 key 生命周期,当缓存过期瞬间多个请求都卡在“等锁”阶段,一旦第一个请求写完缓存,后续请求仍可能因锁未释放或重试策略不当而继续穿透数据库。
-
SET key value NX成功后崩溃,没来得及设过期时间 → 锁永久残留 - 锁过期时间(如 30 秒)短于业务查询+回填耗时(如 45 秒)→ 锁提前释放,第二个请求抢锁成功,双写数据库
- 用固定字符串(如
"1")作锁值,DEL lock:key直接删 → 高并发下极易误删别人持有的锁
必须用 SET 命令原子加锁 + Lua 安全释放
加锁必须一步到位,释放必须带值校验。Redis 的 SET key value NX PX milliseconds 是唯一推荐的加锁入口,它把存在性判断、赋值、过期时间三件事打包成原子操作。
- 锁值不能写死,要用唯一标识,比如
UUID.randomUUID().toString() + "-" + hostName,避免容器漂移或多实例冲突 - 释放锁必须用 Lua 脚本,且只在值匹配时才
DEL:if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end - 不要用
redisTemplate.opsForValue().setIfAbsent()单独设值再手动expire(),Spring Data Redis 的这个方法底层仍是分两步,不可靠
如何防止击穿:加锁不是终点,是缓存重建流程的起点
拿到锁之后,不能直接查库——要先二次检查缓存是否已被其他线程写入。这是“双重检测”(Double-Check)的核心,否则大量请求排队获取锁后,仍会重复查库。
- 加锁成功后,立刻
GET cache:key,若命中则直接返回,跳过数据库查询 - 只有缓存确实为空时,才查库、写缓存、再返回;空结果也要缓存(如
SET cache:key "" EX 60),防穿透 - 锁过期时间必须大于「最坏情况下的查库+序列化+写缓存」耗时,建议按 P99 数据库响应时间 × 2 再上浮 20%
- 别依赖 Redlock 多节点——它不解决击穿,也不保证锁不丢;单实例 Redis + 正确原子操作 + 合理超时,对缓存重建类场景已足够
看门狗不是可选项,而是生产环境刚需
业务处理时间不可控,锁过期时间又不能无限制拉长。这时候必须引入自动续期机制,也就是“看门狗”。Redisson 的 watchdog 默认每 10 秒检查一次锁剩余时间,若大于 1/3 就自动延长为 30 秒。自己实现的话,需起独立线程监听锁 TTL 并定期 PEXPIRE。
- 没有看门狗,只要业务慢于锁过期时间,就必然出现“锁失效后多客户端并发写缓存”
- 看门狗续期必须校验锁值,否则可能给别人的锁续命
- 注意时钟漂移:Docker 容器或虚拟机时间不同步会导致
PEXPIRE计算异常,建议宿主机开启 NTP 同步
真正容易被忽略的点是:锁粒度和缓存 key 必须严格对齐。比如锁的是 lock:product:123,那缓存 key 就得是 cache:product:123,不能一个带前缀一个不带,也不能用用户 ID 替代商品 ID 去加锁——否则根本拦不住针对同一资源的并发请求。


















