Redis 分布式锁:从 SETNX 误删到看门狗自动续期的原理与实践

分布式锁几乎是后端面试和工程实践里出现频率最高的话题之一。但"会用 Redisson"和"真正理解它为什么安全"之间,隔着几个经典的坑:业务没执行完锁就过期、旧持有者误删新持有者的锁、持锁节点宕机后会不会死锁。

本文不堆概念,先用一个可直接运行、零外部依赖的工程把这些问题逐个复现出来(互斥、误删、token 校验、看门狗续期、崩溃释放),再对照真实 Redis/Redisson 的命令与源码级行为解释背后的原理。所有现象都附实测输出。

1. 为什么需要分布式锁

在单机里我们用 synchronized 或 ReentrantLock 保证临界区互斥,但它们只在单个 JVM 进程内有效。当服务多实例部署时,同一个操作可能落在不同节点上,JVM 锁互相不可见:

css 复制代码
用户下单 ──> 网关 ──> 服务实例A(JVM 锁)
                  └─> 服务实例B(另一把 JVM 锁,互不相干)

于是需要一个所有实例共享、且保证操作原子性的协调者,Redis 是最常见的选择。但"用 Redis 实现一把正确的锁"比想象中讲究,下面逐层演进。

2. 第一版:SETNX 加锁,DEL 解锁(有坑)

最直觉的写法:

scss 复制代码
// 加锁:不存在才成功
Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(10));
if (!ok) throw new LockException("获取锁失败");
// 业务...
// 解锁
redis.delete(key);

SET key value NX PX ttl 是原子的,互斥没问题。但这版有两个致命缺陷:

  1. 解锁不校验持有者 :任何客户端都能 DEL 掉这把锁;
  2. value 用固定值 "1" :无法区分锁是谁的。

3. 复现:旧持有者如何"误删"新持有者的锁

经典故障时序:

css 复制代码
A 加锁成功(TTL=300ms)
A 业务卡顿(GC / 慢查询 / 下游超时)超过 300ms,锁自动过期
B 加锁成功,拿到同一把锁
A 缓过来,执行 DEL ------ 把 B 的锁删掉了
C 此时也能加锁成功 ------ 互斥被彻底破坏

我在演示工程里用内存原语复现了这一过程,实测输出:

css 复制代码
===== 场景2:无条件 DEL 误删别人的锁(错误实现) =====
  A 拿到锁,TTL=300ms,业务超时睡 500ms
  锁过期后 B 拿到锁:true
  A 执行 DEL 后,锁是否还在:false(false=B 的锁被 A 误删!)

根因是:锁的所有权在 TTL 到期后已经发生了转移,但解锁动作没有检查"当前这把锁还是不是我的"。

4. 第二版:唯一 token + Lua 比对删除

修法是给每次加锁生成唯一 token(通常用 UUID,Redisson 里是 客户端实例ID:线程ID),解锁时用 Lua 保证"判断 + 删除"原子:

ini 复制代码
String token = UUID.randomUUID().toString();
redis.opsForValue().setIfAbsent(key, token, Duration.ofSeconds(10));
​
// 解锁 Lua:只删自己的锁
String UNLOCK_LUA = """
  if redis.call('get', KEYS[1]) == ARGV[1] then
      return redis.call('del', KEYS[1])
  else
      return 0
  end
  """;

为什么必须用 Lua 而不是先 GET 再 DEL?因为这是两条命令,中间同样存在竞态:你 GET 比对成功、正准备 DEL 的瞬间锁可能刚好过期并被别人拿走。Lua 在 Redis 单线程内一次执行完,"比较 + 删除"不可分割。

实测同一时序下,安全实现不会误删:

arduino 复制代码
===== 场景3:比对 token 再删(正确实现) =====
  B 拿到锁:true
  A 尝试释放锁是否成功:false(期望 false)
  B 的锁是否仍在:true(期望 true)

演示工程里对应的原子原语(内存版用 ConcurrentHashMap.compute 模拟 Redis 单命令/Lua 的原子性):

