分布式锁给一个不长的 TTL,是为了防止持锁进程真的崩溃时锁被永久占用;但任务到底要跑多久是猜不准的——TTL 短了,任务还没做完锁就没了;TTL 长了,崩溃时大家要陪着等很久。这个两难就是 watchdog(续期协程)存在的理由:只要持锁方还活着,就不断把锁的死线往后推——注意是"往后推",不是"变长",任务跑多久就续多久;进程真崩溃了,续期跟着停摆,锁按最后一次续期设定的死线自然过期。
这一篇在本机真实 Redis 8.10.1 上,把"没有 watchdog 会出什么问题"和"watchdog 具体怎么工作、还有哪些防不住的角落"都实测了一遍。
分布式锁靠 TTL 兜底"进程崩溃后锁不会永久占用",但 TTL 该设多长,是个两头为难的选择——这正是 watchdog 要解决的问题。
这不是小概率的极端情况,是必然会发生的事——你没法精确预测一个任务到底要跑多久。任务还没做完,锁先到期了,别人就会正常抢到这把"看起来空闲"的锁。
为了防止任务跑得久锁就先没了,把 TTL 设得很长——代价是一旦持锁进程真的崩溃(没有走到正常释放这一步),这把锁要等满整个超长 TTL 才会被释放,别的调用方全程等着。
TTL 设得比较短,但只要持锁方还活着,就用一个后台协程不断续期——任务跑多久就续多久,TTL 只在进程真的崩溃、没人续期时才发挥作用。
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 直到最后释放锁时才发现"咦,删不掉,已经不是我的了",但那时候双重处理已经发生了。
同样的场景,加一个后台续期协程:每隔 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 而消失。
一个容易搞错的地方:每 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 不能变成"续上了就再也不会释放"——这是它必须守住的安全底线。
$ 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 可能也在操作同一份资源。
每个色块代表"这把锁距离真正过期还剩多久"——续期会把它重新推远,过期会让它归零。
绿色窗口是当前持有者距离真正过期还剩的时间;发生续期/加锁/释放,会有一个绿色(成功)或红色(失败)的旗标标出。数据由 Python 独立生成并用一套完全独立重写的状态机交叉验证过——每一步的成功/失败判断,都和"只根据 token 是否匹配、死线是否已过"这两条最基本规则独立算出来的结果核对一致。
| 问题 | watchdog 能不能解决 |
|---|---|
| 任务耗时不可预测,固定 TTL 猜不准 | 能——只要进程正常运行,续期就能让锁活到任务真正做完 |
| 持锁进程真的崩溃,锁要不要永久占用 | 能——watchdog 跟着进程一起消失,锁按最后一次续期的死线自然过期 |
| 进程没崩溃,但被无界暂停(GC/调度/网络)卡住,续期迟到 | 不能——这是租约机制的结构性局限,只能靠更勤的续期降低概率,压不到 0 |
最后一行的残留风险,真正的解法不在锁这一层,而在被保护的资源那一层:给每次加锁发一个单调递增的编号(fencing token),操作真正的资源时把编号一起带上,让资源自己拒绝"编号比它见过的最大值还小"的请求——这样即使 A 真的在锁过期后又操作了一次,资源层会因为编号过期而拒绝这次操作,不必依赖锁本身做到绝对不出错。