Skip to content

分布式事务:XA 协议 vs TCC vs Seata AT 模式

问题

微服务架构下,一个业务操作往往涉及多个数据库(订单库、库存库、账户库),如何保证跨库的数据一致性?XA、TCC、Seata AT 三种主流方案各有什么优缺点,生产上怎么选?

为什么普通事务在微服务下失效了

单体应用里,一个 @Transactional 就能搞定跨表一致性——数据库自己管理 ACID。但微服务拆分后,每个服务有独立的数据库,@Transactional 管不到别的服务。这就是分布式事务要解决的问题。

典型场景:下单扣库存。订单库写入一条记录,库存库扣减一个 SKU,账户库扣款。如果库存扣了但订单创建失败,数据就脏了。如果没有分布式事务,就只能靠人工对账补数据,8 年的后端经验告诉你,这种对账脚本写起来比分布式事务本身还麻烦。

三种方案逐层分析

1. XA 协议(两阶段提交 2PC)

XA 是数据库原生支持的分布式事务协议,由 X/Open 组织定义,MySQL 5.0+ 的 InnoDB 就支持。分两个阶段:

时序流程:

协调者(TM)               参与者1(MySQL)          参与者2(MySQL)
    |                         |                      |
    |--- xa prepare 'xid' --->|                      |
    |--- xa prepare 'xid' --->|                      |
    |                         |--- 锁定资源返回 ready |
    |                         |--- 锁定资源返回 ready |
    |<--- 全部 ready ----------|<---                  |
    |                         |                      |
    |--- xa commit 'xid' ---->|                      |
    |--- xa commit 'xid' ---->|                      |
    |                         |--- 释放锁,提交       |
    |                         |--- 释放锁,提交       |
    v                         v                      v

Prepare 阶段:协调者(TM)通知所有参与者(RM)准备提交,参与者锁定资源,返回 ready 或 abort。 Commit 阶段:如果所有参与者都返回 ready,协调者通知全部提交,否则全部回滚。

sql
-- 伪代码:XA 事务流程
begin;
-- 执行 SQL
update account set balance = balance - 100 where id = 1;
update order set status = 'PAID' where id = 1001;
-- 第一阶段:Prepare
xa prepare 'xid-001';
-- 第二阶段:Commit(如果所有参与者都 prepare 成功)
xa commit 'xid-001';

实际落地配置:MySQL 的 innodb_lock_wait_timeout 默认 50s,XA 长事务下很容易把连接池打满。生产上建议调到 10s 以内,配合事务超时中间件兜底。

优点:强一致性,数据库原生支持,代码侵入低。 缺点

  • 性能差:Prepare 阶段所有参与者锁定资源,直到 Commit 才释放。一个 XA 事务如果涉及 3 个库,锁持有时间 = 最慢的那个库的 SQL 执行时间 + 网络延迟。实测 3 库 100ms 延迟场景下,单个 XA 事务耗时 350ms+,是普通本地事务的 3-5 倍
  • 阻塞风险:协调者挂了,参与者一直持有锁,无法释放。MySQL 的 xa_recover 命令可以手工恢复,但需要 DBA 介入,线上故障恢复时间通常在 10 分钟以上
  • 不支持异构存储:Redis、MQ、MongoDB 无法参与 XA
  • 实际生产中已很少使用,除非是金融核心系统用了 Oracle 的 XA

2. TCC(Try-Confirm-Cancel)

TCC 是业务层面的两阶段提交,不是数据库级别的。2007 年由阿里中间件团队提出,目的是解决 XA 的性能问题。

核心思想:把"锁定资源"和"释放资源"拆成两个业务操作,Try 阶段只是预留,不长时间持有行锁。

Try:资源预留,比如冻结库存 10 件,冻结账户 100 元。 Confirm:真正扣减,Try 预留的资源在 Confirm 中完成。 Cancel:回滚预留,Try 预留的资源全部释放。

java
// TCC 接口示例
public interface AccountTccService {
    // Try:冻结金额
    @TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
    boolean try(@BusinessActionContextParameter(paramName = "userId") Long userId,
                @BusinessActionContextParameter(paramName = "amount") BigDecimal amount);
    
    // Confirm:确认扣减
    boolean confirm(BusinessActionContext context);
    
    // Cancel:取消冻结
    boolean cancel(BusinessActionContext context);
}

try 阶段的 SQL