ini 复制代码
public boolean compareAndDelete(String key, String token) {
    long now = System.currentTimeMillis();
    boolean[] removed = {false};
    store.compute(key, (k, old) -> {
        if (old != null && old.expireAt() > now && old.token().equals(token)) {
            removed[0] = true;
            return null;
        }
        return old;
    });
    return removed[0];
}

5. 新的矛盾:TTL 设短了提前失效,设长了崩溃后等死

token 校验解决了"误删",但 TTL 的两难依然存在:

  • TTL 设短:业务稍微慢一点锁就提前过期,并发安全被破坏;
  • TTL 设长:持锁节点一旦宕机,其他节点要等很久才能拿到锁,可用性差。

固定 TTL 本质是在"安全性"和"可用性"之间做静态妥协。看门狗(Watchdog)机制就是用来打破这个两难的:锁先给一个租期,只要持锁客户端还活着,就后台自动续期;客户端死了续期停止,锁到期自动释放。

6. 看门狗复现:业务比租期长,锁不过期;进程崩了,锁会释放

演示工程里看门狗每 lease/3 时间执行一次"仅当 token 仍属于我才续期":

ini 复制代码
ScheduledFuture<?> renewal = scheduler.scheduleAtFixedRate(() -> {
    boolean renewed = redis.compareAndExpire(key, token, leaseMs);
    // 续期也必须带 token 校验,不能无脑 PEXPIRE
}, period, period, TimeUnit.MILLISECONDS);

实测:初始租期只有 300ms,业务执行 1000ms,期间另一个线程反复抢锁:

less 复制代码
===== 场景4:看门狗自动续期(业务 1000ms > 初始 TTL 300ms) =====
  A 拿到锁并启动看门狗,开始执行业务...
    [watchdog] 续期 成功 key=order:lock:1001:watchdog
    ...(每 100ms 续期一次,共 10 次)
  业务执行期间 B 抢到锁次数 = 0(期望 0,锁被看门狗续命)
  A 业务完成,主动释放锁

而当持锁者"崩溃"(续期任务停止、也不主动解锁),锁会在一个租期后自动释放,不会死锁:

arduino 复制代码
===== 场景5:持锁者崩溃后锁自动释放(防死锁) =====
  A 拿到锁后崩溃,停止续期
  崩溃后 200ms,B 抢锁是否被挡住:true(期望 true,锁尚未过期)
  崩溃后约 600ms(超过 TTL),B 拿到锁:true(期望 true)

这两条性质合起来正是分布式锁想要的:活着就一直持有,死了能自动让位。

7. Redisson 是怎么做的(源码级行为)

Redisson 的可重入锁 RedissonLock 与上面的演示一一对应,理解这几个关键点就够了:

锁结构不是普通 String,而是 Hash。 field 是 客户端UUID:线程ID,value 是重入次数,从而支持同一线程可重入:

yaml 复制代码
key: myLock
  field: 7a8c..:1   value: 1   // 同一线程再次 lock,value 递增为 2

加锁是一段 Lua :判断 key/field 是否存在,不存在则 hset 并 pexpire;存在且是本线程则重入计数 +1。

看门狗的触发条件很关键:

  • lock.lock() 或 tryLock(waitTime, -1, unit) 不传 leaseTime 时,才启用看门狗;
  • 默认 lockWatchdogTimeout = 30s,每 1/3(即 10s)续期一次;
  • 续期也是 Lua:只有 field 仍是自己时才 pexpire 回 30s。

一旦你显式传了 leaseTime,例如 tryLock(10, 30, SECONDS),看门狗就不生效,锁到期自动释放、不续期。这是非常容易踩的点------很多人以为用了 Redisson 就一定有自动续期,其实带固定超时的加锁是没有的。

解锁 Lua 做三件事:判断 field 属不属于自己、重入计数减一、归零后删除 key 并发布一条解锁消息(唤醒等待者重新抢)。

