前阵子跟一个做订单系统的同学聊到后半夜。他问我:订单创建完了,库存也扣了,但如果积分服务这时候超时了,订单算成功还是失败?我反问他:你们现在用了哪个分布式事务方案?他愣了半天,说目前这套是"碰运气式处理"——哪个服务失败就重试哪个,重试不成功就人工补单。这种状态在业务早期还能扛,一旦订单量上来、链路拉长,问题就会从"偶尔对不上账"变成"每天都要对账,对不完的账"。
所以这篇东西我想把主流的分布式事务框架与方案彻底串一遍,从最传统的 XA 协议开始,一直讲到 Seata 的四种模式。不是那种官方文档复读,而是把每个方案背后的设计动机、适用边界、以及我在实际项目里踩过的坑都摊开讲。你会看到:为什么 XA 协议当年被认为"太重",为什么 Seata 能靠 AT 模式把侵入性降到最低,又为什么 TCC、SAGA 这些模式反而需要更高的人力投入。适合正在做服务化拆分、订单库存类强一致场景、或者想给自己的系统引入分布式事务框架的开发者来参考。
1. 分布式事务爆发的根因:从单库到分库分表,ACID 是怎么失效的
1.1 单体时代的本地事务有多香
在做微服务拆分之前,绝大多数业务系统都是单库单应用。比如一个订单系统,订单表、库存表、账户表都放在同一个 MySQL 实例里。那时候写个下单逻辑,代码长这样:
java复制@Transactional
public void purchase(Long skuId, Integer count, Long accountId) {
orderMapper.insert(buildOrder(skuId, count, accountId));
inventoryMapper.decrease(skuId, count);
accountMapper.deduct(accountId, count * price);
}
数据库自己的事务机制会把这三条 SQL 包在一个原子单元里:要么全部提交,要么全部回滚。这个过程的约束能力来自数据库的 redo log、undo log 和行锁,业务代码基本不用关心,因为 @Transactional 背后就是一条 BEGIN 和一条 COMMIT / ROLLBACK。
这种模型在单库时代太舒服了,以至于很多团队在拆库拆服务的时候,第一件事就是原地复制这套代码,然后发现根本不work。订单表和库存表分别落到了两个数据库实例,甚至两个服务,本地事务边界互相不可见。原来的 @Transactional 只能保证"订单库里的插入"是原子的,至于 RPC 调用的库存扣减,它根本管不到。
1.2 拆开之后,什么叫全局事务、分支事务
服务拆开之后,我们需要重新定义事务的作用范围。一次用户请求可能跨订单、库存、积分三个服务,每个服务各自维护一个本地事务。这时候保证"所有服务要么一起成功,要么一起失败"的这种跨服务一致性,在分布式事务语境里叫做全局事务,每个服务里的本地事务叫分支事务。
早期不少团队的解法是"先调核心库,再调外围服务,外围失败了就补"。但这样做通常会踩到两个现实问题:
- 订单库先提交了,库存服务后面的失败无法让订单库自动回滚,只能写补偿逻辑去删单。
- 补偿逻辑本身也可能失败,或者被多个线程重复触发,最后状态还是乱的。
所以我们需要一套机制,能把多个数据库/服务纳入同一个全局事务来协调。业界把这种机制统称为分布式事务框架。而这个领域里最古老、也最"标准"的一套规则,就是 XA。
1.3 CAP 理论不是免死金牌,但能解释为什么没有免费午餐
很多人一谈分布式事务就搬出 CAP,说"分布式系统不能同时满足一致性、可用性和分区容错性,所以别追求强一致性"。这句话表述上不够准确,但方向是对的:跨节点的强一致性,需要付出额外的协调开销,而这个开销通常体现在延迟和可用性上。
分布式事务框架要做的,就是在"一致性强度"和"业务代价"之间找平衡。XA 选择了最强的两阶段提交,结果被很多人嫌弃太重;Seata 的 AT 模式想做轻量版两阶段,于是引入了全局锁和 undo log;TCC 把补偿交给业务,换来更高的并发;Saga 直接放弃隔离性,只为处理超长链路。
没有谁能同时做到零侵入、强一致、高并发、支持任意长事务。下面逐个拆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. XA 协议:正统二阶段提存在生产环境里到底卡在哪
2.1 XA 和 2PC 的基本流程
XA 是 X/Open DTP 组织定义的一套分布式事务处理标准。它有四个核心角色:应用程式(AP)、事务管理器(TM)、资源管理器(RM),以及用来传递消息的通信资源管理器。我们平时接触到的 MySQL XA、Oracle XA,指的都是数据库作为 RM 实现了 XA 接口。
XA 的工作过程,就是教科书里的二阶段提交:
- 应用发起全局事务,TM 要求所有 RM 执行准备操作。
- 每个 RM 在自己的事务内部做完所有写操作,但先不提交,而是把事务状态置为 ready,然后向 TM 返回"我可以提交"。
- TM 收到所有 RM 的准备成功响应后,进入第二阶段,向所有 RM 发出 commit。
- 如果任何一个 RM 在准备阶段失败,TM 就向所有 RM 发出 rollback。
这个机制的底层其实是在模拟单机事务里的"预提交"。数据库里已经产生了 redo/undo,但对外释放锁的时机被推迟到了 final commit。好处是强一致、强隔离;坏处也很明显,下面说。
2.2 XA 为什么在互联网高并发场景被嫌弃
XA 最大的问题是阻塞。在二阶段提交的第一阶段结束后,所有参与者都已经把资源锁住了。它们必须等 TM 发出最终决策,才能释放锁或者回滚。这个等待时间的长短取决于所有 RM 当前的状态和网络情况,而不是单个节点能决定的。
举一个直观的例子:A 库在杭州,B 库在上海,两个库之间网络抖动 2 秒。单库事务里这 2 秒最多影响本库的连接池,但 XA 事务会让 A 库和 B 库里的相关行都被锁 2 秒以上。对订单这种频繁写同一行库存的场景,这种等待基本不能接受。
还有个被低估的问题:TM 本身成为单点。如果第一阶段全部 prepare 完成,TM 在发出 commit 之前宕机了,所有 RM 都不知道该提交还是回滚,只能阻塞等待恢复。虽然可以通过 TM 持久化恢复,但恢复逻辑的复杂度并不低,很多业务团队根本不敢拿系统稳定性去赌这个流程。
Java 生态里 JTA 是对 XA 的官方封装,配合 Atomikos、Narayana 这些事务管理器,可以在 Spring 里实现跨数据源的强一致事务。我见过很多老的项目,确实靠这套方案跑了好多年。它们共同的特点是:并发量不高、对数据一致性极其敏感、事务链路短。比如银行核心系统里两个账户之间的转账,跨分行跨库,用 XA 虽然慢,但可接受。电商大促秒杀这种场景硬上 XA,就是自找麻烦。
2.3 从 XA 到 Seata 的思路转变
既然 XA 的痛点在于"让数据库资源一直锁到全局事务结束",那有没有可能让每个分支的数据库资源先释放,再做分布式协调?
Seata 这个框架的核心思想其实就一句话:把协调动作从数据库内部抽出来,放到独立的 TC 组件里,让分支事务能更快完成本地提交,回滚能力则通过额外的日志或业务补偿来实现。
在这个思路下,Seata 一共实现了四种模式:AT、TCC、SAGA、XA。其中前三种多多少少都牺牲了一些原生数据库事务的特点,来换取性能或灵活性;只有 XA 模式老老实实走标准协议,只是把 TM 换成了 Seata 自己的实现。
3. 认识 Seata:TC、TM、RM 三个角色和一条事务链路
3.1 Seata 不是只有一个 AT 模式
很多文章一提到 Seata 就默认指 AT 模式,其实是误解。Seata 是一个完整的分布式事务框架,内部抽象了三个角色,这三大角色共同构成了 Seata 的统一事务模型:
| 角色 | 全称 | 职责 |
|---|---|---|
| TC | Transaction Coordinator | 事务协调器,独立部署的 server,负责维护全局事务和分支事务的状态,驱动全局提交/回滚 |
| TM | Transaction Manager | 事务管理器,嵌入在发起方业务应用中,负责开启全局事务、提交或回滚全局事务 |
| RM | Resource Manager | 资源管理器,嵌入在参与方业务应用中,负责管理分支事务,向 TC 注册分支并汇报执行结果 |
TM 和 RM 都不是独立进程,它们以 SDK 的形式和业务代码跑在同一个 JVM 里。TC 则单独部署成一个服务,也就是我们常说的 seata-server。
3.2 一次完整下单请求里发生了什么
假设订单服务下单成功后要扣减库存,库存是另一个独立服务。要纳入 Seata 的全局事务,需要在外层方法上打 @GlobalTransactional 注解,而不是在某个具体 SQL 上打注解。
java复制@GlobalTransactional
public void createOrderAndDeductStock(Order order) {
orderService.insert(order);
inventoryClient.deduct(order.getSkuId(), order.getCount());
}
这笔请求的全局事务链路大致如下:
- 订单服务里的 TM 在方法入口向 TC 发起全局事务注册,拿到一个全局唯一的
XID,比如192.168.1.1:8091:208470290233201。这个 XID 会放到当前线程的上下文里。 - 订单服务自身执行订单插入时,它本地的 RM 会拦截这个写操作,向 TC 注册一条分支事务,并把 XID 和本地事务关联起来。
- 订单服务调用库存服务时,XID 会通过 RPC 的隐式传参透传过去。Dubbo 有专门的 filter,Spring Cloud OpenFeign 也有对应的 interceptor。如果你们用自定义 RPC,就必须手动在 header 里带上 XID,否则下游服务会认为这是一次新的非分布式事务调用。
- 库存服务收到请求后,它的 RM 同样向 TC 注册分支事务。
- 所有业务操作都执行完之后,订单服务里的 TM 再向 TC 发起全局提交或全局回滚请求。
- TC 根据当前分支状态,向所有 RM 下发分支提交或分支回滚指令。
理解这条链路之后,看四种模式的差异就简单了:差异集中在 RM 在接收到 TC 的提交/回滚指令后,用什么手段完成分支的最终生效或逆向恢复。
3.3 接入 Seata 至少要有的基础设施
生产环境用 Seata,不是引入一个 Maven 依赖就能完事的。除了业务服务本身的依赖,你还需要:
- 一个高可用的
seata-server集群。官方默认支持 file、db、raft 三种存储模式,生产建议用 db 模式或 raft 模式,否则 TC 宕机重启后可能丢失全局事务状态。 - 合适的注册中心配置。Seata 支持 Nacos、Consul、Eureka、Zookeeper 等,国内用 Nacos 的团队最多。
- 数据源代理。Seata 需要通过代理 DataSource 来拦截 SQL,所以业务服务的数据源要包一层
DataSourceProxy。
这个架构层本来就值得单独开一篇,这里提一个经常被忽略的点:TC 的存储模式不能随便用 file 上生产。我之前见过一个团队,开发环境用 file 一直没问题,上生产后 TC 一台机器重启,结果所有执行到一半的全局事务全部丢失,底单和库存对不上账。后来老老实实切了 db 模式,并给 TC 做了主备,才把这个问题压下去。
4. AT 模式:无侵入补偿背后的全局锁与 undo_log 代价
4.1 AT 模式的阶段拆解
AT 是 Seata 四种模式里业务侵入性最低、最容易上手的一种。它依然是一个"二阶段提交"模型,但没有让数据库锁至少等到全局提交。
AT 模式第一阶段做的事:
- RM 解析业务 SQL,比如
update inventory set stock = stock - 1 where sku_id = 10。 - 在执行前先查一次数据,记录前镜像。
- 执行业务 SQL。
- 执行后再查一次数据,记录后镜像。
- 将前镜像、后镜像、分支事务 ID 等信息写进一张
undo_log表。 - 分支本地事务提交,释放数据库本身的行锁。
第二阶段如果全局事务成功,TC 通知各 RM 异步删除 undo_log 记录即可。如果全局事务失败,RM 会拿 undo_log 里记录的前后镜像做比对,确认数据没有被其他事务修改过,然后生成反向 SQL 把数据还原回前镜像状态。
注意,AT 模式里这个"失败回滚"不是靠数据库自动做,而是靠 Seata 自己生成的补偿 SQL。比如原来是 update,反向 SQL 就是把所有被改字段改回原值;原来是 insert,反向 SQL 就是按主键删除;原来是 delete,反向 SQL 就是按主键重新插入。
4.2 undo_log 长什么样
每个参与 AT 模式分支事务的数据库,都必须额外建一张 undo_log 表。大致 DDL 如下:
sql复制CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这张表里最关键的是 rollback_info,它存的就是前镜像和后镜像序列化后的数据。每次完成全局回滚后,这张表里对应的记录会被清理。如果你们生产环境发现这张表一直在增长,基本可以断定是分支回滚阶段出了问题,需要查是不是存在连接池超时、TC 不可用、或者某些 SQL 生成了错误的回滚逻辑。
4.3 全局锁:AT 的隐形瓶颈
AT 模式为了在本地事务提前提交的情况下依然避免脏写,引入了全局锁机制。简单说,A 事务先更新了某一行,并且这一步是在全局事务里做的,那么 TC 会记录这行数据被全局事务 XID 锁住。其他全局事务再想更新同一行时,必须先向 TC 申请到这行数据的全局锁,否则只能重试等待。
这个设计让 AT 模式在"不同全局事务写同一行"的场景下变得非常脆弱。秒杀场景里,成千上万个请求同时更新同一个库存行,如果都套 @GlobalTransactional,大量请求会阻塞在全局锁等待上,表现就是接口 RT 骤增,数据库连接池被打满。所以 AT 模式适合并发冲突不严重、事务链路短、回滚频率低的场景,不适合高并发热点行更新。
还有一点容易被忽视:AT 模式在隔离性上并不完美。由于分支事务在全局提交前就已经本地提交了,如果全局事务最终回滚,期间其他事务可能已经读到了这些"中间状态"。官方建议业务侧对一致性很敏感的读,配合 @GlobalLock 或 SELECT ... FOR UPDATE 来读。如果你的业务不能容忍这种中间态,AT 模式可能不是首选。
4.4 AT 的好与不好
好处不多说了,业务无侵入、上手快,很多老系统改造时切 AT 的成本最低。需要注意的限制是:
- 数据库必须支持本地事务,MySQL 推荐 InnoDB。
- 被代理的表必须有主键,因为生成的回滚 SQL 要靠主键定位数据。
- 对 SQL 的解析有限制,像
update ... limit、多表关联更新这类 SQL,AT 模式未必能正确生成还原镜像。 - 事务执行期间,数据库连接会一直被业务占用,所以要额外关注连接池配置。
我在一个清结算后台项目里用过 AT,场景是对账文件和订单表之间的状态同步,并发量一天就几千笔,写入冲突很小,AT 用得非常稳。但如果让我把这套方案搬到前台秒杀链路,我大概率会第一时间否决。
5. TCC 模式:用 Try/Confirm/Cancel 换掉数据库锁
5.1 TCC 的本质是"业务手写补偿"
TCC 是 Try、Confirm、Cancel 三个英文单词的缩写。它不是一个具体的自动框架机制,而是一种业务拆分思想。Seata 只是提供了 TCC 模式的协调和调用框架,真正的资源预留、确认扣减、反向释放,都得业务开发自己写。
相比 AT 模式那种"代理 SQL + 镜像回滚"的做法,TCC 最大的变化是:不再依赖数据库的行锁和 undo_log,而是把资源分成准备态和确定性状态,让事务过程可编排。
拿库存场景举例。假设表里有两个字段:总库存 total_qty 和冻结库存 frozen_qty:
- Try 阶段:检查
total_qty - frozen_qty >= n,然后执行frozen_qty += n,这一步表示预先锁定 n 件库存。 - Confirm 阶段:把冻结库存真正扣掉,执行
total_qty -= n; frozen_qty -= n。 - Cancel 阶段:释放之前冻结的库存,执行
frozen_qty -= n。
整个过程里,Try 阶段只加了冻结数,不影响别人正常下单能看到的可售库存,业务上比"直接扣减到底"更容易处理中间态。
5.2 Seata 里 TCC 接口是怎么写的
Seata 对 TCC 模式的封装比较轻,需要你定义一个接口并在实现里写清楚提交和回滚方法。伪代码结构大概是这样:
java复制@LocalTCC
public interface InventoryTccAction {
@TwoPhaseBusinessAction(
name = "deductInventory",
commitMethod = "confirm",
rollbackMethod = "cancel"
)
boolean tryDeduct(
BusinessActionContext context,
@BusinessActionContextParameter(paramName = "skuId") Long skuId,
@BusinessActionContextParameter(paramName = "count") Integer count
);
boolean confirm(BusinessActionContext context);
boolean cancel(BusinessActionContext context);
}
关键在于:confirm 和 cancel 的入参通常从 BusinessActionContext 里拿,不能单纯依赖方法入参。因为 Seata 在全局提交/回滚时调用的是同一个接口,如果入参不一致,会导致业务方法拿不到当时 Try 阶段的参数。
5.3 TCC 绕不开的三个副作用:空回滚、悬挂、重复执行
TCC 比 AT 更考验设计能力,因为网络不可靠带来的问题全暴露了。实际项目里至少要处理这三种情况:
空回滚:TM 在调用 Try 之前的网络请求超时了,TC 判定这个全局事务失败,直接调用了 Cancel。可 Cancel 执行时,Try 可能根本没到或者响应还没回来。这时候如果 Cancel 里做"扣冻结数"操作,把本来就不存在的冻结数扣成负数,就出问题了。解决办法通常是用一张事务控制表,记录每个事务 XID 的 Try/Confirm/Cancel 执行状态。在 Cancel 执行前先查,如果没查到 Try 记录,要标记为空回滚,允许状态落地但不做业务扣减。
悬挂:Cancel 已经先于 Try 执行完了,可 Try 的请求因为网络延迟,之后才到达。这时 Try 如果再执行资源预留,系统里就会出现一条"已经回滚但又被执行了一段 Try"的数据。所以要设计防悬挂:在 Try 里先检查事务状态是否已经被 Cancel 过,如果是,直接拒绝执行。
重复执行:Confirm/Cancel 的指令可能因为 TC 重试而出现多次。所以 Confirm 和 Cancel 方法本身必须幂等,通常靠唯一事务 ID + 业务流水表来做去重。
这几个坑我没法用三言两语讲完,但想提醒一点:TCC 的成本不只是多写两个方法,而是整个资源模型都要围绕 try/confirm/cancel 去设计。 如果你们的库存模型只有"扣减"这一个动作,没有冻结态,TCC 根本无从下手。
5.4 TCC 的适用边界
TCC 换来的好处是:数据库行锁只在 Try/Confirm/Cancel 各自的一瞬间被持有,事务不需要像 XA 那样持有锁到全局提交。而且业务模型本身的健壮性提高了,Confirm/Cancel 逻辑是显示化的,执行结果可观测、可补偿。
代价是人力成本高、和业务强耦合。一个简单的扣库存,你至少要多维护两个方法,还要额外加事务控制表处理空回滚、悬挂、幂等。团队里如果没有能把事务状态梳理清楚的人,TCC 很容易变成"三个方法各写各的,互相之间对不上账"的灾难。适合 TCC 场景的是:高并发、热点数据激烈竞争、并且业务愿意为一致性付出开发成本的核心交易链路。
6. Saga 模式:长流程编排拿"最终一致"换掉长锁
6.1 Saga 的思路:每个分支都有反操作
Saga 最早来自 1987 年一篇论文,思路是:把一个长事务拆成一系列本地短事务,并为每个短事务配置一个补偿事务。正常情况顺着链路往下走,一旦某个环节失败,就回溯执行之前所有已成功环节的补偿操作。
对比 TCC,Saga 少了一个 Try 阶段。也就是说,每个参与服务直接把自己该做的事做掉,提交本地事务;如果后面发现整个链路要回滚,再挨个调用补偿逻辑。由于没有 Try 的预留动作,Saga 的一致性强度其实是更弱的,它只在最终状态上保证"要么全部提交,要么全部被补偿回来",中间态对别的服务是可见的。
6.2 编排式 Saga 和 choreography 式 Saga
Saga 的落地有两大风格。
一种是 choreography(协同式),各服务之间通过事件解耦。订单服务创建订单后发布 OrderCreated 事件,库存服务订阅事件扣库存,扣完发 StockDeducted 事件,
