一个挡不住的幽灵
本系列已经三次撞见同一个幽灵:第 5 篇 RedLock 论战里的 GC 停顿客户端、第 11 篇 ZK 会话过期后的复活进程、第 7 篇主从切换后的第一个持有人——共同点是锁已经丢了,它自己不知道,业务还在往下游写。第 11 篇给过缓解三板斧:状态监听、主动查询、会话校验——但它们都依赖「客户端去问」,而幽灵的本质恰恰是「它不会去问」。要把这类事故从「降低概率」变成「数学上不可能」,防线必须前移到下游:让存储有能力拒绝过期持有者的写入。这就是 fencing token(栅栏令牌)。
机制:一张只涨不跌的通行证
fencing token 的运行规则两句话:锁服务每次授予锁,发一个严格单调递增的编号(第一次 33,下一次 34……);下游存储记录它见过的最大编号,凡是编号小于记录值的写入,一律拒绝。看时间线:
t1 A 抢到锁,领到 token = 33,开始干活
t2 A 发生 GC 停顿 / 网络分区(自己毫无感知)
t3 锁过期或易主,B 抢到锁,领到 token = 34
t4 B 写下游:携带 token 34 → 下游记录 max_token = 34,写入成功
t5 A 复活,带着 token 33 来写 → 33 < 34,下游拒绝
↑ 幽灵的写入被物理拦下,无论 A 是否「知道自己成了幽灵」妙处在于判决权在数据这一侧:不再依赖持有人自觉(知情机制)或锁服务仲裁(锁的正确性),而是让「最新持有人」的写入天然盖过「过期持有人」——token 的单调性由锁服务保证,比较由存储执行,两个环节都在「清醒」的参与者手里。Martin 对 RedLock 的全部批评,落点就是这句话:时间型锁必须配 fencing token 才能谈安全。
令牌从哪来
三家锁的令牌原料天差地别,第 14 篇的对比表里埋过伏笔。ZooKeeper:锁节点的 zxid 或 stat.version——Curator 的 acquire 之后能拿到节点状态,zxid 全局单调,直接当 token 用。etcd:锁 key 的 CreateRevision——revision 全局单调递增、由 Raft 仲裁,一等公民级原料,etcd 官方文档明确支持这个用法。Redis:没有现成的,要自建——抢锁成功后对同一个计数 key 执行 INCR(Lua 里和 SET NX 打包成原子操作),或者用数据库 sequence。自建 INCR 本身也有单点与持久性问题,但作为「锁 + 兜底」组合里的兜底层,够用;真要较真,资金场景直接换 etcd 更省心。
下游得配合:条件写入是前提
fencing token 的一半成本在下游改造:存储必须支持「带令牌比较的条件写入」。数据库一行代码的事——update account set balance=? where card_no=? and token=?(把 token 当乐观锁字段用,第 16 篇版本号的变体);etcd 用事务原语 compare-and-put 天然支持;ZNode 的 version CAS 也可以。麻烦的是纯 Redis 下游——需要 Lua 把「比较 max_token」和「写入」打包成原子操作。还要诚实交底两条局限:其一,fencing 保证「旧的写不进来」,不保证「新的写一定正确」——业务逻辑 bug 照样出事故,它不是银弹;其二,锁服务整体宕机时发不出新 token,业务不可用——fencing 保护正确性,可用性要靠锁服务自身的高可用(共识派的优势又回来了)。
| 防线 | 拦截对象 | 位置 | 强度 |
|---|---|---|---|
| 锁的知情机制 | 丢锁后继续跑的持有者 | 客户端 | 降概率(依赖自觉) |
| 幂等 / 去重 | 同一操作的重复执行 | 业务层 | 强(但需要业务键) |
| fencing token | 过期持有者的任意写入 | 存储层 | 数学级(单调性背书) |
三条防线各司其职:知情机制减损、幂等防重、fencing 终审——资金级场景三层全上,普通业务做到前两层即可。下一篇落地最后一问:真要引入协调服务,ZK 还是 etcd?部署规模、运维成本、生态绑定,给出云原生时代的现实答案。
评论 (0)