sql
-- 冻结余额:从可用余额中划出,写入冻结字段
update account set frozen = frozen + 100, balance = balance - 100 where id = 1 and balance >= 100;
-- 注意 where balance >= 100 是防超卖,没有这个条件 Try 会失败

confirm 阶段的 SQL

sql
-- 确认扣减(释放冻结)
update account set frozen = frozen - 100 where id = 1 and frozen >= 100;

cancel 阶段的 SQL

sql
-- 回滚冻结:把冻结的金额还回去
update account set frozen = frozen - 100, balance = balance + 100 where id = 1 and frozen >= 100;

TCC 的三个隐藏坑(面试必问):

坑 1:空回滚。 Try 没执行成功(比如网络超时),但 Cancel 被调用了。Cancel 必须能处理"还没 Try 过"的情况——直接返回成功,不要去扣已经冻结的余额。

坑 2:幂等。 Confirm/Cancel 可能被重复调用(网络重试),必须保证执行多次和执行一次的结果一样。最简单的做法是用事务状态表做去重:update tcc_log set status = 'CONFIRMED' where tx_id = ? and status = 'TRY_SUCCESS',affected rows == 0 说明已经执行过了,直接返回成功。

坑 3:防悬挂。 Try 的超时时间比 Cancel 长,导致 Cancel 先到、Try 后到。Try 后到时发现已经 Cancel 了,不能继续执行,否则数据就乱了。解决办法:Try 入口先查一下事务状态,如果已经是 CANCELED 就跳过。

java
// 防悬挂检查
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
public boolean try(BusinessActionContext context, Long userId, BigDecimal amount) {
    // 先查事务状态表,防止悬挂
    TccLog log = tccLogMapper.selectByTxId(context.getTxId());
    if (log != null && "CANCELED".equals(log.getStatus())) {
        // 已经 Cancel 了,不能执行 Try
        return true;
    }
    // 正常执行 Try 逻辑
    return accountMapper.freezeBalance(userId, amount) > 0;
}

优点

  • 性能好:无锁、无阻塞,资源只在 Try 阶段短暂冻结。实测 1 万 TPS 的订单场景,TCC 的平均响应时间在 50ms 以内
  • 支持异构存储:Redis、MySQL、MQ 都可以参与
  • 适合高并发场景

缺点

  • 代码侵入性强:每个业务操作都要实现 try/confirm/cancel 三个接口
  • 开发成本高:幂等、空回滚、防悬挂等边界问题都要处理
  • 对业务设计能力要求高

3. Seata AT 模式(Auto Transaction)

Seata AT 是 Seata 框架的自动补偿模式,在业务无感知的情况下完成分布式事务。2019 年阿里开源,前身是蚂蚁金服的 XTS 和阿里巴巴的 Txf。

核心原理

第一阶段(业务 SQL 执行):

业务服务                      Seata RM (DataSource Proxy)
   |                                  |
   |-- execute SQL ------------------>|
   |                                  |-- 解析 SQL,生成 before_image
   |                                  |-- 执行 SQL
   |                                  |-- 生成 after_image
   |                                  |-- 写入 undo_log 表
   |                                  |-- 提交本地事务
   |<-- 返回结果 --------------------|
   |                                  |
   |-- 向 TC 注册分支事务 ------------>|
   v                                  v

第二阶段(全局提交/回滚):

  • 全局提交:异步删除 undo log,业务无感,毫秒级
  • 全局回滚:用 undo log 的 before_image 生成反向 SQL 恢复数据
sql
-- Seata AT 的 undo log 示例(Seata 自动生成,业务无感知)
-- 原 SQL:
update account set balance = balance - 100 where id = 1;

-- Seata 自动记录的 undo log:
-- before_image: { id: 1, balance: 500 }
-- after_image:  { id: 1, balance: 400 }
-- rollback_sql: update account set balance = 500 where id = 1

业务代码几乎无侵入

java
// 业务代码只需要一个 @GlobalTransactional 注解
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
public void createOrder(OrderDTO order) {
    // 1. 扣减库存(操作本地数据库)
    inventoryService.deductStock(order.getProductId(), order.getQuantity());
    // 2. 扣减余额(操作账户数据库)
    accountService.deductBalance(order.getUserId(), order.getAmount());
    // 3. 创建订单(操作订单数据库)
    orderService.createOrder(order);
}

Seata AT 的 Write Skew 问题(一个真实案例):

假设有两个活动:A 和 B,各自检查总余额是否 >= 200 元,然后各自扣 100 元。

