分布式系统(25):分布式事务的决定、补偿与消息边界
订单记录已经提交,支付消息却没有发出。把顺序反过来,支付消息可能已经被处理,订单事务随后回滚。两个系统各自正确地完成一次操作,仍然不能保证这两次操作具有共同的结果。
分布式系统(24):分片与在线迁移的归属边界前篇解决一个分片迁移后由谁服务。跨分片下单还需要回答另一件事:订单、库存和支付分别位于不同资源时,哪些变化必须一起提交,哪些中间结果允许出现,失败后还剩什么义务。站内既有的分布式事务覆盖更多方案名称;这里沿同一条订单链路推导失败路径,再用本地实验检查原子边界。
两次成功调用之间存在失败窗口
先限定业务含义。订单服务创建的是“待支付订单”,消息是“请求扣款”,消费者在自己的账本里扣款。订单创建成功不代表支付完成。若业务要求两个资源共同提交或共同中止,需要跨资源原子提交;若还要求读者看不到部分结果,还必须设计跨资源的隔离与读规则。若允许订单先进入待处理状态,则可以持久保存后续动作,由工作流继续推进。这是对可观察行为的选择。
sequenceDiagram
participant S as 订单服务
participant D as 订单库
participant Q as 消息系统
rect rgb(245, 235, 235)
S->>D: 提交订单
D-->>S: 成功
Note over S,Q: 此时服务退出:有订单,无消息
end
rect rgb(235, 240, 250)
S->>Q: 先发布消息
Q-->>S: 接收成功
Note over S,D: 订单未提交便退出:有消息,无订单
end
图中是两条独立执行,不能把它们连成同一笔订单。调整调用顺序只会移动窗口。把发布动作放进普通数据库事务也不够:数据库回滚不会撤销另一个系统已经接收的消息。只有消息系统确实参加了同一提交协议,或者发送意图本身成为本地事务的一部分,边界才发生变化。Transactional Outbox 模式
这里区分三个性质。原子提交约束参与者不能作出相互冲突的最终决定;隔离约束并发事务能观察到什么;持久性约束确认后的结果在规定故障中能否恢复。可串行化要求并发事务的读写结果可以由某个串行顺序解释,严格可串行化还要求尊重事务的实时先后关系。单独一个提交协议并不实现这些读写规则。MIT 6.5840 事务讲义与 FAQ
因此,两个资源最终都提交,不能推出读者从未看到中间状态;单条记录线性一致,也不能推出跨记录的一组调用就是原子事务。检查方案时,需要画出同一事务究竟覆盖哪些状态。
2PC:先承诺能够执行,再保存共同决定
两阶段提交(Two-Phase Commit,2PC)包含协调者和资源管理器。订单库与库存库是不同参与者,分别承担自己的业务修改。模型假设参与者遵守协议、事务身份唯一、消息可能延迟或重复,节点崩溃后能恢复必要的稳定状态;磁盘永久丢失和拜占庭行为不在这个模型内。
参与者最初可以执行暂存修改,也可以拒绝事务。收到 prepare 后,投 YES 的含义是:即使进程随后重启,它仍能执行最终的 commit 或 abort。因此,恢复所需的暂存数据、事务状态以及相应并发控制信息必须先持久化,才能回复 YES。在锁式实现中,相关锁或等价的访问限制不能因为重启就消失。MIT 2026 Lecture 11
sequenceDiagram
participant C as 协调者
participant A as 订单资源
participant B as 库存资源
C->>A: Prepare T
C->>B: Prepare T
A->>A: 持久保存 Prepared 与恢复信息
B->>B: 持久保存 Prepared 与恢复信息
A-->>C: YES
B-->>C: YES
C->>C: 持久保存 Commit T
C->>A: Commit T
A->>A: 完成本地提交
C->>B: Commit T
B->>B: 完成本地提交
Note over A,B: 决定一致;消息到达不必同时
协调者收到所有参与者的 YES 后,才能决定提交,并且必须先保存决定再发通知。若先通知 A、尚未保存便崩溃,恢复后重新决定 abort,就会让 A 已提交而 B 回滚。提交决定一旦形成,后续超时只能触发查询或重复通知,不能把已经决定的 commit 改成 abort。
正确性直觉来自三条约束:提交前每个参与者都已经 prepared;最终决定只有一个且不可改变;参与者只按合法决定结束事务。假设某参与者已经 commit,另一个却 abort,则要么它们收到了矛盾决定,要么某参与者绕过了决定。前两条日志与状态规则分别排除了这些路径。这个论证依赖协议参与者和恢复存储正确,不覆盖误删事务日志或人工强制回滚。Gray、Lamport:Consensus on Transaction Commit,2017 修订本
stateDiagram-v2
[*] --> Working
Working --> Aborted: 尚未投 YES,可以拒绝
Working --> Prepared: 持久准备,再投 YES
Prepared --> Prepared: 超时,决定仍未知
Prepared --> Committed: 获得 Commit 决定
Prepared --> Aborted: 获得 Abort 决定
Committed --> [*]
Aborted --> [*]
参与者在 Working 时拒绝,与 Prepared 时撤销承诺,是两种不同操作。协调者尚未决定时可以因未收齐 YES 而中止;一个已经投 YES 的参与者,却没有同样的权限。故障处理规则必须绑定角色和状态,不能把“超时就回滚”写成通用分支。
阻塞来自无法区分的历史
设 P 已经投 YES,之后收不到任何最终决定。P 能看到自己的 Prepared 记录和超时,但这些观察至少兼容两条历史:其他参与者 Q 也投 YES,协调者已提交且 Q 已执行;或者 Q 投 NO,协调者决定中止。P 不能依据相同的局部信息,判断哪条历史实际发生。
flowchart TD
P[共同局部视图:P 已 Prepared<br/>已投 YES,没有最终决定] --> C[历史 C:Q 投 YES<br/>协调者持久 Commit<br/>Q 已 Committed]
P --> A[历史 A:Q 投 NO<br/>协调者持久 Abort<br/>Q 已 Aborted]
C --> X[P 超时自行 Abort<br/>产生 Commit 与 Abort 分裂]
A --> Y[P 超时自行 Commit<br/>产生 Abort 与 Commit 分裂]
P --> W[安全动作:等待或查询可靠决定]
这说明经典 2PC 存在阻塞执行,不意味着每次协调者退出都会阻塞所有事务。如果某个可达节点保有可信的终局决定,就可能继续恢复;如果只剩 prepared 且缺少决定证据,重新计时或再选一个没有历史的协调者都不能补出证据。锁式参与者还可能因此阻塞访问同一记录的其他事务。
安全性允许等待;活性必须另列条件,例如协调者及必要参与者恢复、稳定记录可读、重传得到调度、通信最终足够及时。不能把“节点还活着”和“能够及时交换完成协议所需的信息”混为一谈。给协议增加阶段也不能自动消除这些前提,任意分区下不能把超时当作故障的可靠证明。MIT 事务 FAQ
复制可以提高决定记录的可用性,但不会把全体业务参与者的同意降成多数同意。订单、库存、付款分别做不同工作;缺少付款的同意,不能因为另两项成功就假定付款也成功。
flowchart TD
C[事务协调服务<br/>内部可以复制决定] --> O[订单参与服务]
C --> I[库存参与服务]
C --> P[付款参与服务]
O --> OR[组内多数副本<br/>保存同一状态]
I --> IR[组内多数副本<br/>保存同一状态]
P --> PR[组内多数副本<br/>保存同一状态]
O -. YES .-> ALL[跨服务提交要求全部参与者同意]
I -. YES .-> ALL
P -. YES .-> ALL
Gray 和 Lamport 的 Paxos Commit 为各资源管理器的准备结果分别运行共识,再据这些结果判断整笔事务。它并非简单把参与者的 YES 数过半就提交。本文没有实现 Paxos Commit;下一篇 Spanner 会进一步讨论复制组、事务协调和时间如何组合。
Saga:已经可见的动作只能用新动作补偿
Saga 将长流程拆成可单独提交的局部事务。某个后续步骤失败后,执行已经完成步骤的补偿。原始 Sagas 论文明确允许局部事务与其他事务交错,因此补偿不提供“整个流程从未发生”的可观察历史。Garcia-Molina、Salem:Sagas,SIGMOD 1987
以整数单位的教学账本为例:初始余额 100,订单 A 扣 30 后为 70,其他业务 B 加 20 后为 90。A 的后续步骤失败,补偿应当归还 A 的 30,结果为 120。如果把余额直接恢复成 A 开始前的快照 100,就抹去了 B 的合法更新。其他读者此前看到的 70、90,也不会因为余额回到 120 而被从历史中删除。
flowchart TD
S[余额 100] --> A[A 局部扣款提交:70]
A --> R[其他读者可观察 70]
A --> B[B 独立加 20:90]
B --> F[A 后续步骤失败]
F --> GOOD[按 A 的身份补偿加 30:120]
F --> BAD[错误恢复旧快照:100<br/>覆盖 B 的更新]
GOOD --> RETRY[重复补偿 A:仍为 120]
补偿必须知道撤销的是哪一笔效果。这个例子用稳定的补偿身份,将“已补偿”记录与加回余额放进同一本地事务;重试时读到原记录就不再增加。实际业务可能需要取消预订、释放占用或新增退款记录,不能普遍用数值取反实现。金额精度、手续费与不可逆动作都属于业务语义,不能从 Saga 名称推导出来。
补偿自身也可能失败。工作流需要保存步骤状态,保留继续补偿所需的参数,并区分“原步骤确定失败”和“结果未知”。原请求超时后盲目启动相反动作,可能与迟到的成功操作交错;通常还需要稳定请求身份、查询、状态条件或预约资源的生命周期约束。长时间未恢复的补偿需要明确的人工处理路径,不能宣称只要重试就一定成功。Azure Compensating Transaction,2026-04-20
串行描述的 T1、T2、T3、C2、C1 只表示一条选定流程。现代业务补偿的依赖关系可能允许部分并行,也可能要求优先处理某个资源。需要证明的是业务不变量恢复到允许状态,以及其他已提交操作没有被覆盖。
Outbox:把后续义务放进订单事务
若允许“订单先创建,扣款随后处理”,订单服务可以在同一本地数据库事务中写入订单和待发事件。这个待发事件表称为 Outbox。事务结束后只有两种有效结果:两条记录都没有,或者两条记录都有。独立 Relay 只读取已经提交的待发事件,负责投递。AWS Transactional Outbox
flowchart TD
S[创建待支付订单] --> TX
subgraph TX[订单库的一次本地事务]
O[订单记录] --- E[Outbox:稳定事件 ID 与参数]
end
TX --> R[Relay 读取已提交且未发送事件]
R --> Q[消息系统接收并持久保存]
Q --> M[回写 Outbox 已发送标记]
Q --> D[消费者:独立事务]
Q -. 接收后标记前退出 .-> AGAIN[恢复后再次投递同一个事件 ID]
AGAIN --> Q
原子化的是订单与发送意图。订单事务提交后,Relay 暂时不可用时,事件仍然在库中等待;它不会随着应用进程退出而消失。若 Relay 永远不恢复或待发记录被删除,数据库提交本身当然不能保证最终送达。因此,最终投递依赖扫描与重试持续运行、消息系统恢复接收,以及记录保留。
Relay 仍面对两个系统。先发布、后标记,在消息已接收但标记尚未写入时退出,恢复后会重发。反过来先标记、后发布,则可能漏发。Outbox 接受重复投递,将稳定事件身份一直传到消费者。每次传输的 delivery ID 可以不同,逻辑事件 ID 必须相同。
订单 API 的客户端重试也是同一类问题:提交后未收到响应,重试应当携带同一个订单身份,核对原参数并返回已存在的结果。重试时重新分配订单 ID,会创建第二笔业务;复用同一 ID 却改变金额,应当作为冲突拒绝,不能静默当作成功。
顺序需要单独设计。多 Relay 并发发布同一订单的多个事件时,扫描顺序、发布确认顺序和消费顺序可能不同。生产实现可以按业务聚合分区、携带单调业务版本,并定义遇到缺口或旧版本时如何处理;单纯加一个时间戳并不能证明端到端有序。本文实验使用单个逻辑事件,不声称验证多事件有序。
批量 API 还增加了另一种边界:HTTP 成功不等于批中每项成功。SQS SendMessageBatch 的官方契约明确允许 HTTP 200 同时携带 Successful 和 Failed 项;Relay 必须逐项确认后更新待发状态。这里是 API 文档核验,没有实际调用 AWS。SendMessageBatch API
消费端的去重记录必须与效果一起提交
消费者收到事件 E 后要扣款。只在内存保存“见过 E”无法跨进程恢复;持久化去重表但分开提交,同样存在窗口。先单独提交去重记录再扣款,崩溃重试会误以为已经处理;先扣款再单独写去重记录,崩溃重试会再扣一次。
sequenceDiagram
participant Q as 消息系统
participant C as 消费者
participant D as 消费库
Q->>C: 事件 E 与完整参数
C->>D: Begin
C->>D: 插入 E 的唯一去重记录
C->>D: 扣款并保存首次结果
C->>D: Commit
Note over C,Q: 此处退出,消息会再次交付
Q->>C: 再次交付 E
C->>D: 核对已处理 E 与参数
D-->>C: 返回首次结果,不再扣款
C-->>Q: ACK
正确边界是一个消费端事务:去重唯一键、参数绑定、业务修改和首次结果一起成功或回滚。多种消费者处理同一事件时,身份通常还需包含消费者或订阅者标识。该实验只有一个消费者,因此使用单一事件键;这个简化不应直接推广到所有订阅共享一张表。Richardson:Idempotent Consumer,2020-10-16
假设事件 E 第一次处理后余额为 70,另一个事件随后把余额改成 90。E 重试的返回应当是原处理结果 70,同时当前余额保持 90。返回当前值会把“重试原操作”和“查询最新状态”混在一起。第 15 篇讨论的首次结果缓存,在消息消费端仍然适用。
唯一约束只是发现身份冲突的机制。只有查明同一 ID 已经成功处理、且参数相同,才能按重复消息处理;数据库 I/O 错误、余额不足、其他约束失败都不能吞成“已处理”。去重记录若需要回收,还须限定最大重放范围,保留期之外的旧消息不能继续获得无限期去重保证。
如果副作用是外部支付 HTTP 请求或发送邮件,它不在上述 SQLite 事务内。本地账本的去重检查不能自动覆盖外部系统;还要依赖对方接受稳定幂等键、提供结果查询或明确的补偿协议。本文只检验消费库内的业务效果。
本地实验:沿持久边界退出并恢复
正式代码是 examples/distributed-systems/transactions25/check.py,依赖已有 Python 标准库 sqlite3。每个场景使用三个独立数据库:订单与 Outbox 同处订单库,教学队列单独一库,消费者去重记录与余额同处消费库。没有使用 ATTACH 将它们合并成跨库事务。
flowchart TD
DRIVER[父进程:枚举切点并查询持久结果] --> W[当前 Python 解释器运行正式脚本]
W --> O[orders.db<br/>订单与 Outbox]
W --> B[broker.db<br/>教学投递记录]
W --> C[consumer.db<br/>inbox 与余额]
W --> EXIT[指定切点受控退出 73]
EXIT --> NEW[新进程打开同一组库继续]
NEW --> CHECK[比较数据库状态、投递次数与业务效果]
连接显式使用事务控制,并回读 journal_mode=DELETE、synchronous=FULL、temp_store=MEMORY。SQLite 官方文档解释了回滚日志模式下的原子提交以及文件系统、刷盘等前提;这些配置不是本机断电测试的替代品。SQLite Transaction、Atomic Commit In SQLite
运行命令从仓库根目录执行,所有运行目录、日志、数据库及临时目录都留在仓库:
1 | |
进程切点采用正式脚本子模式中的 os._exit(73)。它可以跳过 Python 的正常清理流程,随后由另一个进程重新打开数据库检查恢复结果;它仍是指定位置的受控退出,没有模拟存储设备断电、任意指令处崩溃或真实网络丢包。教学队列是独立 SQLite 文件,不是 Kafka、RabbitMQ 或 SQS 服务。2PC 部分是有限状态轨迹,不是 SQLite 的 XA 实现。
本次两轮运行均通过:一轮使用 Python 3.9.6 / SQLite 3.54.0,独立复跑使用 Python 3.14.4 / SQLite 3.51.3;忽略 PID 后,两轮摘要完全一致。13 个 SQLite 场景中包含 9 组退出后继续执行的进程对,均观察到不同 PID、先退出 73 再正常退出 0。父进程会先以新连接查询退出后的状态,可能首先触发回滚恢复;随后新子进程继续业务。
| 实际检查 | 观察结果 |
|---|---|
| 两种分开双写 | 分别留下无消息的订单、无订单的消息 |
| 订单事务的三个切点 | 提交前订单与 Outbox 都无;提交后都有,重试仍仅一组 |
| Relay 接收后未标记便退出 | 恢复生成两条不同投递记录,逻辑事件与参数相同 |
| 消费事务的三个切点 | 提交前全部回滚;提交后未 ACK 的重投不再扣款 |
| 首次结果与当前余额 | 原扣款结果 70;另一业务加款后当前余额 90,重投仍返回 70 |
| 错误拆分消费事务 | 先记去重导致余额 100、漏扣;先扣款导致余额 40、双扣 |
| 补偿与旧快照对照 | 幂等补偿保留并发加款,余额 120;错误旧快照覆盖成 100 |
| 2PC 有限状态模型 | 两个世界中 P 视图相同;两种单方面超时决定各产生一个分裂反例 |
金额参数冲突另行检查:同一订单或事件 ID 改成金额 31 时拒绝处理,拒绝前后三库状态保持一致。原始事件、版本与进程记录见同名附件。安全性质的论据由协议推导和具体反例共同提供;有限几个退出位置没有穷尽所有并发执行,也没有证明生产系统的吞吐、隔离级别或端到端恰好一次。
如何判定所需边界
| 需求 | 必须覆盖的状态 | 失败后仍需承担的义务 |
|---|---|---|
| 跨资源共同提交 | 全体参与者的准备与唯一决定 | 保留恢复信息,传播已形成的决定 |
| 长流程允许中间可见 | 已完成步骤及其业务补偿参数 | 重试或补偿,保留并发合法更新 |
| 订单提交后可靠驱动后续动作 | 订单与 Outbox 同一本地事务 | 重试投递并处理积压,接受重复 |
| 重复消息不重复扣本地账本 | 去重身份、参数、效果与首次结果 | 同事务提交,保留足够重放历史 |
这些机制可以组合。一个 Saga 步骤可以用 Outbox 发出下一步命令,消费者用本地事务去重;一个分布式数据库也可以把 2PC 参与服务建在复制组上。判断组合是否正确,要沿实际调用链逐一确认每个原子边界,不能仅凭方案名称。
两道练习
练习一:准备状态的超时。 A 已收到 Commit,B 只有 Prepared。协调者暂不可达。若 B 规定等待 30 秒后 Abort,指出被破坏的性质;再说明协调者在收齐 YES 之前超时 Abort 为什么不构成同一个反例。
参考推导:A 和 B 将得到相反终局,破坏原子提交的一致决定。协调者尚未决定时可以形成 Abort,前提是没有已经形成的 Commit;B 投 YES 后却不知道全局处于哪个阶段。等待时长本身不提供决定证据。
练习二:补偿与去重。 余额从 100 开始,E 扣 30,F 加 20。E 的补偿归还 30,但 ACK 丢失,补偿重试。给出正确最终余额、重复扣款 E 的原返回结果,并解释为何不能直接写回 100。
参考推导:补偿身份与加 30 原子登记后,第一次补偿使余额成为 120,重复补偿仍为 120。E 原处理结果为 70,重复 E 应返回该结果而不改当前余额。写回 100 会覆盖 F;即使最终余额正确,也不能撤销其他读者此前观察到的中间值。
资料与核验范围
课程结构采用 MIT 6.5840 Spring 2026 的事务到 Spanner/FaRM 顺序,并参考 Stanford CS244b Spring 2024 的论文讨论。Saga 与 Outbox 是本系列补入的工程衔接,不宣称它们都是两校该年度的独立课程单元。
Gray/Lamport 引用作者 2017-07-05 修订副本,封面说明修正了最后一页的小错误;本文未将旧版附录当作未经修订的证明。Sagas 引用 1987 年原文。AWS、SQLite 与 Azure 的页面版本或访问日期、逐项论断和反例核验保存在资料记录,本地检查记录在验证记录,可比较的摘要见原始观察。
下一篇讨论 Spanner:同样需要区分跨分片提交、复制组容错与读写的时间约束,并进一步解释外部一致性由哪些条件支持。
