系列目录
- MySQL 索引与慢查询:B+ 树如何减少扫描
- 声明式事务之下:InnoDB 的 MVCC 与锁
- 接入层:Nginx 反向代理与 OpenResty 的边界
- Web 容器与 Netty:线程模型之下的 IO 模型
- Redis(上):缓存用法与单线程模型的限制
- Redis(下):超出缓存用途的用法:锁、队列与排行榜
- 数据库访问层:连接池与 MyBatis 的显式 SQL
- Kafka(上):吞吐的来源是顺序 IO
- Kafka(下):生产端、broker 与消费端的可靠性配置
- RPC 框架:像本地调用一样调远程的代价
- 消息语义:按 at-least-once 设计业务代码
- 熔断与限流中间件:把失败当作正常状态管理
- 唯一 ID 中间件 Leaf:号段模式与雪花模式
- RocketMQ 的事务消息与延迟消息
- 分布式任务调度:同一时刻只跑一份
- ZooKeeper/etcd:小数据强一致的协调服务
- 分库分表:用应用层复杂度换数据库容量
- 分布式事务:2PC 的代价与业务补偿模式(本篇)
跨服务写操作的一致性
跨服务、跨库的写操作如何保持一致,是分布式事务要处理的问题。分库分表:用应用层复杂度换数据库容量提过,水平拆分后数据库不再保证跨分片事务。XA 的持锁阻塞会在高并发链路中带来较高成本,常见做法是采用最终一致(见 消息语义:按 at-least-once 设计业务代码),RocketMQ 的事务消息与延迟消息说明事务消息如何绑定本地事务提交与消息可见。本篇说明 2PC、TCC、Saga,以及 Seata 等框架标准化协调逻辑后的约束。
我经手的跨服务一致性链路采用消息最终一致:本地事务加事件表、扫描投递、消费幂等、对账兜底。团队讨论过在资金和库存等场景使用 TCC,但我没有在生产中运行过 TCC 或 Saga 全链路,也没用 Seata 承载过核心链路。下文对 TCC、Saga、Seata 的说明基于官方文档、公开资料和本地验证;生产经验不足的部分会显式标注。
单库事务里的多条写操作要么全部成功,要么全部失败,由 InnoDB 的 redo/undo 保证(声明式事务之下:InnoDB 的 MVCC 与锁讲过)。操作跨越两个库或两个服务后,单库事务不能覆盖两边:A 库提交成功、B 库提交失败时,A 的数据已落盘,单库回滚无法恢复一致性。分布式事务处理的是多个独立资源管理器上的写操作如何保持提交或回滚一致。两阶段提交(2PC)是经典协议;工程中多数链路使用消息最终一致,不能容忍中间状态的少数场景使用 TCC 或 Saga。
原理拆解:2PC 的过程与阻塞点
2PC 引入一个协调者(coordinator),所有参与者的提交决策由协调者统一驱动,过程分两阶段。
第一阶段叫 prepare(投票)。协调者向所有参与者发 prepare 请求,参与者执行本地事务到「可提交但未提交」的状态:写 redo/undo log、锁定相关行,然后应答 yes 或 no。应答 yes 意味着承诺可以提交并已锁住资源,应答 no 则本地回滚释放锁。
第二阶段叫 commit 或 abort(决策)。协调者收齐应答:全部 yes 则发 commit,参与者提交、释放锁;只要有一个 no 或超时未应答,协调者发 abort,参与者回滚、释放锁。
阻塞点在两阶段之间。参与者应答 yes 之后、收到 commit 之前,它持有锁、不能自行决定提交或回滚,因为它不知道其他参与者应答了什么,也不知道协调者会发 commit 还是 abort。协调者在这段窗口宕机,参与者只能持锁等待协调者恢复。超时释放锁不安全:参与者无法判断协调者是否已发 commit,万一已发只是网络丢了,自行回滚就和其他已提交的参与者不一致。所以参与者只能阻塞等待,锁持有时间等于协调者恢复时间。高并发链路里一个参与者的行锁被卡住,后续所有要碰这些行的请求都在排队,这些行上的写吞吐在协调者恢复前降到零。
协调者本身是单点。它宕机的恢复靠日志:协调者把全局事务的决策状态记在日志里,重启后读日志决定对每个参与者发 commit 还是 abort;参与者宕机恢复后也靠日志里「应答过 yes 但未收到最终指令」的记录向协调者查问结局。这套恢复协议能工作的前提是参与者愿意无限期持锁等待,2PC 的可用性因此等于协调者的可用性。
XA 是 2PC 在数据库层的标准实现。MySQL 5.0 起支持,XA START / XA END / XA PREPARE / XA COMMIT 是它的 SQL 接口,协调者角色留给应用层,参与者由 InnoDB 承担。MySQL XA 的生产使用率很低:prepare 后持锁阻塞、协调者单点、5.7 之前主从复制下 XA 状态可能不一致,多数团队选型时就绕开。我在生产里没见过用 MySQL XA 承载核心链路的。XA 透明(SQL 不改)是优点,但抵不过持锁阻塞在高并发下的代价。
原理拆解:TCC 的语义、空回滚和悬挂
TCC(Try-Confirm-Cancel)把每个操作拆成三个接口,由业务实现。Try 预留资源:扣款场景冻结一笔额度(available 减、frozen 加,总额不变),扣库存场景预占库存,不产生最终效果,只把资源占住。Confirm 确认扣减:全局事务提交时把 frozen 清掉、余额真正减少,或把预占转为已扣。Cancel 释放预留:全局事务回滚时把 frozen 还回 available,或释放预占库存。
和 2PC 的区别:TCC 没有长期持锁阶段,Try 完成就释放(资源以业务字段表达,不是行锁),Confirm/Cancel 是后续独立调用。它要求业务实现每个操作的三个接口,且资源必须能「预留」。发短信、调用第三方支付等外部副作用没有 Try 语义,不适用 TCC。
TCC 需要处理空回滚和悬挂。空回滚:Try 因网络问题没有到达参与者,协调器超时后决定回滚并发送 Cancel;Cancel 可能先于 Try 到达,直接执行会误扣不存在的预留。Cancel 执行前先查询事务记录表(主键是全局事务 ID):Try 时插入记录,Cancel 查不到记录说明 Try 未到,写入一条「已 Cancel」占位记录后直接返回。悬挂:Cancel 已执行,迟到的 Try 才到;如果 Try 照常预留,这笔预留不会由 Confirm 处理。Try 执行前查询事务记录表;记录已存在且状态为 Cancel 时直接拒绝。Try 和 Cancel 的到达顺序不保证,因此两种情况都通过事务记录表处理;该表要和业务表位于同一数据库,并在同一本地事务中写入。
Confirm 和 Cancel 都要幂等。协调器重试是常态,接口不幂等会重复扣减或释放。幂等靠事务记录表的状态字段:执行前查状态,已执行直接返回。
// TCC 三接口最小骨架(脱敏,幂等与异常分支靠事务记录表)
public class AccountTccAction {
// Try:冻结额度,available 减 frozen 加
public boolean tryFreeze(String txId, long accountId, BigDecimal amount) {
if (txLogDao.exists(txId, "CANCEL")) return false; // 悬挂拦截
accountDao.freeze(accountId, amount); // available-=amount, frozen+=amount
txLogDao.insert(txId, "TRY");
return true;
}
// Confirm:扣减冻结额
public boolean confirmDeduct(String txId, long accountId, BigDecimal amount) {
if (txLogDao.exists(txId, "CONFIRM")) return true; // 幂等
accountDao.deductFrozen(accountId, amount); // frozen-=amount
txLogDao.update(txId, "CONFIRM");
return true;
}
// Cancel:解冻
public boolean cancelFreeze(String txId, long accountId, BigDecimal amount) {
if (txLogDao.exists(txId, "CANCEL")) return true; // 幂等
if (!txLogDao.exists(txId, "TRY")) { // 空回滚:Try 未到
txLogDao.insert(txId, "CANCEL"); // 占位,挡住迟到的 Try
return true;
}
accountDao.unfreeze(accountId, amount); // frozen-=amount, available+=amount
txLogDao.update(txId, "CANCEL");
return true;
}
}这段骨架省略并发控制,只表达三接口职责边界和异常分支拦截位置。真实实现里,冻结、扣减、解冻要和 txLogDao 写入在同一本地事务原子完成。
原理拆解:Saga 的语义和隔离性限制
Saga 把一个长事务拆成一串本地事务,每步提交后立即生效,失败时靠反向补偿回退。和 TCC 的区别:Saga 没有 Try 预留阶段,每一步都真正执行并提交,中间状态对外可见。
旅行预订是典型场景:订机票(T1)、订酒店(T2)、租车(T3)。T3 失败时回退 T2、T1:C2 取消酒店、C1 取消机票。每个 Ti 配一个补偿 Ci,失败时反向执行 Ck-1…C1,补偿由业务实现,不是数据库回滚。
Saga 的限制是缺少隔离性。2PC 在 prepare 后持锁,其他事务读不到中间状态;Saga 的每一步都会立即提交,T1 提交后、T2 完成前,外部可能读到「机票已订但酒店没订」的中间状态。如果业务或外部系统据此决策,补偿回退后该决策可能不再成立。
可使用语义锁或版本号降低这一影响。语义锁:业务字段增加状态标记,订单状态设为「处理中」,Saga 完成后才置为「已确认」或「已取消」,外部读到「处理中」时不能将其作为最终结果。版本号:每步带递增版本号,外部读取时校验版本,读到中间版本即可识别流程尚未完成。两种做法都在应用层补充隔离约束,业务表需要增加状态字段或版本字段。
补偿操作必须幂等:C1 取消机票执行两次时第二次不该报错;补偿还要能处理「原操作未生效」的情况:T1 实际没执行,C1 来补偿时查不到记录直接返回成功,和 TCC 空回滚是同一类问题。
Saga 的协调有两种实现。编排式(orchestration)用中心化协调器按顺序调各步,失败时反向调补偿,逻辑集中好维护;事件驱动式(choreography)每个参与者执行完发事件驱动下一步,无中心协调器,流程分散排查链路长。编排式更常见。
原理拆解:Seata 把协调者抽成中间件
Seata(2019 年开源,前身 Fescar)把协调者从业务抽成独立中间件。架构分三个角色:TC(Transaction Coordinator)独立部署,维护全局事务提交/回滚决策和分支状态;TM(Transaction Manager)嵌入应用,开启全局事务、决定提交或回滚;RM(Resource Manager)嵌入应用,注册分支事务。TM 和 RM 以 SDK 嵌在应用进程。
业务侧可用 @GlobalTransactional 标注方法。方法内的多个数据库写操作会被 RM 拦截并注册为分支,TM 决定提交时通知 TC 协调各分支。代码形式接近本地 @Transactional,但全局锁和 undo log 仍会增加实现约束。
Seata 的 AT(Automatic Transaction)模式允许业务继续使用普通 SQL。RM 在执行前自动生成 undo log(记录修改前的行数据)并存入本地 undo log 表;全局事务回滚时,RM 用 undo log 恢复数据。AT 不要求业务手写 Cancel 或补偿;TCC 和 Saga 则需要业务定义相应操作。
AT 模式不要求手写 Cancel,但需要全局锁。RM 执行写操作前向 TC 申请全局锁(锁住被修改行的主键),保证同一行在全局事务提交前不被其他全局事务修改。本地事务提交后、全局事务提交前,其他全局事务修改该行会被全局锁阻塞,但读取不受影响:不参与全局事务的普通查询能直接读到本地已提交的中间值,这就是 AT 模式在全局层面的读未提交隔离语义。要达到读已提交,查询侧需要显式加 @GlobalLock 注解(或包在全局事务里)并用 SELECT FOR UPDATE,让读也走全局锁检查。TC 管理的全局锁会带来锁等待;高并发下的全局锁竞争可能成为瓶颈,锁等待与 2PC 中的持锁阻塞类似,但锁的实现层不同。
Seata 还支持 TCC 和 Saga 模式:TCC 模式下业务实现三接口,Seata 负责调度和重试,空回滚、悬挂、幂等仍要业务自己处理;Saga 模式下用状态机或注解定义流程和补偿。两种模式下 Seata 提供协调和重试基础设施,业务侵入程度与手写方案相同。
Seata 将全局事务 ID 传播、分支注册、提交/回滚决策、undo log 生成和重试调度交给框架。它也引入框架依赖:@GlobalTransactional 语义、全局锁行为和 undo log 表结构依赖 Seata,升级迁移成本较高;AT 模式的全局锁在高并发下可能成为性能瓶颈;undo log 自动补偿只覆盖数据库写,对外部接口无效。框架处理协调逻辑,回退语义仍由业务定义,框架负责执行。
四种模式的适用范围
-
单库事务。 同一数据库实例内的多表写操作使用本地事务(
@Transactional+ InnoDB),不需要引入分布式事务;跨库时才考虑。分库分表:用应用层复杂度换数据库容量讲过分库分表后事务边界收缩到分片,同分片内仍是单库事务。 -
消息最终一致。 适用于业务能容忍秒级或分钟级延迟一致,且操作可拆成「本地事务 + 消息驱动下一步」的场景。本文涉及的多数业务链路使用该方案。消息语义:按 at-least-once 设计业务代码的消费幂等和 RocketMQ 的事务消息与延迟消息的事务消息构成这套方案的两个组件。不一致窗口由扫描间隔或回查上限决定,需要靠对账兜底。它适合订单创建后异步扣库存、支付成功后异步通知下游;操作必须同时成败或中间状态不可见时不适用。
-
TCC。 适用于资源可预留、业务不能容忍中间状态、需要强一致的场景,资金扣减和库存扣减是典型例子。每个操作都需要三个接口,并要处理幂等、空回滚和悬挂,业务侵入较高。资源无法预留的场景,例如外部副作用,无法使用该模式。
-
Saga。 适用于长流程业务、每步都是独立本地事务,且能接受中间状态可见并通过补偿回退的场景,例如旅行预订和订单履约。它缺少隔离性,需要通过语义锁或版本号在应用层补充;每个补偿操作都要实现且保持幂等。资源可预留时选 TCC;资源只能通过反向操作抵消时选 Saga。
-
2PC/XA。 SQL 无需修改使其对业务代码较透明。持锁阻塞和协调者单点会在高并发链路中带来较高成本,MySQL XA 的早期 bug 进一步压低采用率。我见过的生产链路没有用 XA 承载核心业务。
-
Seata 这类框架。 框架将 TCC、Saga、AT 的协调逻辑标准化。是否采用取决于团队是否有足够多的分布式事务链路来分摊运维和侵入成本:链路较少时,手写消息最终一致需要的基础设施较少;链路较多且复杂时,框架可复用事务 ID 传播、分支注册和重试调度。AT 模式的全局锁是性能边界。
降低业务侵入会增加协调层和锁的成本;业务自行实现得越多,承担的回退语义也越多。多数系统选择消息最终一致,用秒级延迟避免协调者单点和持锁阻塞;TCC 和 Saga 适用于资金、库存等对错误容忍度低的场景。选型需要判断业务能接受的代价。
局限
我读过 2PC 的理论,并在本地用两个库验证过 MySQL XA 的 prepare/commit/abort 流程,没有在生产中使用过 XA。TCC 和 Saga 的说明基于公开资料和文档;本地用 Seata 验证过 AT 模式的多分支回滚,没有在生产中运行过 TCC 或 Saga 全链路。Seata Server 集群部署、高可用运维,以及 AT 模式全局锁在高并发下的竞争表现,仅参考过文档。
参考资料
- Gray, J. (1978). Notes on Data Base Operating Systems.(2PC 早期论述)
- X/Open CAE Specification (1991). Distributed Transaction Processing: The XA Specification.(XA 协议规范)
- Seata 官方文档(AT/TCC/Saga 模式、TC/TM/RM 架构、undo log 与全局锁,1.x,2019–2020)
- MySQL 官方文档(XA 事务语句与限制,5.0–5.7)
- 消息语义:按 at-least-once 设计业务代码(消费幂等,本篇引用不复述)
- RocketMQ 的事务消息与延迟消息(事务消息半消息/回查,消息最终一致的协调层)
- 分库分表:用应用层复杂度换数据库容量(分片后事务边界收缩,跨分片改最终一致)
