SET lock:report-job 7c9e6679-7425-40de-963c-b5d48a1f8b6e NX PX 30000
<span># OK</span>
这行命令,是很多人对"分布式锁"的全部理解:一条加锁、一条解锁,二十分钟写完,测试环境跑得欢。
直到某天凌晨,对账报出同一笔订单被处理了两次。你翻代码:明明加锁了,SET NX PX,教科书写法。问题恰恰在这——这行命令是对的,但它的"对"只够你走到第一个坑。
先把核心判断放在这里:前三个坑修的是"怎么把 Redis 锁用对",第四个坑回答的是"该不该用"——连 Redlock 都填不上,它引发了 antirez 和 Kleppmann 最著名的一场论战。
1. 先把这行命令写对
三个要素,一个不能少:
NX:不存在才写——互斥的来源PX 30000:30 秒过期的兜底——持有者崩了,锁也能自己解开- value 是 UUID——持有者的身份,后面三个坑都围着它转
两个流传最广的错误写法,立此存照:
错误一:SETNX 和 EXPIRE 分两步。 两步之间进程崩了,TTL 没设上,锁永远不释放。TTL 是"持有者崩了锁也能解开"的兜底,兜底必须和加锁同一条命令才作数。
错误二:value 写死 1。 看着无害,实际上谁都能 DEL——你把锁的身份丢了。这就是坑一的入口。
预埋一个反方:"我们 SETNX 加 EXPIRE 跑了三年没出事。"没出事,是进程没恰好崩在两步之间;锁买的是"不双跑",不是"大概率不双跑"。
2. 坑一:value 不带身份,你会删掉别人的锁
触发它不需要故障,一次慢查询就够:
t0 T1 拿到锁(TTL 30s),开始干活
t1 T1 遇到长 GC / 慢 SQL,卡了 31 秒
t2 锁到期自动释放 → T2 加锁成功,合法接管
t3 T1 跑完,finally 里 DEL lock —— 删掉的是 T2 的锁
t4 T3 加锁,也成功。T2、T3 并行,双跑开始
DEL 认 key 不认人,没有"只删我的锁"这个语义。锁的 value 就是持有者的身份证,删之前必须验明正身。
现场复现,有 docker 就行(环境每个坑都复用):
docker run -d --name fence-redis -p 63902:6379 redis:7-alpine
<span><span>R</span></span>() { docker <span>exec</span> fence-redis redis-cli <span>"<span>$@</span>"</span>; }
<span># 场景 A:旧持有者醒来,误删了别人的锁</span>
R SET lock:job1 token-T1 NX PX 5000 <span># 预期:OK(T1 拿到锁,5s 过期)</span>
<span>sleep</span> 6 <span># T1 假装长 GC,锁已到期自动释放</span>
R SET lock:job1 token-T2 NX PX 5000 <span># 预期:OK(T2 已合法接管)</span>
R DEL lock:job1 <span># 预期:(integer) 1 ← 删掉的是 T2 的锁!</span>
3. 坑二:先 GET 再 DEL,两步之间照样出事
有人会说,释放前先 GET 判断是不是自己的。仍有缝:GET 时锁还是你的,DEL 发出去那一刻,锁恰好过期、别人恰好接管——删掉的又是别人。
比对和删除必须不可分割。Redis 执行 Lua 期间不插入其他命令,正好压成一步。脚本只干一件事:GET 的值等于我的 token 才 DEL,否则返回 0:
UNLOCK=<span>"if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"</span>
R SET lock:job1 token-T2b NX PX 5000
R EVAL <span>"<span>$UNLOCK</span>"</span> 1 lock:job1 token-T1 <span># 预期:(integer) 0 —— 旧令牌 T1 删不动别人的锁</span>
R EVAL <span>"<span>$UNLOCK</span>"</span> 1 lock:job1 token-T2b <span># 预期:(integer) 1 —— 持有者本人才能解锁</span>
同样是 0 和 1,场景 A 里是事故现场,这里是正确姿势。
4. 坑三:业务比 TTL 长,锁中途易主
坑一的 GC 是意外,但还有更朴素的触发:任务本来就慢。TTL 30 秒,任务跑 90 秒——不需要故障,锁在第 31 秒易主,第二实例进场,双跑。
把 TTL 调大到 10 分钟?持有者崩溃后,别人要干等 10 分钟。TTL 怎么设,都在"防易主"和"防死等"之间左右为难。
标准解是看门狗(watchdog):加锁成功后起一个后台线程,每 TTL/3 续一次期:
拿锁(TTL=30s) ──► 业务线程干活
│
看门狗线程每 10s ──► Lua 比对"还是我的锁?" → 是则 PEXPIRE 回 30s,否则不动
进程崩溃 ──► 看门狗一起死 ──► 无人续期 ──► 最迟 30s 后锁自动释放
Redisson 默认就是这套:锁 30 秒,每 10 秒续回 30 秒。三个工程细节:
- 续期同样要 Lua 比对 token,不是无脑 PEXPIRE。不比对就续,可能把别人刚接管的锁续成永生——坑一的孪生兄弟
- 超时按最坏情况设,不按平均设。按平均定 TTL,长尾(GC、慢查询、磁盘抖动)必然超时,锁易主后双写
- 别自己造轮子,但先认清轮子成色。Java 的 Redisson 内置看门狗(默认 30s、每 10s 续);Go 的 go-redsync 只提供带 token 比对的 Extend 原语,自动续期要自己起 goroutine 定时调——用 Go 的话这一步省不掉,更要评估值不值得手写
进程崩溃时看门狗跟着死,这恰是设计的一部分:无人续期,最迟 30 秒后锁自动释放。看门狗只是让活着的持有者不被 TTL 误伤。
5. 坑四:锁还在你手里,下游可能不认——zombie writer
前三个坑都能修。第四个修不了:它不在 Redis 这边,在你写下游的那一刻。
Kleppmann 在 2016 年那篇《How to do distributed locking》里给的时序:
t0 A 拿到锁,开始干活
t1 A 发生长 GC,世界暂停
t2 锁过期 → B 加锁成功,业务继续
t3 B 写下游:扣款、发货、更新状态
t4 A 醒来——不知道 t2、t3 发生过,认为自己还持锁,
把手里的旧请求继续写下游
这就是 zombie writer(僵尸写者)。要点:锁只解决"互相知情",管不到你对下游的写——A 对数据库、消息队列、第三方接口的写不经过 Redis。看门狗在这里也帮不上忙:A 卡在 GC 里,看门狗线程一起卡住,锁照样过期。
解药:fencing token
终极手段不在持有者一侧,在下游一侧:每次授权附一个单调递增的令牌,下游记住"我见过的最大令牌",收到旧令牌的写直接拒绝。
t2 B 获授权,令牌 = 7
t3 B 写下游(带 token=7)→ 下游记录 last_token=7,接受
t4 A 醒来,写下游(带 token=6)→ 下游比对 6 < 7 → 拒绝
A 复活了,但它的笔已经没水了
现场复现(还是那台 fence-redis,INCR 模拟令牌):
CHECK=<span>"local c=redis.call('get',KEYS[1]) or 0; if tonumber(ARGV[1])>tonumber(c) then redis.call('set',KEYS[1],ARGV[1]); return 1 else return 0 end"</span>
R INCR fencing:job1 <span># 预期:(integer) 1 ← T1 被授予令牌 1</span>
<span>sleep</span> 1
R INCR fencing:job1 <span># 预期:(integer) 2 ← T1 超时,T2 被授予令牌 2</span>
<span># 下游校验(Lua 原子):只接受更大的令牌</span>
R EVAL <span>"<span>$CHECK</span>"</span> 1 downstream:job1 2 <span># 预期:(integer) 1 ← T2(令牌 2)的写被接受</span>
R EVAL <span>"<span>$CHECK</span>"</span> 1 downstream:job1 1 <span># 预期:(integer) 0 ← T1 复活重放写入,被拒</span>
最后一行返回 0,就是 fencing 的全部意义。三个细节,每个都值一次事故复盘:
- 令牌必须来自授权动作本身。共识系统的 term、epoch 天然单调,授权那一刻拿到的令牌才与授权绑定;随手读个状态值当令牌,校验形同虚设
- 下游拒绝旧令牌,与旧主被拒后死心,必须成对出现。常见的半截工程:旧主被拒后当成可重试错误,退避重试、换条路接着写
- fencing 的强度等于最弱一条写路径的强度。同步写库校验了,异步通知、回调这些旁路不校验,僵尸写者照样溜进来
还有个残酷的事实:令牌的单调性要由共识协议免费提供(etcd 的 term/revision、ZK 的 zxid)。Redis 主从异步复制,failover 连写进去的锁都能丢,INCR 出来的令牌同样可能回退【从业者判断】——这是"正确性场景别用 Redis 当锁"的根子。
6. Redlock:五个 master 也换不来正确性
单实例锁在主从架构下会丢:写锁进 master,异步复制还没到 replica,master 宕机;新主上没有这把锁——两个客户端同时持锁。
Redlock 的思路:向 5 个互不复制的独立 master 依次加锁,多数派成功才算持有——听着严密,前提是多数派节点同时可用。而这个前提并不白给:单台崩溃后若未持久化就立刻重启,会把首持有者的一票弄丢,第二个客户端可在其上重新加锁再凑一个 3/5 多数派——antirez 因此要求宕机节点延迟重启(超过最大 TTL)或开启持久化。然后 Kleppmann 上来两记重拳:
| Kleppmann 的质疑 | 内容 |
|---|---|
| GC 停顿可击穿 | A 拿锁后长 GC,锁过期、B 拿锁、A 醒来继续写下游——第 5 节的 zombie writer 原样上演,5 个 master 一个都拦不住 |
| 时钟假设不成立 | Redlock 的安全性依赖各节点时钟大体不跳;而"时钟大体不跳"恰是分布式系统最不敢假设的东西 |
antirez 的回应,要点三条【从业者判断】:
- 时钟跳变是可控的运维问题:NTP 正常是平滑小步调整,大幅跳变本身就是会砸坏一片系统的事故,不是 Redlock 独有的软肋
- 攻击场景依赖极端条件叠加(长 GC、恰无 fencing、时钟跳变),同样条件也能打穿很多生产系统
- fencing 不是免费的:它要求下游支持令牌校验,而大量下游——第三方支付接口、对象存储——没这个接口;下游不配合,fencing 只是纸面安全
论战没有裁判敲槌,但工程结论值得背下来:先问锁是为了效率还是正确性。
| 锁的用途 | 定义 | 方案 | |---|---| | 效率锁 | 防重复计算、防任务双跑,偶尔双跑可接受 | 单实例 SET NX PX + Lua 释放 + 看门狗,足够 | | 正确性锁 | 双跑即资损(重复扣款、重复发货) | 别指望 Redis:etcd/ZK 加 fencing token,或把操作做成幂等 |
把操作做成幂等,永远是更工程化的答案:不追求"绝不双跑",而是让双跑变得无害——幂等键、版本号、对账,比任何锁都皮实。
预埋反方二号:"生产 Redlock 跑了好几年没出事"?出事窗口是低频事件的任一发生——单实例/哨兵架构下的一次主从切换、一次长 GC 停顿,或 Redlock 侧的一次节点崩溃重启/时钟跳变,单独一个就够,不需要几个条件同时凑齐;低频不等于零频。效率锁本来也该这么用——Kleppmann 反对的不是拿 Redis 锁提效率,是拿它保正确性。
7. 四个坑一张表
| 坑 | 症状 | 根因 | 解法 |
|---|---|---|---|
| value 没带身份 | 释放时删掉别人的锁 | DEL 认 key 不认人 | value 用 UUID 标识持有者 |
| 释放不原子 | GET 之后、DEL 之前锁易主 | 比对与删除两步,中间有缝 | Lua:比对 token 再 DEL |
| 业务比 TTL 长 | 锁中途过期,双跑 | TTL 是静态猜测,耗时是动态的 | 看门狗续期,同样比对 token |
| zombie writer | 锁看着正常,下游重复扣款 | 锁管不到对下游的写 | fencing 拒旧令牌,或幂等兜底 |
四行解法叠起来,才是一把"效率锁"的完整形态——少一行,就是裸奔一个故障窗口。
现在就能做的事
5 分钟路径:把第 2、3 节跑一遍。场景 A 的 (integer) 1 是"删掉别人的锁"的实物证据;换成 Lua 后变成 (integer) 0——这两行输出的差别,比十篇教程深刻。
<span># 还没起环境的话:</span>
docker run -d --name fence-redis -p 63902:6379 redis:7-alpine
<span><span>R</span></span>() { docker <span>exec</span> fence-redis redis-cli <span>"<span>$@</span>"</span>; }
<span># 跑完第 2、3、5 节后,清理:</span>
docker <span>rm</span> -f fence-redis
顺手自检三问,答不上任何一个,你的锁可能正在裸奔:释放走 Lua 吗?续期比对 token 吗?真双跑了,下游兜得住吗?
这套演练整理成了带验收脚本的 lab,收在我维护的 SRE 学习仓库:GitHub 搜 sre-learning-hub,分布式模块第 06 章——同一章还有 zombie writer 完整时序推导,和一场"fencing 都上了还是双写"的事故复盘。
最后聊个实的:你们生产的锁,是 Redis 单实例、Redlock,还是 etcd/ZK?真在下游做过 fencing token 校验的,评论区冒个泡——我想看看这个比例。
从代码细节到架构选型,文章把 Redis 锁的边界讲透了。适合后端与 SRE 对照自查:效率锁可继续用,正确性锁请认真考虑 etcd/ZK 加 fencing 或幂等。