分布式事务解决方案对比——Seata、TCC、SAGA 选型的完整决策树
分布式事务解决方案对比——Seata、TCC、SAGA 选型的完整决策树
适读人群:有微服务实战经验的 Java 后端开发者 | 阅读时长:约18分钟 | 核心价值:建立分布式事务选型决策框架,避免踩坑
从一次线上资损事故说起
去年底,我的老朋友阿杰在某电商公司负责核心交易链路改造,把单体系统拆成了微服务。拆完之后他非常得意地告诉我:"老张,我们用 Spring Cloud 搞定了,性能提升三倍,代码清爽多了。"
我问他:"分布式事务怎么处理的?"
他愣了一下:"嗯……每个服务自己管自己的事务,应该没问题吧?"
三个月后,他给我发来一条消息:"老张,我们出事了。用户下单扣款成功,但库存扣减失败,订单状态是已支付,但库存没变化。线上跑了两周才发现,已经有几十单数据不一致了。"
这就是分布式事务最典型的问题:在单机数据库里,BEGIN / COMMIT / ROLLBACK 帮你搞定一切。但当一笔业务操作横跨多个微服务,各自操作自己的数据库,任何一个环节失败都可能导致数据不一致。
阿杰的问题不是代码写得差,而是根本没有分布式事务方案。
我花了一个周末帮他梳理了选型思路,结合我们这两年在不同项目里踩过的坑,今天把这套完整的决策树分享出来。
先搞清楚你面对的是什么问题
很多人一上来就问:"我用 Seata 还是 TCC?"这个问题本身就问错了——不同的业务场景需要不同的方案,没有银弹。
在选型之前,先回答三个问题:
1. 你的业务容许数据短暂不一致吗?
如果是资金类业务(转账、支付),答案一般是否。如果是库存预占类业务,短暂不一致可能可以接受。
2. 你对性能的要求是什么级别?
强一致性方案(XA、Seata AT)会带来锁竞争,TPS 通常在几百到几千级别。最终一致性方案(SAGA、消息队列)性能更好,但实现复杂度高。
3. 你的团队愿意为分布式事务改多少业务代码?
Seata AT 模式对业务代码无侵入,而 TCC 需要你为每个操作实现 try/confirm/cancel 三个接口。
四大主流方案深度解析
方案一:Seata AT 模式(自动补偿)
Seata AT 是目前国内用得最广的方案,阿里巴巴开源,原理是对 SQL 操作的自动代理。
核心机制:
Seata AT 在执行 SQL 前后各打一个快照(before image / after image),正常提交时释放本地锁,回滚时用 after image 做反向补偿。全局事务由 TC(Transaction Coordinator)协调,各服务的 TM(Transaction Manager)参与。
@GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class)
public void createOrder(OrderRequest request) {
// 第一步:扣减库存(调用库存服务)
inventoryService.deductStock(request.getSkuId(), request.getQuantity());
// 第二步:创建订单(本地数据库操作)
Order order = new Order();
order.setUserId(request.getUserId());
order.setSkuId(request.getSkuId());
order.setAmount(request.getAmount());
order.setStatus(OrderStatus.CREATED);
orderMapper.insert(order);
// 第三步:扣减账户余额(调用账户服务)
accountService.deductBalance(request.getUserId(), request.getAmount());
}看起来和本地事务一样简单,只需要加一个 @GlobalTransactional 注解。
但这背后有几个关键细节你必须知道:
- 每个参与服务必须引入 Seata 客户端,并配置 TC 地址
- 每个参与服务的数据库必须新增
undo_log表 - Seata AT 使用的是"读已提交"级别的隔离,全局锁期间会阻塞其他事务
实际测试中,在 500 并发下,Seata AT 的 TPS 大约在 800-1200 之间(取决于业务复杂度),而无事务保护的直接调用 TPS 可以达到 5000+。这个性能损耗在高并发场景下是显著的。
方案二:TCC(Try-Confirm-Cancel)
TCC 是一种业务补偿型的分布式事务方案,不依赖数据库的事务机制,而是在业务层手动实现三个操作:
- Try:预占资源(冻结库存、冻结余额)
- Confirm:确认操作(真正扣减)
- Cancel:取消操作(释放预占)
以"下单扣余额"为例:
// 账户服务的 TCC 接口实现
@Component
public class AccountTccServiceImpl implements AccountTccService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper accountFreezeMapper;
/**
* Try 阶段:冻结用户余额
* 在 account_freeze 表插入冻结记录,account 表余额不变
*/
@Override
public void tryDeductBalance(BusinessActionContext context,
String userId, BigDecimal amount) {
// 幂等检查:同一个 xid 只处理一次
String xid = context.getXid();
AccountFreeze freeze = accountFreezeMapper.selectByXid(xid);
if (freeze != null) {
return; // 已经处理过,直接返回
}
Account account = accountMapper.selectByUserId(userId);
if (account.getBalance().compareTo(amount) < 0) {
throw new RuntimeException("余额不足");
}
// 冻结余额
AccountFreeze newFreeze = new AccountFreeze();
newFreeze.setXid(xid);
newFreeze.setUserId(userId);
newFreeze.setFreezeAmount(amount);
newFreeze.setState(FreezeState.TRY);
accountFreezeMapper.insert(newFreeze);
// 扣减可用余额(不是真正扣减,而是减去冻结部分)
accountMapper.deductBalance(userId, amount);
}
/**
* Confirm 阶段:确认扣款
* 删除冻结记录(余额已在 Try 阶段扣除)
*/
@Override
public void confirmDeductBalance(BusinessActionContext context,
String userId, BigDecimal amount) {
String xid = context.getXid();
AccountFreeze freeze = accountFreezeMapper.selectByXid(xid);
if (freeze == null || freeze.getState() == FreezeState.CANCEL) {
return; // 幂等处理
}
accountFreezeMapper.updateState(xid, FreezeState.CONFIRM);
}
/**
* Cancel 阶段:回滚,释放冻结余额
* 注意:必须处理"空回滚"问题
*/
@Override
public void cancelDeductBalance(BusinessActionContext context,
String userId, BigDecimal amount) {
String xid = context.getXid();
AccountFreeze freeze = accountFreezeMapper.selectByXid(xid);
// 空回滚处理:Try 根本没执行就来了 Cancel
if (freeze == null) {
AccountFreeze emptyFreeze = new AccountFreeze();
emptyFreeze.setXid(xid);
emptyFreeze.setState(FreezeState.CANCEL);
accountFreezeMapper.insert(emptyFreeze);
return;
}
if (freeze.getState() == FreezeState.CANCEL) {
return; // 幂等:已经 cancel 过了
}
// 恢复余额
accountMapper.addBalance(userId, amount);
accountFreezeMapper.updateState(xid, FreezeState.CANCEL);
}
}TCC 的代码量是 Seata AT 的 3-5 倍,但它有一个巨大优势:不依赖 undo_log,没有全局锁,性能接近本地事务。在我们做的压测中,TCC 方案在同等配置下 TPS 比 Seata AT 高 40%-60%。
方案三:SAGA 模式
SAGA 适合长事务场景,特别是跨多个服务、执行时间较长的业务流程(比如旅行预订:预订机票→预订酒店→租车→确认)。
SAGA 将长事务拆成多个本地事务,每个本地事务都有对应的补偿事务。正向执行失败时,按逆序执行补偿。
SAGA 有两种编排方式:
- 编排(Choreography):各服务通过事件驱动,服务间无直接调用
- 协调(Orchestration):由专门的 SAGA 协调器负责调度
Seata 提供了 SAGA 状态机的实现,用 JSON 定义状态转换:
{
"Name": "create_order_saga",
"Comment": "创建订单 SAGA",
"StartState": "DeductStock",
"States": {
"DeductStock": {
"Type": "ServiceTask",
"ServiceName": "inventoryService",
"ServiceMethod": "deductStock",
"CompensateState": "CompensateDeductStock",
"Next": "DeductBalance"
},
"DeductBalance": {
"Type": "ServiceTask",
"ServiceName": "accountService",
"ServiceMethod": "deductBalance",
"CompensateState": "CompensateDeductBalance",
"Next": "CreateOrder"
},
"CreateOrder": {
"Type": "ServiceTask",
"ServiceName": "orderService",
"ServiceMethod": "createOrder",
"IsEnd": true
},
"CompensateDeductStock": {
"Type": "ServiceTask",
"ServiceName": "inventoryService",
"ServiceMethod": "addStock",
"IsEnd": true
},
"CompensateDeductBalance": {
"Type": "ServiceTask",
"ServiceName": "accountService",
"ServiceMethod": "addBalance",
"IsEnd": true
}
}
}SAGA 的隔离性是最弱的——在整个 SAGA 执行期间,中间状态对外可见。所以不适合对隔离性要求高的金融类业务。
方案四:基于消息队列的最终一致性
这其实不是严格意义上的分布式事务,而是通过事务消息来保证最终一致性。RocketMQ 的事务消息是这类方案的代表实现。
核心思路:
- 发送半消息到 MQ(消费者不可见)
- 执行本地事务
- 本地事务成功则提交消息,失败则回滚消息
- 消费者收到消息后执行下游操作,失败则重试
这个方案最大的优势是彻底解耦,服务间不需要同步调用,性能最好。但业务需要能接受最终一致性,且需要消费者做好幂等。
三大高频踩坑实录
坑一:Seata AT 的"脏读"问题
现象: 在高并发场景下,发现有的查询读到了"应该被回滚"的数据,导致业务逻辑出错。
原因: Seata AT 的全局事务在 Try 阶段提交了本地事务,其他事务可以读到这个中间状态。Seata 默认是"读未提交"全局隔离级别,如果不加全局锁,就会出现脏读。
解法: 在 Select 语句上加 FOR UPDATE,Seata 会自动识别并加全局锁:
// 在 mapper 中的查询加 for update
@Select("SELECT * FROM account WHERE user_id = #{userId} FOR UPDATE")
Account selectForUpdate(@Param("userId") String userId);或者在代码层面使用 @GlobalLock 注解强制全局锁查询。
但要注意,加全局锁后性能会进一步下降。在我们的压测中,加全局锁后 TPS 从 1200 降到了 600 左右。
坑二:TCC 的"悬挂"问题
现象: 偶发性出现已经 Cancel 的事务,又重新执行了 Try,导致资源被永久冻结。
原因: 这是 TCC 的经典问题,叫做"悬挂"。当网络拥堵时,Try 请求还没到达,Cancel 请求先到了(因为超时触发了回滚)。Cancel 执行完后,Try 请求才到,这时候 Try 正常执行,但全局事务已经结束,这个 Try 产生的冻结记录永远不会被 Confirm 或 Cancel,资源就悬挂了。
解法: 在 Cancel 阶段插入一条空记录,Try 阶段检查这条记录,如果发现已经有 CANCEL 状态的记录,则直接放弃 Try 操作:
// Cancel 阶段插入标记
if (freeze == null) {
// 插入一条 CANCEL 状态的记录,防止后续 Try 执行
AccountFreeze emptyFreeze = new AccountFreeze();
emptyFreeze.setXid(xid);
emptyFreeze.setUserId(userId);
emptyFreeze.setFreezeAmount(BigDecimal.ZERO);
emptyFreeze.setState(FreezeState.CANCEL);
accountFreezeMapper.insert(emptyFreeze);
return;
}
// Try 阶段检查
@Override
public void tryDeductBalance(BusinessActionContext context, ...) {
String xid = context.getXid();
AccountFreeze freeze = accountFreezeMapper.selectByXid(xid);
// 如果发现已经是 CANCEL 状态,说明发生了悬挂,直接报错
if (freeze != null && freeze.getState() == FreezeState.CANCEL) {
throw new RuntimeException("事务已取消,拒绝 Try 操作");
}
// ...正常 Try 逻辑
}坑三:SAGA 补偿失败后没有告警
现象: 业务流程失败了,走了补偿,但补偿也失败了(网络超时、服务宕机),系统没有任何告警,DBA 半个月后对账才发现数据不一致。
原因: SAGA 依赖补偿操作的成功,一旦补偿失败,就需要人工介入。如果没有配置补偿失败的告警和重试机制,问题会被掩盖。
解法:
- 补偿操作必须实现重试(指数退避),Seata SAGA 状态机支持配置重试次数
- 配置补偿失败的死信队列,把失败的事务写入单独的表
- 配置监控告警:补偿失败即触发 P0 告警
// 在状态机 JSON 中配置重试
"CompensateDeductStock": {
"Type": "ServiceTask",
"ServiceName": "inventoryService",
"ServiceMethod": "addStock",
"Retry": [
{
"Exceptions": ["java.lang.Exception"],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2.0
}
],
"IsEnd": true
}选型决策树
根据以上分析,我整理了一份选型决策树:
业务是否需要强一致性?
├── 是(资金、核心数据)
│ ├── 业务量 < 1000 TPS?
│ │ ├── 是 → Seata AT(开发成本低,性能够用)
│ │ └── 否 → TCC(高性能,但开发成本高)
│ └── 涉及外部系统(第三方支付等)?
│ └── 是 → TCC 或 消息最终一致性(外部系统无法参与 XA)
└── 否(可接受最终一致性)
├── 业务流程长(≥5步)、执行时间长?
│ └── 是 → SAGA(Seata 状态机)
└── 业务相对简单,需要彻底解耦?
└── 是 → 消息队列最终一致性(RocketMQ 事务消息)我的经验总结:
- 80% 的业务场景,Seata AT 足够,别过度设计
- 秒杀、高并发支付场景,必须上 TCC
- 跨公司、跨外部系统的长流程,用消息队列
- SAGA 适合业务流程复杂但对隔离性要求不高的场景(旅游、保险等)
各方案性能横向对比
在相同硬件(4核8G * 3节点)、相同业务复杂度(3个服务参与)、500并发下:
| 方案 | TPS | 平均延迟 | P99延迟 | 开发成本 |
|---|---|---|---|---|
| 无事务保护 | 5200 | 12ms | 45ms | 低 |
| Seata AT | 1100 | 58ms | 180ms | 低 |
| Seata AT + 全局锁 | 620 | 95ms | 320ms | 低 |
| TCC | 1800 | 35ms | 120ms | 高 |
| SAGA | 2400 | 28ms | 90ms | 中 |
| 消息最终一致性 | 4100 | 15ms | 55ms | 中 |
注:以上数据来自内部压测,仅供参考,实际情况因业务而异。
我对阿杰的建议
回到文章开头的故事,阿杰的交易系统核心链路是:扣库存 → 创建订单 → 扣余额,属于强一致性要求的金融类业务,峰值 TPS 在 300 左右。
我给他的建议是:Seata AT 模式 + 关键查询加全局锁。理由是:
- 300 TPS 对 Seata AT 来说绰绰有余
- 对业务代码改动最小,最快速修复线上问题
- 团队没有 TCC 经验,贸然上 TCC 引入更多风险
三周后,他告诉我改造上线了,数据一致性问题解决,没有新的资损。
分布式事务没有最好的方案,只有最合适的方案。根据你的业务 TPS、团队能力、一致性要求三个维度来做决策,比看任何测评文章都可靠。
