16 · 跨节点原子提交与补偿
目标:解释两阶段提交的待决状态,并区分原子提交与业务补偿。
一个事务离开了单个数据库
前两课把相关修改放在一个数据库中。现在一个报名需要同时占用活动名额和设备租赁名额,分别由两个数据节点保存。若 A 提交、B 失败,用户就得到半份承诺。跨节点原子提交要使参与者对同一事务采取一致的最终决定。
两阶段提交(2PC)先询问能否提交。参与者持久记录准备状态、保留所需资源并投票。全部同意后,协调者持久记录提交决定,再通知各参与者。准备成功不是“已经提交”,却意味着参与者不能再随意反悔。
关键困难在协调者故障:某个参与者可能只知道自己投了赞成票,不知道提交决定是否已发给别人。此时自行超时回滚,可能与已提交的另一方矛盾。经典 2PC 会在这种情况下阻塞;这不是把超时时间调小就能修复的问题。
一半节点收到决定
保存 distributed_commit.py。字典代表故障后仍保留的日志,本例不测试实际磁盘持久性。先预测 B 是否能根据“长时间没消息”安全决定回滚。
participants = {"A": "prepared", "B": "prepared"}
coordinator_log = {"tx1": "commit"}
def deliver(node, decision):
current = participants[node]
if current in {"commit", "abort"}:
assert current == decision
participants[node] = decision
deliver("A", coordinator_log["tx1"])
assert participants["B"] == "prepared"
print("before_recovery:", participants)
unsafe = dict(participants)
unsafe["B"] = "abort"
assert len(set(unsafe.values())) == 2
for node in participants:
deliver(node, coordinator_log["tx1"])
assert participants == {"A": "commit", "B": "commit"}
print("after_recovery:", participants)
uv run --python 3.12 --no-project distributed_commit.py
预期输出:
before_recovery: {'A': 'commit', 'B': 'prepared'}
after_recovery: {'A': 'commit', 'B': 'commit'}
重复发送相同决定是安全的。恢复依据是可信的持久决定,不是哪个节点先回消息。若协调者日志不可用,模型里 B 就必须继续待决;生产系统可能复制协调者状态以降低这一风险,但参与者协议和资源占用仍需处理。
引导修改:① 把协调者决定改为 abort;② 相应最终断言改成两个 abort;③ 重发同一个决定,结果保持不变。不要从已经提交的状态再跑“改成 abort”的通知,提交决定不可撤销。
补偿接受了什么不同语义
另一种路线是先预留活动名额,再预留设备;第二步失败则发起取消活动预留。这是业务补偿,允许中间状态被观察到,取消也可能失败并需要重试。它不是 ACID 回滚。若系统先告诉用户“已确认两项资源”,之后再撤销,就改变了产品承诺。
异构系统的 XA 事务把多种资源拉进统一提交协议,需要各方支持,并使事务生命周期与故障恢复更耦合。数据库内部跨分片事务可以把协议集成得更紧密,但跨外部 API 往往无法要求同样能力。
独立练习:为活动与设备预留写出四个工作流状态,指出何时才允许显示“报名完成”,以及补偿持续失败时怎么办。
参考状态机
pending → activity_reserved → confirmed:第二个箭头必须等设备也成功。设备失败进入 compensating,释放活动预留成功后成为 cancelled。四个核心中间及成功状态为 pending、activity_reserved、compensating、confirmed;cancelled 是失败终态。补偿超时保持 compensating,记录操作 ID、有限退避并升级处理,不能伪装成已释放。用户只在 confirmed 看到全部成功。
若出现互相矛盾的终态,检查是否有参与者在 prepared 后擅自决定;若锁占用过久,检查待决事务及协调者恢复路径。在 design.md 说明初始版本为何把名额约束保留在一个事务边界,并记录未来外部预留需要的状态机。