Redis 系列 · 第八篇

分布式锁的 watchdog

分布式锁给一个不长的 TTL,是为了防止持锁进程真的崩溃时锁被永久占用;但任务到底要跑多久是猜不准的——TTL 短了,任务还没做完锁就没了;TTL 长了,崩溃时大家要陪着等很久。这个两难就是 watchdog(续期协程)存在的理由:只要持锁方还活着,就不断把锁的死线往后推——注意是"往后推",不是"变长",任务跑多久就续多久;进程真崩溃了,续期跟着停摆,锁按最后一次续期设定的死线自然过期。

这一篇在本机真实 Redis 8.10.1 上,把"没有 watchdog 会出什么问题"和"watchdog 具体怎么工作、还有哪些防不住的角落"都实测了一遍。

5 组真实实验
本机真实 redis-cli,从"没有 watchdog 会双持锁"到"迟到续期为什么不会破坏别人的锁"
1 个残留风险
无界暂停(GC/调度延迟)导致的过期,watchdog 本身解决不了,需要 fencing token 兜底

为什么需要 watchdog:固定 TTL 的两难

分布式锁靠 TTL 兜底"进程崩溃后锁不会永久占用",但 TTL 该设多长,是个两头为难的选择——这正是 watchdog 要解决的问题。

TTL 设短了

任务比预期慢,锁提前消失

这不是小概率的极端情况,是必然会发生的事——你没法精确预测一个任务到底要跑多久。任务还没做完,锁先到期了,别人就会正常抢到这把"看起来空闲"的锁。

TTL 设长了

进程真崩溃时,大家要陪着等

为了防止任务跑得久锁就先没了,把 TTL 设得很长——代价是一旦持锁进程真的崩溃(没有走到正常释放这一步),这把锁要等满整个超长 TTL 才会被释放,别的调用方全程等着。

watchdog

让 TTL 只对"崩溃"负责

TTL 设得比较短,但只要持锁方还活着,就用一个后台协程不断续期——任务跑多久就续多久,TTL 只在进程真的崩溃、没人续期时才发挥作用。

真实实测(一):没有 watchdog,会真的双持锁

A 加锁处理一笔订单,TTL 设 2000ms,但订单处理实际耗时 3500ms,期间没有任何续期。

真实实测
$ redis-cli SET lock:order "CLIENT-A" NX PX 2000
$ # t=0ms: A 加锁成功, 开始处理订单(预计耗时 3500ms, 没有 watchdog)
# t=2100ms, 订单还没处理完, 查一下锁的状态: $ redis-cli GET lock:order (nil) # 已经是空的 —— TTL 到了自动释放, A 的业务逻辑完全不知道 # t=2100ms, B 认为锁是空闲的, 正常拿到锁: $ redis-cli SET lock:order "CLIENT-B" NX PX 5000 OK # t=3500ms, A 的订单处理这时候才做完, 执行释放锁(用自己的 token 判断该不该删): $ redis-cli --eval release.lua lock:order , "CLIENT-A" (integer) 0 # 没删成功 —— 当前值已经是 B 的 token, 不是 A 的了

t=2100ms 到 t=3500ms 这 1400ms 里,A 和 B 都以为自己独占着这把锁,同时在处理同一笔订单——这不是理论推演,是本机真实跑出来的结果。A 直到最后释放锁时才发现"咦,删不掉,已经不是我的了",但那时候双重处理已经发生了。

真实实测(二):有 watchdog,任务跑多久锁就活多久

同样的场景,加一个后台续期协程:每隔 TTL 的 1/3 左右,用 Lua 脚本原子地"先确认锁还是不是自己的,再续期"。

renew.lua · 本文实验脚本 if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("PEXPIRE", KEYS[1], ARGV[2]) else return 0 end
真实实测
$ redis-cli SET lock:res "TOKEN-A" NX PX 3000
$ # 任务预计耗时 7000ms, TTL 只有 3000ms, watchdog 每 1000ms 续期一次
[watchdog] 第1次续期, 返回=1, 此时 TTL=2985ms [watchdog] 第2次续期, 返回=1, 此时 TTL=2986ms [watchdog] 第3次续期, 返回=1, 此时 TTL=2986ms [watchdog] 第4次续期, 返回=1, 此时 TTL=2988ms [watchdog] 第5次续期, 返回=1, 此时 TTL=2980ms [watchdog] 第6次续期, 返回=1, 此时 TTL=2985ms # 任务在 t=7000ms 结束, 此时锁是否还在: $ redis-cli GET lock:res; redis-cli PTTL lock:res TOKEN-A 2213

整个 7 秒里,锁的 TTL 一直被"续"回接近 3000ms,没有因为超过了初始的 3s TTL 而消失。

续期不是"变长",PEXPIRE 是重置,不是叠加

一个容易搞错的地方:每 1s 续 3s,跑 7 次会不会变成 21s?不会——每次续期是把死线重新定到"现在 + 3s",不是在原来的死线上再加 3s。

真实实测
$ redis-cli SET demo v PX 3000
刚设置: TTL = 2995ms $ sleep 1 1秒后(还没续期): TTL = 1975ms # 自然倒计时, 从3000掉到约2000 $ redis-cli PEXPIRE demo 3000 续期后: TTL = 2992ms # 直接重置回~3000, 不是 2000+3000=5000 $ sleep 1; redis-cli PEXPIRE demo 3000 再续期一次后: TTL = 2989ms # 还是重置回~3000, 没有累加

PEXPIRE key 3000 的语义是"从现在起 3000ms 后过期",跟它之前还剩多少 TTL 无关。整个续期过程是一个**滑动窗口**:锁剩余的存活时间始终维持在"最近一次续期 + 3s"附近,不会因为续了很多次就越滚越大。

