两阶段提交与 TCC:准备之后,由谁保存最终的决定
一次跨库转账,两个数据库都已经「准备好」了,协调者服务这时发布重启。重启之后,扣款库里有一行一直被锁着,相关的查询全部超时;另一次故障里,一笔 TCC 交易的取消先于冻结到达,账户凭空多出了 100 元。
两阶段提交(2PC)和 TCC 都想解决同一个问题:让多个资源上的修改要么都生效,要么都不生效。它们的共同弱点在同一个地方:第一阶段之后、第二阶段之前。参与者已经做好了准备、锁住了资源,最终的决定却掌握在协调者手里;协调者此时消失,或者第二阶段的消息乱序、重复、丢失,参与者自己无法做出正确的判断。
本文在 MySQL 8.4.11 上用 XA 事务演示准备之后的状态,再用三种 TCC 实现演示空回滚、悬挂和重复确认。实验见文末。
一、两阶段提交:准备意味着什么
XA 事务把一次提交拆成两步:
- 准备(PREPARE):每个参与者执行修改、写好日志、锁住相关的行,然后承诺「之后无论如何都能提交,也能回滚」。
- 提交或回滚(COMMIT / ROLLBACK):协调者收集所有参与者的准备结果,全部成功就让大家提交,任何一个失败就让大家回滚。
两个分支都 PREPARE 之后,XA RECOVER 列出 [t1a, t1b]
两个分支都 COMMIT:a=900 b=1100正常情况下这很简单。问题在于第一步的承诺:准备好的分支必须一直保持可以提交的状态,直到收到决定。
二、协调者消失之后
准备之后协调者的连接全部断开:XA RECOVER 仍列出 [t2a, t2b];余额仍是 a=900 b=1100
另一个事务更新 bank_a 的同一行(锁等待超时 2 秒):失败(Lock wait timeout exceeded)
更新同一张表的另一行:成功
MySQL 实例重启之后 XA RECOVER:[t2a, t2b, t3b]
重启之后,另一个事务更新 bank_a.id=1:失败(Lock wait timeout exceeded)协调者的连接断开了,准备好的分支没有消失,也没有自动回滚,它们一直持有行锁。重启 MySQL 也一样:准备好的事务被持久化了,重启后恢复出来,锁也恢复了。这正是 XA 必须做到的,否则「准备」这个承诺就不成立;它的代价就是开头那一行一直被锁着的数据。
这些「悬而未决」的分支只能由外部做出决定:用 XA RECOVER 找到它们,按协调者的决定执行 XA COMMIT 或 XA ROLLBACK。MySQL 允许从另一个会话结束它们。
三、只提交了一半
协调者提交了分支 a 后消失:bank_a.id=2 已是 700,XA RECOVER 列出 [t2a, t2b, t3b]更棘手的情况是协调者已经开始第二阶段,提交了一个分支之后消失了。此时:
- 分支 a 已经提交,钱已经扣了;
- 分支 b 处于准备状态,不知道该提交还是回滚。
只看数据库,无法判断 t3 的决定是什么:它可能是「提交」(然后协调者在发送第二条提交时消失),也可能是「回滚」(然后 a 不应该被提交,这不可能发生,但数据库并不知道协调者的逻辑)。唯一的依据是协调者在第二阶段开始之前持久化的决定。没有这份记录,恢复程序只能猜,或者等人来判断。
由恢复程序按协调者的决定从新会话 XA COMMIT 剩余分支:XA RECOVER [];id=1 a=700 b=1300;id=2 a=700 b=1300所以两阶段提交的协调者本身必须是可靠的:它的决定日志要持久化,它的恢复程序要在重启后扫描所有参与者的未决分支并完成它们。很多系统选择不用 XA,原因之一就在这里:协调者成了一个需要和数据库同等可靠的组件,而且准备阶段持有的锁会把一次故障的影响扩大到所有访问这些行的请求。
四、TCC:把两阶段搬到业务层
TCC(Try-Confirm-Cancel)把两阶段提交的思路搬到业务接口上:
- Try:检查并预留资源,例如把 100 元从可用余额转到冻结金额;
- Confirm:真正使用预留的资源,例如扣掉冻结金额;
- Cancel:释放预留的资源,例如把冻结金额退回可用余额。
它不需要数据库的 XA 支持,也不在两个阶段之间持有数据库锁(预留通过业务字段表达)。代价是三个接口都要由业务自己实现,并且要处理消息的乱序、丢失和重复。三个典型问题:
朴素实现 只处理空回滚 带分支状态表
Try 丢失后收到 Cancel(空回滚) balance=1100 frozen=-100 balance=1000 frozen=0 balance=1000 frozen=0
Cancel 之后迟到的 Try(悬挂) balance=1000 frozen=0 balance=900 frozen=100 balance=1000 frozen=0
Confirm 被重试两次 balance=900 frozen=-100 balance=900 frozen=0 balance=900 frozen=0空回滚:Try 请求在网络中丢失,或者超时了还没到,协调者判定失败并发出 Cancel。朴素的 Cancel 不检查 Try 是否执行过,直接把冻结金额退回余额,凭空多出 100 元。修法是 Cancel 先查这个全局事务在本服务里有没有 Try 记录,没有就不做任何资金操作。
悬挂:只处理空回滚还不够。Cancel 之后,迟到的 Try 终于到达,它不知道这笔交易已经取消了,照常冻结了 100 元。之后不会再有任何 Confirm 或 Cancel,这 100 元永远冻结。修法是空回滚时留下一条「已取消」的记录,Try 到达时先检查,发现已取消就拒绝执行。
重复确认:Confirm 和 Cancel 都可能被协调者重试,朴素实现每次都扣一次冻结金额。修法是每个分支的状态只能按「已预留 → 已确认 / 已取消」转换一次,重复的请求直接返回。
三个问题的修法是同一张表:每个全局事务在本服务里有一条分支记录,Try、Confirm、Cancel 都先在同一个本地事务里读取并更新它的状态,再操作资金。这张表就是 TCC 参与者自己保存的「决定」。朴素实现在悬挂场景里结果看起来正确,是因为空回滚先错误地加了 100,迟到的 Try 又减去了 100,两个错误恰好抵消;换一个顺序就会暴露。
五、怎样选
| 做法 | 一致性 | 代价 | 适合 |
|---|---|---|---|
| 单个数据库的本地事务 | 强 | 无 | 能放进一个库的,优先放进一个库 |
| XA 两阶段提交 | 强(协调者可靠的前提下) | 准备阶段持有锁;协调者需要持久化决定与恢复 | 少量、短事务,参与者都支持 XA |
| TCC | 最终一致,预留期间资源不可用 | 每个参与者实现三个接口与分支状态表 | 资金、库存这类可以「预留」的资源 |
| 本地消息表 / Outbox + 幂等消费 | 最终一致 | 消费方幂等,需要补偿逻辑 | 大部分跨服务的业务流程 |
无论哪种,都离不开状态记录、幂等和对账:订单与库存之间怎样用状态机、幂等与对账保证一致,见订单、库存与数据一致性;可靠事件的发布方式,见上下文集成。
六、检查清单
- 能放进一个数据库事务的修改,不拆成分布式事务。
- 使用 XA 时,协调者在第二阶段之前持久化决定,并有扫描
XA RECOVER的恢复程序。 - 监控长时间处于准备状态的分支,它们持有锁。
- TCC 的每个参与者有分支状态表:处理空回滚、拒绝悬挂、保证 Confirm 与 Cancel 幂等。
- 所有方案都配合对账,发现并修正少数不一致。
配套实验
- codesphere-labs/distributed/xa-and-tcc:MySQL 8.4.11、JDK 25.0.4.1:XA 分支在协调者断开与实例重启后的状态与行锁、只提交一半的恢复;TCC 三种实现的空回滚、悬挂与重复确认(验证记录)
参考资料
- MySQL 8.4 参考手册:XA Transactions、XA Transaction States
- Jim Gray、Leslie Lamport,Consensus on Transaction Commit
- Pat Helland,Life beyond Distributed Transactions