两头堵的困局
上一篇结尾留了个死结:锁的过期时间(比如 10 秒)必须覆盖业务执行时长,但业务时长没有上限——数据库抖 30 秒、GC 停 20 秒,10 秒的锁就是纸糊的。直觉的解法是把过期时间往大了设:设 5 分钟,业务再慢也跑得完。但反过来想:持有人真的崩了呢?所有请求干等 5 分钟才敢抢锁,一家门店宕机,全公司的储值卡业务瘫痪 5 分钟——防死锁的代价变成了慢性死亡。过期时间设短是「锁跑赢业务」,设长是「业务拖死锁」,两头堵。破局的思路要掉个头:锁的生死别靠猜,靠持有人「还活着」这个事实来保证——活着就续期,死了没人续,锁自然过期。
看门狗:一个线程的忠诚契约
这就是看门狗(watchdog)机制的全部思想。抢到锁之后,启动一个后台线程,每隔一段时间(通常是 TTL 的三分之一)检查一次:业务还在跑吗?在跑就给锁续期,把 TTL 重置回初始值:
时间线(TTL = 10s,看门狗周期 = 10/3 ≈ 3.3s):
t=0s 抢到锁,TTL=10s,看门狗启动
t=3.3s 业务还在跑 → 续期,TTL 重置为 10s
t=6.6s 业务还在跑 → 续期,TTL=10s
t=9.9s 业务还在跑 → 续期,TTL=10s
……业务跑多久,锁就活多久……
t=Xs 业务完成 → 主动释放锁,看门狗停止
或
t=Ys 进程崩溃 → 没人续期 → 最迟 10s 后锁自动过期这个设计最妙的地方在于崩溃安全是白送的:进程死了,看门狗线程跟着死了,没人续期,锁按 TTL 自动释放——「防死锁」和「业务不受时长限制」两个目标同时达成,过期时间再也不用赌业务时长,设个 10 秒、30 秒都行。契约的本质是:锁的存续绑定在「持有进程还活着」这个可自动失效的事实上,而不是绑定在「业务什么时候做完」这个不可预测的变量上。
续期本身也要原子
续期不是无脑 EXPIRE。续期那一刻,锁可能刚好过期、别人可能刚好抢到——上一篇的教训在续期这里原样复现:校验身份和重置 TTL 必须原子,还是 Lua:
-- 续期脚本:锁还在且是我的,才重置 TTL
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0 -- 锁没了或易主了,续期失败
end续期失败要立刻警觉——这意味着你已经不是持有人了,临界区里的业务要尽快中断或走失败处理,而不是继续蒙头跑完。一个诚实的实现:续期失败时设置一个「锁丢失」标志,业务代码在关键写操作前检查标志,发现丢锁立即回滚自己已做的动作。能不能做到,决定了这套锁是玩具还是工程。
看门狗也看不见的角落
看门狗不是免死金牌,两个角落要交底。其一,STW 停顿把看门狗一起冻住:Full GC 停 30 秒,续期线程也停 30 秒,锁过期、B 抢走、B 干活、GC 结束后 A 复活——A 的续期脚本一跑,身份校验失败(锁已易主),返回 0,A 知道自己丢锁了,这算体面收场;怕的是 A 复活后不看返回值继续干活。其二,进程假死:线程还在、没崩溃,但网络断了一直续不上期,锁照样过期易主——看门狗续期依赖 Redis 连接,「活着」的定义是「还能跟 Redis 说话」,说不上话的活着,等于死了。这两类场景的根治靠下游兜底(fencing token,第 16 篇),锁只能把危险概率压小,压不到零。
| 场景 | 无看门狗 | 有看门狗 |
|---|---|---|
| 业务跑 60 秒,TTL 10 秒 | t=10s 锁过期,互斥破碎 | 持续续期,锁活到业务结束 |
| 进程 t=2s 崩溃 | 锁按 TTL 释放(本就该如此) | 续期线程同死,锁按 TTL 释放 |
| Full GC 停 30 秒 | 锁过期,GC 后误以为仍持有 | 续期脚本校验失败,可感知丢锁 |
到这里,一把单机 Redis 锁的正确姿势已经齐了:SET NX PX 加随机 value 抢锁、Lua 校验身份释放、后台线程 Lua 续期。三件套自己写也就百来行,但边界情况(重入、公平排队、读写锁、集群漂移)每加一个,复杂度翻一倍——有轮子为什么不用。下一篇登场的就是 Redis 生态的锁库一哥 Redisson:看门狗它给你造好了,可重入它给你实现了,我们从它的一段 Hash 结构讲起。
评论 (0)