分布式事务:Seata AT/TCC/SAGA 的取舍与踩坑
关键词标签:Seata、分布式事务、AT 模式、TCC、Saga、全局锁、回滚补偿
一个容易被忽略的事实:Seata 的 AT 模式之所以能"无侵入"地回滚业务 SQL,靠的不是数据库事务,而是在业务库中额外维护一张
undo_log表记录前后镜像,再加一把由 TC 维护的全局锁。这意味着——如果你的业务表没有主键、SQL 形态超出 Seata 解析器覆盖范围、或者把数据写进了information_schema这类系统表,AT 模式很可能在第一阶段就无法正确生成回滚信息。这不是 bug,是 AT 的设计边界。
本系列前面讲过线程池、AQS、CompletableFuture 这些"单机并发"的底座,也讲过 Spring Boot 自动配置的加载链路。这一篇我们把视角从单机拉到分布式,专门聊 Seata 三种模式(AT / TCC / SAGA)的取舍逻辑和边界条件。下文中的order-service、storage-service仅为讲解示例(非真实系统)。
一、先想清楚:分布式事务到底在解决什么
分布式事务的本质矛盾只有一个:多个独立资源(DB、MQ、RPC 服务)的本地事务无法用一个 XA 事务串起来,或者串起来代价太高。
Seata 的定位不是替代 XA,而是提供一套"最终一致 + 自动补偿"的框架。它把一次全局事务拆成:
- TC (Transaction Coordinator):独立部署的协调者,维护全局事务和分支事务状态;
- TM (Transaction Manager):发起全局事务的一方,通常是业务入口;
- RM (Resource Manager):管理分支事务的资源,通常是每个微服务里的数据源代理。
核心流程是经典的两阶段:第一阶段各分支各自提交本地事务并注册分支;第二阶段 TC 根据全局状态决定提交或回滚。三种模式的差别,全在"第一阶段做了什么"和"第二阶段怎么补偿"。
二、AT 模式:无侵入的代价是全局锁
2.1 它到底做了什么
AT(Auto Transaction)的核心机制可以用一句话概括:拦截业务 SQL,解析出 before image 和 after image,写入undo_log,然后提交本地事务。
关键点是:第一阶段本地事务就已经提交了。这就是为什么 AT 通常比 XA 性能更好——它没有把数据库连接一直挂到第二阶段。
那么回滚怎么办?靠undo_log里的前后镜像反向生成补偿 SQL。下面是 Seata 官方脚本mysql.sql中的undo_log表结构:
CREATE TABLE `undo_log` ( `branch_id` BIGINT NOT NULL COMMENT 'branch transaction id', `xid` VARCHAR(128) NOT NULL COMMENT 'global transaction id', `context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization', `rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info', `log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status', `log_created` DATETIME(6) NOT NULL COMMENT 'create datetime', `log_modified` DATETIME(6) NOT NULL COMMENT 'modify datetime', UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) ) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4;回滚时,Seata 会校验当前数据的 after image 是否仍等于undo_log里记录的 after image。如果不等,说明数据在全局事务提交后被别的事务改过,此时会抛出SQLUndoFailedException(其内部封装了RollbackRetryTimeoutException等场景),由 TC 决定重试或进入人工处理。这个校验逻辑是为了防止"脏回滚"覆盖别人的更新。
2.2 全局锁:AT 真正的命门
第一阶段提交本地事务前,Seata 会向 TC 申请全局锁(TC 侧维护lock_table)。申请成功才提交,否则重试。这是 AT 保证隔离性的核心。
// 示意:AT 模式下一个典型的业务方法(order-service 假设示例) @GlobalTransactional(name = "create-order", rollbackFor = Exception.class) public void createOrder(OrderDTO dto) { // 分支 1:本地订单库 orderMapper.insert(buildOrder(dto)); // 分支 2:远程扣库存(storage-service 也是 Seata RM) storageClient.deduct(dto.getSkuId(), dto.getCount()); // 分支 3:远程扣余额 accountClient.debit(dto.getUserId(), dto.getAmount()); }这段代码看起来和普通@Transactional没区别,但@GlobalTransactional会通过GlobalTransactionalInterceptor开启全局事务,把 XID 通过 RPC 上下文透传到下游。
2.3 常见误区
- 误区一:AT 能保证强一致。不能。它保证的是最终一致,且默认隔离级别是读未提交(因为第一阶段就提交了)。如果你在全局事务未结束时读到了中间态数据,是正常的。
- 误区二:AT 支持所有 SQL。不支持。多表关联更新、
INSERT ... SELECT、部分复杂子查询、DDL 语句,Seata 的 SQL 解析器可能解析失败。解析失败时,该分支不会生成undo_log,回滚就无从谈起。 - 误区三:全局锁不占数据库锁。全局锁是 TC 内存 +
lock_table表维护的,和数据库行锁是两码事。但它会造成跨服务的串行等待——两个全局事务改同一行数据,后到的那个会一直重试直到超时。
三、TCC 模式:把补偿逻辑交给业务
3.1 三段式
TCC(Try-Confirm-Cancel)把每个分支拆成三个方法:
- Try:预留资源(冻结库存、预扣余额);
- Confirm:确认使用预留资源,必须幂等;
- Cancel:释放预留资源,必须幂等且能空回滚。
@LocalTCC public interface StorageTccAction { @TwoPhaseBusinessAction(name = "storageTcc", commitMethod = "confirm", rollbackMethod = "cancel") boolean tryDeduct(BusinessActionContext ctx, @BusinessActionContextParameter(paramName = "skuId") Long skuId, @BusinessActionContextParameter(paramName = "count") Integer count); boolean confirm(BusinessActionContext ctx); boolean cancel(BusinessActionContext ctx); }@LocalTCC表示这是本地 TCC,BusinessActionContext会携带 XID 和分支 ID,用于幂等判断。
3.2 TCC 的三个经典坑
坑一:空回滚。Try 还没执行(比如网络超时,TC 以为失败了),Cancel 先到了。此时不能报错,要识别"没有对应的 Try 记录"直接返回成功。做法通常是 Cancel 时先查预留记录,查不到就返回 true。
坑二:幂等。网络抖动导致 Confirm/Cancel 被重复调用。必须靠唯一键(XID + branchId)去重。
坑三:悬挂。Cancel 比 Try 先到,Cancel 空回滚返回成功;随后 Try 才到达并真的扣了库存。此时全局事务已结束,库存永远扣着。解决办法是Try 执行前先检查是否已有对应的 Cancel 记录,有则拒绝执行。
这三个坑不是理论,是 TCC 落地的必备防御。任何声称"TCC 很简单"的说法,都忽略了这三个状态机。
四、SAGA 模式:长事务的最终答案
SAGA 的思路和 TCC 不同:它没有 Try,每个正向操作直接提交,失败时按逆序执行补偿操作。
Seata 的 SAGA 基于状态机(StateMachine),用 JSON 定义流程:
{ "Name": "orderSaga", "StartState": "CreateOrder", "States": { "CreateOrder": { "Type": "ServiceTask", "ServiceName": "orderService", "ServiceMethod": "create", "CompensateState": "CancelOrder", "Next": "DeductStorage" }, "DeductStorage": { "Type": "ServiceTask", "ServiceName": "storageService", "ServiceMethod": "deduct", "CompensateState": "RestoreStorage", "Next": "Succeed" }, "CancelOrder": { "Type": "CompensateState" }, "RestoreStorage": { "Type": "CompensateState" }, "Succeed": { "Type": "Succeed" } } }SAGA 适合长流程、跨多个服务、无法长时间持有资源的场景,比如订单履约、跨系统对账。代价是:没有隔离性,中间状态对外可见,补偿逻辑必须由业务自己保证正确。
五、三种模式怎么选:一张决策表
| 维度 | AT | TCC | SAGA |
|---|---|---|---|
| 侵入性 | 低(加注解) | 高(写三个方法) | 中(写状态机 + 补偿) |
| 隔离性 | 读未提交(全局锁) | 由 Try 的预留程度决定 | 无 |
| 性能 | 中(全局锁有竞争) | 高 | 高 |
| 适用事务长度 | 短 | 短 | 长 |
| 补偿正确性 | 框架自动 | 业务保证 | 业务保证 |
| 典型场景 | 常规 CRUD 跨服务 | 资金、库存等强约束 | 履约、审批流 |
一句话取舍:能用 AT 就用 AT,AT 覆盖不了(复杂 SQL、强隔离、非事务资源)再上 TCC,流程长且补偿天然可逆就用 SAGA。
六、什么时候别用 / 别踩的坑
- 别用 AT 处理资金。读未提交的隔离性 + 全局锁重试,在资金场景下风险不可控。资金请用 TCC 或本地消息表。
- 别在 AT 事务里做 RPC 之外的副作用。发 MQ、调第三方支付、写 Redis,这些不在
undo_log覆盖范围内,回滚不会撤销它们。 - 别忽略
undo_log的清理。如果 TC 长时间不可用,undo_log会堆积,需要配置log_status和定时清理策略,否则表会膨胀。 - 别把全局锁当成数据库锁。它跨服务串行,热点数据(比如秒杀库存)在 AT 下会变成全局串行瓶颈。
- TCC 的 Cancel 必须能空回滚、必须幂等、必须防悬挂,这三条缺一不可,否则线上一定出数据不一致。
- SAGA 的补偿不是回滚,是"反向业务操作"。如果正向操作不可逆(比如已发短信、已扣积分),SAGA 就无能为力。
官方文档值得反复读的两处:Seata 官方文档、Apache Seata GitHub 仓库。源码里io.seata.rm.datasource.undo包下的AbstractUndoExecutor及其子类是理解 AT 回滚的最佳入口。
系列预告:单机并发和分布式事务都聊完了,下一篇我们转向分布式锁的三种实现(Redis / ZooKeeper / 数据库)在源码层面的取舍,重点拆 Redisson 看门狗续期和 RedLock 的争议。感兴趣可以关注,我们下篇见。