失联的协调者回来了,Commit 被拒了

有一次盘点代码库,我们把所有”提交”机制摊在一张表里数了数:raft 有 commitIndex,2PC 参与者等协调者的 commit record,TransactionLog 落 24 字节提交记录,FS 冷热分层迁移事务在锚根 journal 里写移交记录。

四套代码,四个时间点写的,四套词汇。但把每一套按”它实际上分几步”拆开,表格长成了这样:

机制stageddecided(唯一权威)applied
raft 本体组日志条目多数派 commitIndexApplyPipeline 逐条 apply
2PC 参与者Prepare = flush + seq 悬空协调者 commit recordConfirmCommitted
TransactionLogpending24B commit record(Write+Flush 原子点)链式 Confirm-all
FS 迁移事务写穿冷根 + CRC 校验耐久锚根 journal 的移交记录打洞 + 归属移交

每一行都是同三步:先把效果暂存成一个可丢弃的形态,然后在一个耐久点上定案,最后把效果落实。 这篇讲的是我们怎么把这个骨架升格成平台一等内核,以及路上真正难的三个问题。

四条纪律

骨架本身不值钱——2PC、raft、WAL 提交都长这样,教科书里都有。值钱的是四行共同遵守的纪律,它们各自堵住一类经典的洞:

stage 可弃。 staged 阶段的任何失败不产生副作用——abort 就是把暂存扔掉,源根数据原样保留(FS 迁移的冷副本删掉即可),不需要”回滚一个半成品”。为此 Prepare 必须是”flush + seq 悬空”:数据已到耐久介质,但对外不可见。

decision 唯一权威。 commit 和 abort 都经决策日志线性化,任何参与者不得自主裁决。这条最容易做半吊子——“提交要共识”大家都懂,中止却常常被当成局部决定,下面单讲。

apply 幂等。 决策定案后,applied 阶段至少送达一次(at-least-once),重放必须确定:同一条决策应用两遍,结果和一遍相同。KV 的 Put 覆写天然幂等,Delete 用墓碑——幂等不是附加优化,是”恢复可以放心补齐”的前提。

恢复 = 问决策日志。 in-doubt(staged 已定案而决策未见)只有唯一一个动作:去问决策日志。不是猜、不是超时就回滚、不是两边对赌。

中止也要排队

决策唯一权威里最反直觉的是:abort 也无权自作主张。

想象参与组把一个事务 stage 超时了,它”理所当然”地本地回滚。但协调者那边,Commit 决策可能已经在路上——参与组的自弃和迟到的 Commit 赛跑,谁赢取决于调度。这就是经典的”自弃 vs 迟到 commit”竞态,输掉的一方制造出半个事务。

Tier 的规则是:参与组没有自主裁决权。超时想中止,必须向家组提议一条 abort 决策——和可能迟到的 Commit 在同一条决策日志里排队,先后一见分晓:

staged 超时
  → 向家组问询该 seq
      → 已 Committed:自己把 Confirm 补落完(决策已定案,不可回退)
      → Unknown:向家组提议 abort 决策(经日志线性化——若 Commit 先到,abort 提案按已定案处理)
      → 家组不可达:本轮跳过,悬挂有界,下轮再来

配套的两件武器处理更恶心的形态——僵尸协调者。旧世代的协调者失联后又醒过来,揣着一份它以为有效的 Commit:

  • ForceAbort 命令携带协调者世代(4B epoch),Commit 载荷同样携带;
  • 参与组状态机里有 forceEpoch 世代守卫:旧世代的迟到 Commit 被确定性拒绝,不落地——而且是”重放同判”:崩溃恢复重放日志时,拒绝决定和当初一模一样。

原子性守卫的要点就在”确定性”三个字:拒绝不是运行时的临时判断,是和决策记录绑定的可重放事实。

耐久铁律:装配期就死

同一个骨架,决策日志可以有三种耐久档位:

档决策日志适用in-doubt 裁决
内存档域内纯内存序全进程内参与者,重启裁决可接受域声明(ForwardCommit / DropTail)
记录档TransactionLog 的 24B commit record决策需跨重启耐久LoadAndReconcile 双向裁决
raft 档家组日志条目含跨节点 / 跨组参与者日志重放 + 问询

三个档位本身不稀奇,稀奇的是那条铁律:决策日志的耐久档必须 ≥ 事务内最强参与者的耐久要求——事务里有 raft 承载位的参与者,决策就绝不允许落在内存档。更重要的是执行位置:

这条规则在装配期强制,违者拒绝启动。

配置错误死在 StartAsync,不死于三个月后的故障现场。运行时的故障注入是用来验证恢复逻辑的,不是用来发现配置错误的——后者应该在第一次启动时就以 fail-fast 的方式出现。

一个事务对象

内核的调用面上,档位、驱动、本地还是远端,全部不可见:

using var txn = domain.OpenTransaction("place-order");

txn.Stage("orders", () => { /* 物化委托:读-改-写,域管线线程执行 */ });
txn.Stage("audit",  commandBatch);        // 命令批:确定性编码,可作 raft 载荷
                                          // ——两种 Stage 同一货币,可混排
await txn.CommitAsync();                  // Stage-all(并行)→ 决策 → Confirm-all

CommitAsync 的内部只有一条直线,其中藏着整个内核的价值观:

  • 定案之前的任何失败:Abort 全部 staged,抛 RollbackException——stage 可弃,回滚干净;
  • 取消窗口里的不确定(取消令牌触发时决策状态未知):向决策日志问询收敛,不盲回滚——万一决策已定案,回滚是制造原子性破洞;
  • 定案之后的 Confirm 失败:不回滚。聚合上抛异常,悬干留给恢复裁决幂等补齐——决策是不可回退点,这之后的失败属于”还没送完”,不属于”不做”。

第三条是多数实现最容易心软的地方:Confirm 失败时顺手回滚,看起来更”安全”,实际是把已定案的事务撕成两半。

无差别验收

内核做完,怎么证明”本地对象和 raft 组里的远端成员,对调用方真的是同一个东西”?我们用的办法是让同一个事务脚本在两个档位上跑,逐条比对轨迹:

同一脚本(成功 → staged 失败回滚 → 成功)分别跑 standalone 内存档与 raft 档——真家组决策、真参与组、真 TierWal 存储——断言相对轨迹逐条一致:成功 seq 集合、参与面确认集合、回滚零提交。

本地档的差异(物化委托在域管线线程执行)和 raft 档的差异(命令批编码成组提案、多数派定案)全部封在驱动实现里,脚本看不见。验收标准的形态就是设计目标本身:如果两档轨迹对不齐,封装就是漏的。

收尾:一条可迁移的判据

如果只能留一句给未来的自己:

分布式事务的正确性不在”提交这个动作”里,在”谁有权宣布提交”里——决策日志是唯一权威,其余一切(包括中止,包括超时)都得排队。

还有个后记。这个骨架定稿之后,FS 层的迁移事务按同一套词汇重述了一遍设计——但特意不共享代码:fs 在 Core 层,事务内核在 Runtime 层,层级不同。共享的是纪律和词汇(stage/decided/applied、锚根 journal=决策日志、打洞=applied),不是程序集。同一张表格后来出现在两份设计稿里,行数还在增加——下一次再遇到”提交”,先填表,再写代码。


本文事实来自 KernLab.Tier 的统一事务内核设计稿(v1.1)、Runtime/Transactions 实现(TierTransaction / TransactionLog / 世代守卫与超时裁决器)与 Products.Net 的 raft 档契约测试材料。

评论

← 全部文章