外观
从误删到零事故:Redis watchdog 锁续期实战
问题背景:长事务为什么会把分布式锁用坏
在订单结算、库存扣减、批量对账等场景中,我们常使用 Redis 分布式锁保证同一业务键同一时间只被一个实例处理。典型实现是 SET key value NX PX 30000:加锁成功后执行业务,最后删除锁。短任务下它工作良好,但一旦业务耗时超过锁的 TTL,问题会连续发生:锁先自动过期,第二个线程拿到锁;第一个线程结束后又误删第二个线程的锁;随后第三个线程继续进入临界区,互斥彻底失效。
这不是简单把过期时间调大就能解决的问题。TTL 太短,长事务会丢锁;TTL 太长,节点宕机后其他节点要等待很久,可用性下降。真正稳妥的思路是:初始 TTL 只用于故障兜底,业务仍在运行时由后台 watchdog 自动续期;业务结束或节点宕机后,续期停止,锁仍能按 TTL 释放。
方案对比:固定 TTL、手动续期与 watchdog
固定 TTL 最简单,适合执行时间可预测的短任务,但无法覆盖慢 SQL、下游重试、Full GC、网络抖动等不确定耗时。手动续期由业务线程在处理过程中定期调用 EXPIRE,看似灵活,却容易漏调,而且续期操作必须校验持有者,否则仍然可能误删别人的锁。
watchdog 是更工程化的方案:加锁成功后启动后台任务,每隔小于 TTL 的周期检查业务线程是否存活;若仍存活且锁还属于当前线程,就用 Lua 脚本原子延长过期时间;业务完成时取消任务并释放锁。它把续期逻辑从业务代码中抽离出来,同时保留宕机后的死锁兜底能力。
Redis 锁结构:把持有者与续约次数存清楚
推荐锁键使用业务前缀加唯一资源标识,例如 lock:order:20240501001;值使用全局唯一 token,推荐 UUID 或“服务实例 ID + 线程 ID + UUID”。不要直接用用户 ID、订单号作为值,因为释放锁和续期锁都要依赖它判断持有者。
可以通过下面的命令复现加锁、查看 TTL、模拟续期和安全释放:
bash
# 1. 加锁:NX 保证互斥,PX 设置 30 秒兜底过期
redis-cli SET lock:order:10001 "uuid-8f3c-thread-18" NX PX 30000
# 2. 查看剩余毫秒数,确认锁存在 TTL
redis-cli PTTL lock:order:10001
# 3. 错误示范:不要无条件续期,可能给别人的锁续命
redis-cli PEXPIRE lock:order:10001 30000
# 4. 正确思路:通过 Lua 先比较 token,再延长 TTL
redis-cli EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end" 1 lock:order:10001 "uuid-8f3c-thread-18" 30000
# 5. 安全释放:只有持有者才能删除
redis-cli EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock:order:10001 "uuid-8f3c-thread-18"关键数据结构并不复杂,但语义必须完整:String 保存唯一 token,TTL 表示租约时间;watchdog 每次续约只做“比较 token 并刷新 TTL”。如果还需要排查续期情况,可额外维护一个 Hash,但生产环境不建议把锁状态和锁本体拆成两个非原子键。
bash
# 可选诊断结构:仅用于观测,不应替代锁键的原子判断
redis-cli HSET lock:meta:order:10001 owner "order-service-7f9c:thread-18" renewCount 12 lastRenew "2024-05-01T10:30:00Z"
redis-cli HGETALL lock:meta:order:10001实操步骤:实现一个可续期的 Redis 分布式锁
第一步,定义锁配置。初始 TTL 建议 20 到 30 秒,续期间隔取 TTL 的三分之一,例如 30 秒 TTL 配 10 秒周期。这样即使一次续约遇到短暂网络抖动,仍有两次补偿窗口。第二步,加锁成功后把任务注册到本地调度器,并将 token 与业务线程绑定。第三步,续期脚本必须在 Redis 服务端原子执行,不能拆成 GET 后再 PEXPIRE。第四步,业务方法无论成功还是抛异常,都要在 finally 中取消 watchdog 并释放锁。
下面是一个简化的 Java 实现骨架,连接池、线程池和异常上报可以按项目规范替换:
java
public boolean tryLock(String key, String token, long ttlMs) {
Boolean ok = redis.opsForValue()
.setIfAbsent(key, token, Duration.ofMillis(ttlMs));
if (!Boolean.TRUE.equals(ok)) {
return false;
}
// 周期通常设为 TTL 的 1/3,给网络抖动预留重试窗口
long renewIntervalMs = ttlMs / 3;
ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(() -> {
Long result = redis.execute(renewScript, List.of(key), token, String.valueOf(ttlMs));
if (result == null || result == 0L) {
// 锁已过期或被别人持有:必须中断当前业务的“独占假设”
log.error("Redis lock renew failed, key={}, token={}", key, token);
currentThread().interrupt();
}
}, renewIntervalMs, renewIntervalMs, TimeUnit.MILLISECONDS);
renewTasks.put(token, future);
return true;
}
public void unlock(String key, String token) {
ScheduledFuture<?> future = renewTasks.remove(token);
if (future != null) {
future.cancel(false); // 先停止续期,避免删除后又被后台任务重建
}
redis.execute(unlockScript, List.of(key), token);
}两段 Lua 脚本建议在应用启动时加载,避免每次发送大段脚本文本:
lua
-- renew.lua:KEYS[1]=锁键,ARGV[1]=当前持有者,ARGV[2]=新的毫秒 TTL
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
else
return 0
endlua
-- unlock.lua:KEYS[1]=锁键,ARGV[1]=当前持有者
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end如果项目已经使用 Redisson,可直接使用其可重入锁 RLock,默认 leaseTime 为 -1 时会启用 watchdog,默认锁租约 30 秒、每 10 秒续约一次。但要注意,显式调用 tryLock(waitTime, leaseTime, unit) 且传入了固定 leaseTime,通常不会启用自动续期;想要 watchdog 兜底,应使用不带 leaseTime 的加锁方法或确认所用版本语义。
主从与故障转移:watchdog 不是一致性银弹
很多团队上线 watchdog 后就认为锁绝对安全,这是危险的。Redis 主从复制是异步的:主节点刚写入锁后宕机,从节点尚未同步该键,哨兵提升新主后,另一个客户端仍可能加锁成功。watchdog 只能解决“业务没结束但 TTL 到了”的问题,不能解决故障切换窗口的双写。
对普通互斥任务,主从或哨兵架构配合唯一 token、自动续期通常够用;对资金、库存等强一致场景,应评估 Redlock、Zookeeper、etcd 或数据库唯一约束。更重要的是在业务层增加幂等键、状态机和唯一索引,把分布式锁作为降低并发冲突的手段,而不是唯一正确性来源。
避坑总结:生产环境至少检查这 5 项
现象:业务执行完却删掉了别人的锁。 常见原因是释放时直接
DEL key,或先GET再DEL,中间锁刚好过期并被他人获取。修正方法是 token 必须每次加锁唯一,释放使用 Lua 脚本原子比较并删除。现象:固定 30 秒 TTL,偶发慢任务后出现重复处理。 原因是业务耗时超过 TTL,watchdog 未启用或续期间隔设置过大。修正方法是将间隔设为 TTL 的三分之一,并在续约失败时记录告警、中断任务或进入失败补偿,不允许继续假设自己持锁。
现象:服务关闭后锁没有立刻释放。 原因是没有注册停机钩子,或业务异常后没有执行
finally。修正方法是应用停止前先拒绝新任务,再取消调度器并安全释放锁;即使释放失败,也依赖较短 TTL 兜底。现象:日志显示续期成功,但数据库仍有并发写入。 原因可能是 Redis 主从切换丢失锁,或不同实例使用了相同 token。修正方法是 token 加入实例 ID、线程 ID 和随机 UUID;强一致场景不要只依赖普通主从锁,同时补齐数据库唯一约束与幂等设计。
现象:锁被无限续命,任务异常退出后长期阻塞。 原因是 watchdog 只看调度任务是否存活,不判断业务线程状态,或捕获异常后没有取消续期。修正方法是把 future 与业务上下文绑定,业务线程结束、超时、被中断时必须取消 watchdog;续期脚本连续失败要触发熔断与告警。
合理的 Redis 分布式锁应当同时具备互斥、防误删、故障兜底和可观测能力。watchdog 的价值不是让锁永不超时,而是在业务真实运行时延长租约,在进程失联时仍然自动释放。把这两个边界守住,才能既避免长事务误删锁,又不把系统拖成人工清锁的状态。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。