8. 可重入与等待:容易被忽略的细节

  • 可重入:同一线程嵌套调用加锁方法不会自死锁,靠 Hash 计数;但释放次数要和加锁次数匹配。
  • 等待与自旋:拿不到锁时 Redisson 不是疯狂自旋,而是订阅解锁消息 + 按剩余等待时间挂起,被唤醒后重试,降低 Redis 压力。
  • 锁与事务的边界 :锁要在事务外 获取和释放(lock → 开事务 → 提交 → unlock)。如果在 @Transactional 方法内加解锁,可能出现"锁已释放、事务还没提交",其他线程拿到锁读到旧数据。
  • 锁只挡"用同一把锁的人" :所有相关路径必须用同一个 key、同一套加解锁逻辑;DB 唯一约束等兜底依然重要(这点和幂等是相通的)。

9. Redlock 与极端情况:别神化分布式锁

  • 主从架构下,master 加锁成功后、key 还没同步到 replica 就宕机,新主上可能没有这把锁。Redlock 通过对多个独立节点"多数派加锁"来缓解,但它的争议(时钟漂移、GC 停顿、网络分区)一直存在,Martin Kleppmann 与 antirez 有过著名争论。
  • 工程上的务实选择:绝大多数业务用单 Redis + 看门狗 + token 校验就够了;对强一致要求极高的资源(资金、库存),不要只依赖锁,要用数据库唯一约束、状态机或原子操作兜底。
  • 锁是"尽量减少并发冲突",不是"证明业务绝对正确"。

10. 方案对比

实现 互斥 防误删 业务超 TTL 崩溃释放 可重入
SETNX + 固定值 + DEL ✅ ❌ 锁提前失效 靠 TTL ❌
SETNX + token + Lua 解锁 ✅ ✅ 锁提前失效 靠 TTL ❌(可自行扩展)
看门狗续期(Redisson 默认) ✅ ✅ 自动续期 停续期后 TTL 释放 ✅
看门狗 + DB 唯一约束兜底 ✅ ✅ ✅ ✅ ✅

11. 可运行源码

工程基于 Spring Boot 3.2.5 / Java 17,零外部中间件,用 FakeRedis 模拟 Redis 原子原语,启动后自动跑完 5 个场景,放在仓库掘金专区:

arduino 复制代码
mvn spring-boot:run

迁到生产时,把 FakeRedis 的三个方法替换为:setIfAbsent、解锁 Lua、续期 Lua(或直接用 Redisson 的 RLock),演示结论完全一致。

12. 小结

  • SETNX + DEL 不校验持有者,会在锁过期转移后误删别人的锁;加唯一 token 并用 Lua"比对再删"解决。
  • 固定 TTL 在"提前失效"和"崩溃慢释放"之间两难,看门狗用自动续期 + 宕机停续期同时满足安全与可用。
  • Redisson 只有在不显式指定 leaseTime 时才启用看门狗(默认 30s 租期、10s 续期);锁用 Hash 实现可重入。
  • 注意锁在事务外、同 key 统一使用;强一致场景用 DB 约束兜底,Redlock 不是银弹。

如果这篇对你有帮助,欢迎点赞收藏。你们线上的分布式任务/库存扣减用的是 Redisson 还是自研 Lua?评论区聊聊踩过的坑。

相关推荐
真上帝的左手2 小时前
30. 技术专题-‌应用框架组件-缓存-Redis
数据库·redis·分布式·缓存·中间件
bksczm2 小时前
C++Redis开发(Ubuntu 22.04)
数据库·redis·缓存
斯普信专业组2 小时前
Kafka 3.9.1 三节点集群部署方案(ZooKeeper 模式)
分布式·zookeeper·kafka
goehou3 小时前
缓存该删还是该改,LLM 服务里的双写问题
redis·缓存·llm·一致性·系统设计
余槐i3 小时前
Spring Boot 3.2 偶发500无日志:线程池复用导致请求上下文丢失
spring boot·redis·后端·mysql·性能优化
真上帝的左手4 小时前
30. 技术专题-‌应用框架组件-搜索引擎-ES
大数据·分布式·elasticsearch·搜索引擎·中间件
IT规划师4 小时前
Redis 数据类型全解 - ZSet:两个编码,七种玩法(五)
redis·.net
我不会起名字32213 小时前
Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭——ZSet 延迟队列 Go 实现
redis·go·延迟队列·zset·订单
利威尔xu15 小时前
Go IM 源码实战(13):30 个会话只有 2 个红点,未读数错在三条路径
redis·后端·微服务·面试·golang