时间线:
T1: 事务1 查询 balance = 300,余额够,开始扣减
T2: 事务2 查询 balance = 300,余额够,开始扣减
T3: 事务1 扣 100,balance = 200,提交
T4: 事务2 扣 100,balance = 100,提交
结果:余额从 300 变成 100,但两个事务各自检查时都认为够。Seata AT 的 undo log 只记录行级快照,解决不了这种写偏序场景。

解决办法:用 select for update 加锁,或者改用 TCC 的冻结模式。

优点

  • 业务代码几乎无侵入,只需加注解
  • 性能介于 XA 和 TCC 之间
  • 自动生成 undo log,开发效率高
  • Seata 社区活跃,文档完善

缺点

  • 依赖 TC(Transaction Coordinator)高可用,TC 挂了会导致分支事务长时间挂起。TC 推荐至少 3 节点集群,用 raft 做一致性
  • 高并发写入场景下 undo log 可能产生脏写
  • 大事务下 undo log 膨胀,一个 1000 行 update 的大事务,undo log 轻松突破 10MB,回滚时磁盘 IO 压力大

三种方案对比总结

维度XATCCSeata AT
一致性强一致最终一致最终一致
性能差(锁资源,3库100ms延迟下350ms+)好(无锁,万TPS下50ms内)中(undo log 开销,百毫秒级)
代码侵入高(需实现3接口+处理边界)极低(一个注解)
异构存储不支持支持有限支持
运维成本中(需维护TC集群)
适用场景金融强一致高并发业务通用微服务

面试追问指南

Q: Seata AT 和 TCC 怎么选? A: 看对业务代码的侵入容忍度。如果团队能接受每个接口写三份(try/confirm/cancel),而且业务场景有明确的资源预留逻辑(比如库存冻结),选 TCC 性能更好。如果团队追求快速上线、不想改业务代码,用 Seata AT,但要注意高并发下的 Write Skew。

Q: XA 真的完全不能用了吗? A: 不是。金融核心系统用 Oracle XA 的很多,但 MySQL XA 因为实现问题(比如 MySQL 5.7 的 XA 在崩溃恢复时可能丢数据)不建议在生产用。如果业务场景是 T+1 对账、非实时结算,用本地消息表就够了,不用上 XA。

Q: 分布式事务的 CAP 取舍? A: 选 TCC 或者 Seata AT 本质上是在 AP 和 CP 之间做了折中——先保证可用性(Try 阶段不锁资源),再通过补偿保证最终一致。如果业务要求强一致,就要接受 XA 的性能损失,或者考虑 Google Spanner 那种 TrueTime 方案。

生产建议:先问自己"真的需要分布式事务吗"

实际生产中,本地消息表 + 消息队列 的最终一致性方案才是使用最广泛的。理由很简单:

  • 架构简单,不需要引入额外的协调者
  • 性能好,MQ 异步削峰
  • 消息可重试、可回放,保证最终不丢
java
// 本地消息表 + MQ 的经典实现
@Transactional
public void createOrder(OrderDTO order) {
    // 1. 本地操作:创建订单 + 写入本地消息表
    orderService.createOrder(order);
    messageService.insertLocalMessage("order-payed", order.getId(), order);
    // 2. 事务提交后,定时任务或监听器将消息发送到 MQ
    // 3. 下游服务(库存、账户)消费消息,执行自己的逻辑
    // 4. 如果消费失败,消息重试;超过重试次数,写入死信队列人工处理
}

一个真实的生产决策: 假设团队就 5 个人,没有专职 DBA,服务部署在 3 台 4C8G 的机器上。这时候上 Seata AT 要搭 TC 集群,出问题排查成本高。不如直接用本地消息表 + RocketMQ,消息重试+死信队列兜底,一个月也就出 1-2 条不一致的数据,写个定时对账脚本半小时搞定。核心原则:不要为了 1% 的场景引入 100% 的复杂度

什么时候该用 Seata AT 或 TCC

  • 需要强一致性的极少数场景:支付扣款、库存扣减(不能多扣也不能少扣)
  • 业务对回滚复杂度要求高,本地消息表无法满足
  • 有足够的运维能力维护 Seata TC 集群

最佳实践是尽量避免跨库事务。通过合理的微服务拆分(比如将订单和账户放在同一个服务里)、数据冗余(冗余存储订单金额到订单表,避免每次都查账户表),很多分布式事务问题本来就是设计问题,不是技术问题。

参考

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。