真实实测(三):进程崩溃,watchdog 停摆,锁必须能自愈

watchdog 不能变成"续上了就再也不会释放"——这是它必须守住的安全底线。

真实实测
$ redis-cli SET lock:res "TOKEN-B" NX PX 3000
$ # watchdog 只续期了1次(t=1000ms), 然后被强行杀掉(模拟进程崩溃)
# 之后每隔1秒查一次锁的状态: 第0s 后: GET lock:res = 'TOKEN-B' 第1s 后: GET lock:res = 'TOKEN-B' 第2s 后: GET lock:res = '' 第3s 后: GET lock:res = ''

watchdog 被杀掉后,没人再续期,锁按最后一次续期设定的死线正常过期,没有变成永久占用的死锁——进程真的崩溃了,安全网照样生效。

真实实测(四):迟到的续期,撞见了新主人

如果续期请求因为 GC 暂停、调度延迟而迟到,恰好错过了自己的过期时刻,甚至等它真正到达 Redis 时,锁已经被别人抢走了,会发生什么?

真实实测
$ redis-cli SET lock:res "TOKEN-A" NX PX 1000
$ # t=0: A 加锁, TTL=1000ms, 之后被 GC 卡住, 没能按时续期
# t=1200ms: 锁应该已经自然过期了, 检查: (nil) # 确实是空的, B 现在可以正常抢到 $ redis-cli SET lock:res "TOKEN-B" NX PX 5000 OK # B 成功抢到锁 # t=1500ms: A 才从卡顿里恢复过来, 它的 watchdog 才执行到续期这一步(用的还是自己的 TOKEN-A): $ redis-cli --eval renew.lua lock:res , "TOKEN-A" 3000 (integer) 0 # 续期失败 —— GET 到的是 TOKEN-B, 跟 A 自己的 token 对不上 $ redis-cli GET lock:res TOKEN-B # B 的锁完好无损, A 迟到的续期没有破坏它

token 比对保护了 数据层面 的安全——A 的续期不会误伤 B 的锁。但保护不了 A 自己的认知:从 t=1000ms(锁真正过期)到 t=1500ms(A 通过续期返回值 0 才发现自己已经不再持有锁)这 500ms 里,如果 A 的业务逻辑没有在每一步检查"我还持有锁吗",它会一直以为自己安全地持有锁,继续操作那份资源——这段时间里 B 可能也在操作同一份资源。

这不是这套实现写得不够好,是所有基于 TTL 租约(lease)的锁共有的结构性局限:进程可能因为一次不可控的暂停(GC 全局停顿、CPU 调度延迟、网络分区、Redis 主从切换)导致锁在自己"活着"但没在正常运行时真的过期——而暂停能有多长,理论上是没有上限的。watchdog 能把这个窗口的概率压得很低(续得越勤,窗口越小),但没法把它压到 0。

演示:五组实验合并成一条时间线

每个色块代表"这把锁距离真正过期还剩多久"——续期会把它重新推远,过期会让它归零。

watchdog 生命周期演示

绿色窗口是当前持有者距离真正过期还剩的时间;发生续期/加锁/释放,会有一个绿色(成功)或红色(失败)的旗标标出。数据由 Python 独立生成并用一套完全独立重写的状态机交叉验证过——每一步的成功/失败判断,都和"只根据 token 是否匹配、死线是否已过"这两条最基本规则独立算出来的结果核对一致。

watchdog 解决了什么,没解决什么

问题watchdog 能不能解决
任务耗时不可预测,固定 TTL 猜不准能——只要进程正常运行,续期就能让锁活到任务真正做完
持锁进程真的崩溃,锁要不要永久占用能——watchdog 跟着进程一起消失,锁按最后一次续期的死线自然过期
进程没崩溃,但被无界暂停(GC/调度/网络)卡住,续期迟到不能——这是租约机制的结构性局限,只能靠更勤的续期降低概率,压不到 0

最后一行的残留风险,真正的解法不在锁这一层,而在被保护的资源那一层:给每次加锁发一个单调递增的编号(fencing token),操作真正的资源时把编号一起带上,让资源自己拒绝"编号比它见过的最大值还小"的请求——这样即使 A 真的在锁过期后又操作了一次,资源层会因为编号过期而拒绝这次操作,不必依赖锁本身做到绝对不出错。

参考与说明

  • 本文全部真实实验(无 watchdog 双持锁、watchdog 续期存活、PEXPIRE 重置语义、进程崩溃自愈、迟到续期撞见新主人)均在本机真实运行的 Redis 8.10.1 上用 redis-cli 完成,输出为真实结果,仅做了裁剪。
  • renew.lua/release.lua 是本文实验用的续期/释放脚本,采用"先 GET 比对 token,再 PEXPIRE/DEL"的比较后操作模式,这也是 Redisson 等成熟分布式锁库 watchdog 机制的通用实现思路,不是某一家库的专属写法。
  • "演示:五组实验合并成一条时间线"的数据由 Python 脚本生成:先按真实实验的时间线手工编排 26 个事件,再用一套完全独立重新实现的状态机(只依据"token 是否匹配当前持有者"和"当前时间是否超过死线"这两条最基本规则)逐事件重放校验,断言全部通过后才用于渲染——不是照抄真实源码,而是复现了 SET NX PX / PEXPIRE / GET 比对这几个命令组合起来的行为规则。
  • 没有涉及:Redlock(多个独立 Redis 实例的多数派加锁算法)本身的正确性争议、真实生产环境里 watchdog 的具体调度实现(比如 Redisson 用 Netty 定时任务而不是本文这种简化的轮询)、fencing token 在真实存储层的落地方式。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电