一种单调编号,让过期主节点的写入全部失效。

fencing token 让过期的旧主节点失效

分布式锁看似简单,可一旦客户端停顿后晚醒,它的持锁声明就会失真;fencing token 把这种过期声明变成了可校验的编号。 光有锁还不够 分布式锁能挡住两个并发的写者,却挡不住一个「曾经拥有锁」的写者。一个节点写到一半卡住,租约过期,新的主节点接管,而旧节点醒来时仍带着它自认的那个许可帧继续写。这正是锁的盲区:它已经不再拥有令牌,却还觉得自己的主位一直没丢。站在资源一侧看,两次写入都像是合法所有者做的,因为在两个相互交叉的时间片里它们确实都持有效份。只靠租约乐观地自动过期,会在事故到账之前就留下一段够大够脏的窗口。 行动前先取编号 护栏令牌用一个递增编号堵住这个洞。当前主节点在动共享资源之前,先向协调服务取一枚单调递增的序列号,并且每次写入都把这个号带上。任何携带更旧编号的写入都会被拒,因为资源能看出已经有更新的所有者在写了。令牌必须来自可信来源,而不是写者自己那口时钟:墙上时钟是伪造的,而且伪造它的恰恰就是这套护栏要约束的那个进程。资源能安全比较的,只有它信任的签发者给出的数字。 更旧的全体拒绝 拒绝规则才是核心,而且必须无条件。当资源按「来者编号 > 上一次编号」比较时,拒绝旧写者不是因为对方是坏家伙,而是因为它的号更旧——这是资源唯一能依赖的新鲜度证据。当编号在所有人之间共享同一条递增序列时,设计会简单得多:一次比较就把所有过期的主者同时挡出去。这个思路并不只是文件的专利:分布式系统里的租约围栏、epoch 编号、护栏令牌,本质上都是在把「相信时钟」这把靠不住的梯子,换成一次数字比较。 坏掉的样子 围栏失效多半源于编号往回走。签发端计数器若只放内存,崩溃重启就会归零:老主节点攥着四十八号,新主节点只拿到二号,资源反而把过期写入当最新落盘。计数器必须落盘,或从日志位点派生;脏重启后在归属对账清楚前拒绝发号。租约只能选出新主,拦不住旧主的迟到覆写,围栏补的正是这一刀。 第二种是拿墙上时钟当编号。一次授时回拨、容器跨宿主机迁移的偏差、同毫秒两位主节点撞号,都可能造出相等或递减的时间戳,资源无从排序。等值必须按重放拒绝;时钟只配做超时猜测,不能替代签发序列。若两端拿到同号,资源只能二选一瞎猜,等于把仲裁权交还给运气。 第三种是脑裂双主,拆掉的是比较点本身。分区两侧各自选主、各自签发七号、各自落盘,愈合后两份历史都自称合法,光看编号已无法裁决谁是后来者。没有单一签发序列和单一校验资源,新旧只是标签,比较再严格也挡不住各说各话的覆写。 最安静的是令牌半路丢失截断。升级丢字段、代理去掉陌生请求头、兼容逻辑把缺失当零跳过检查,旧写就混了进去;镜像是资源重启丢了上一次编号,服过三十四号又接住三十三号。为老客户端留的兼容后门,往往正好给过期写入让路。每次变更必带令牌,缺失默认拒绝,高水位与数据同边界落盘。 快速验证 把下面脚本保存为 fencing.py,直接运行 python3 fencing.py 即可。它只用标准库,同时模拟单调递增的签发者和带校验的资源服务,不需要起任何外部组件,也不依赖系统时钟,几秒内就能看到旧写被拒、新写放行的完整过程: class StaleFenceError(RuntimeError): pass class FencingResource: """只接受严格递增围栏编号的资源服务。""" def __init__(self): self.last_seen = 0 self.data = None def write(self, token, value): if token is None: raise StaleFenceError("missing fence token") if token <= self.last_seen: raise StaleFenceError(f"stale token {token} <= {self.last_seen}") self.last_seen = token self.data = value return True class Issuer: """单调递增的签发者,生产环境里这个计数器必须落盘。""" def __init__(self): self.counter = 0 def acquire(self): self.counter += 1 return self.counter issuer = Issuer() resource = FencingResource() old_leader = issuer.acquire() # 1:暂停前的老主节点 new_leader = issuer.acquire() # 2:租约过期后接管的新主节点 assert (old_leader, new_leader) == (1, 2) assert resource.write(new_leader, "from-new-leader") is True assert resource.data == "from-new-leader" try: resource.write(old_leader, "from-stale-leader") except StaleFenceError as error: print(f"rejected stale write: {error}") else: raise AssertionError("stale token was accepted") try: resource.write(new_leader, "replay") except StaleFenceError as error: print(f"rejected replay: {error}") else: raise AssertionError("equal token was accepted") try: resource.write(None, "no-token") except StaleFenceError as error: print(f"rejected missing token: {error}") else: raise AssertionError("missing token was accepted") next_token = issuer.acquire() # 3 assert resource.write(next_token, "from-new-leader-again") is True assert (resource.data, resource.last_seen) == ("from-new-leader-again", 3) print("PASS: stale, replayed, and missing tokens rejected; newer token accepted") 脚本复刻停顿苏醒:老主节点持一号冻结,新主节点以二号先写并记下高水位。随后旧号迟到、同号重放、无号写入均被拒绝,只有三号成功。每步都有断言,误放行即抛错,不会悄悄打印通过。 ...

十一月 30, 2027 · 2 分钟 · XB Field Notes