分布式事务: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 vPrepare 阶段:协调者(TM)通知所有参与者(RM)准备提交,参与者锁定资源,返回 ready 或 abort。 Commit 阶段:如果所有参与者都返回 ready,协调者通知全部提交,否则全部回滚。
-- 伪代码: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 预留的资源全部释放。
// 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:
-- 冻结余额:从可用余额中划出,写入冻结字段
update account set frozen = frozen + 100, balance = balance - 100 where id = 1 and balance >= 100;
-- 注意 where balance >= 100 是防超卖,没有这个条件 Try 会失败confirm 阶段的 SQL:
-- 确认扣减(释放冻结)
update account set frozen = frozen - 100 where id = 1 and frozen >= 100;cancel 阶段的 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 就跳过。
// 防悬挂检查
@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 恢复数据
-- 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业务代码几乎无侵入:
// 业务代码只需要一个 @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 压力大
三种方案对比总结
| 维度 | XA | TCC | Seata 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 异步削峰
- 消息可重试、可回放,保证最终不丢
// 本地消息表 + 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 集群
最佳实践是尽量避免跨库事务。通过合理的微服务拆分(比如将订单和账户放在同一个服务里)、数据冗余(冗余存储订单金额到订单表,避免每次都查账户表),很多分布式事务问题本来就是设计问题,不是技术问题。
参考
- Seata 官方文档:https://seata.apache.org/
- 《分布式事务原理与实战》
- MySQL XA 事务文档:https://dev.mysql.com/doc/refman/8.0/en/xa.html
- 阿里中间件团队 TCC 论文:https://github.com/seata/seata/wiki/TCC-Design