分布式事务与 Seata 的 AT/XA 模式
分布式事务的难点从来不是「怎么提交」,而是「怎么回滚」和「怎么隔离」。这篇对比 XA、AT、TCC、SAGA 四种模式的原理与代价,重点拆解 Seata AT 模式的两阶段流程、undo_log 补偿机制和它无法回避的脏写风险。
一、先界定问题
一个业务操作要跨两个服务完成:订单服务创建订单,库存服务扣减库存。两个服务各自有独立的数据库。
单机时代,这件事用 @Transactional 一行注解就解决了——同一个数据库连接、同一个事务,要么全成功要么全回滚。拆成微服务之后,这条注解失效了:
订单服务: BEGIN → INSERT order → COMMIT ✅ 成功
库存服务: BEGIN → UPDATE stock → ??? ❌ 这里挂了怎么办?
订单已经提交了,库存没扣。数据不一致。
分布式事务的定义:一个业务操作需要多个服务协作完成,每个服务有自己的本地事务,这些本地事务必须同时成功或同时失败。整体叫全局事务,其中每个服务的事务叫分支事务。
这里有个关键认知要先建立:
分布式事务真正的难点不是「怎么让它们一起提交」,而是「已经提交的本地事务怎么撤销」。
本地事务一旦提交,数据库层面就没有退路了。所有分布式事务方案的本质,都是在回答同一个问题:提交之后反悔,靠什么把数据改回去? 四种主流模式的差异,全部源于对这个问题的不同回答。
二、Seata 的三个角色
Seata 把分布式事务拆成三个角色:
| 角色 | 全称 | 职责 |
|---|---|---|
| TC | Transaction Coordinator | 事务协调者,独立部署的服务端。维护全局事务状态,驱动分支的提交和回滚 |
| TM | Transaction Manager | 事务管理器,发起全局事务的一方。开启全局事务、提交或回滚全局事务 |
| RM | Resource Manager | 资源管理器,管理分支事务。向 TC 注册分支、上报状态、执行回滚 |
一次完整的执行流程:
TM(订单服务) TC(Seata Server) RM(库存服务)
│ │ │
│── 开启全局事务 ───────→ │ 生成全局唯一 XID │
│←───────── XID ────────│ │
│ │
│── 调用库存服务(带 XID) ────────────────────────→ │
│ │←── 注册分支事务 ───────│
│ │←── 上报分支状态 ───────│
│ │ │
│── 提交/回滚全局事务 ──→ │ │
│ │── 通知各分支提交/回滚 →│
XID 是整个链路的纽带。它通过 RPC 上下文在服务之间传递,让每个参与的数据库操作都能被追溯到同一个全局事务。
三、四种模式的核心差异
XA 模式:数据库原生两阶段提交
XA 是 X/Open 组织定义的分布式事务规范,由数据库厂商实现。它把提交拆成两个阶段:
阶段一(Prepare):各分支执行 SQL,但不提交,数据库把事务挂起并持有行锁。 阶段二(Commit/Rollback):TC 收集完所有分支的 Prepare 结果,如果全部成功就通知全部 Commit,任何一个失败就通知全部 Rollback。
阶段一: RM1: UPDATE stock ... (Prepare,持锁不提交)
RM2: INSERT order ... (Prepare,持锁不提交)
全部 Prepare 成功?
↓ 是
阶段二: TC → RM1: COMMIT → 真正落库,释放锁
TC → RM2: COMMIT → 真正落库,释放锁
优点:强一致性。数据要么全提交要么全回滚,不存在中间状态,本质上把单机事务的语义扩展到了多库。
缺点:锁持有时间太长。从阶段一 Prepare 到阶段二 Commit 之间,数据库行锁一直被占着。如果某个分支的 Prepare 慢,或者 TC 通知阶段二时有延迟,这条记录会被锁上几百毫秒甚至更久——其他事务全部阻塞等待。这个锁是数据库层面的真实行锁,不像应用层锁那么好优化。并发一高,锁等待会迅速演变成连接池耗尽。
AT 模式:Seata 自研的自动补偿
AT 模式是 Seata 最常用的模式,它的思路完全不同——不强求一起提交,而是各自先提交,失败时靠反向补偿回滚。
阶段一:执行业务 SQL,同时记录前后镜像到 undo_log 表,然后直接提交本地事务。
① 解析 SQL,得到表名、条件、类型
② 查询前镜像: SELECT * FROM stock WHERE id = 1 → available = 100
③ 执行业务 SQL: UPDATE stock SET available = 99 WHERE id = 1
④ 查询后镜像: SELECT * FROM stock WHERE id = 1 → available = 99
⑤ 把前后镜像写入 undo_log 表
⑥ 申请全局锁 → 提交本地事务(业务数据和 undo_log 一起提交)
阶段二 - 提交:异步删除 undo_log 记录,几乎不占时间。
阶段二 - 回滚:根据 undo_log 里的前镜像生成反向 SQL,把数据改回去。
-- undo_log 里存的 rollback_info(简化)
{
"beforeImage": { "tableName": "stock", "rows": [{ "id": 1, "available": 100 }] },
"afterImage": { "tableName": "stock", "rows": [{ "id": 1, "available": 99 }] },
"sqlType": "UPDATE"
}
-- 回滚时执行的反向 SQL
UPDATE stock SET available = 100 WHERE id = 1;
优点:本地事务一阶段就提交,数据库行锁立刻释放,并发性能远好于 XA。业务代码零侵入,加个 @GlobalTransactional 注解就行。
缺点:放弃了强一致性。一阶段提交后,数据在全局事务结束前是对其他事务可见的,默认隔离级别只相当于「读未提交」。这是 AT 模式最大的代价。
TCC 模式:Try-Confirm-Cancel
TCC 把业务逻辑拆成三个方法,由开发者自己实现:
| 阶段 | 方法 | 做什么 |
|---|---|---|
| 一阶段 | Try | 资源预留。冻结库存、预扣余额,而不是直接扣减 |
| 二阶段-成功 | Confirm | 确认使用预留资源。冻结转实扣 |
| 二阶段-失败 | Cancel | 释放预留资源。解冻、退款 |
以扣库存为例:
// Try:不真扣,只冻结
public boolean tryDeduct(Long skuId, int qty) {
// UPDATE stock SET frozen = frozen + qty
// WHERE sku_id = ? AND available - frozen >= qty
return stockMapper.freeze(skuId, qty) > 0;
}
// Confirm:冻结转实扣
public boolean confirmDeduct(Long skuId, int qty) {
// UPDATE stock SET available = available - qty, frozen = frozen - qty
return stockMapper.confirmFreeze(skuId, qty) > 0;
}
// Cancel:解冻
public boolean cancelDeduct(Long skuId, int qty) {
// UPDATE stock SET frozen = frozen - qty
return stockMapper.unfreeze(skuId, qty) > 0;
}
优点:不依赖数据库事务,隔离性最好。资源在 Try 阶段就被预留了,别人拿不走也不可见,不存在脏读问题。而且不局限于数据库——调用外部接口、发消息都能纳入事务。
缺点:开发成本最高,每个参与者都要写三个方法。而且 Try、Confirm、Cancel 三个方法都必须幂等(网络重试会导致重复调用),还要处理空回滚(Try 没执行,Cancel 却来了)和悬挂(Cancel 先于 Try 到达)。这三个问题每一个都是坑。
SAGA 模式:长事务的事件驱动
SAGA 把一个长事务拆成一串本地事务 T1、T2、…、Tn,每个都直接提交。失败时反向执行补偿操作 Cn、…、C1。
正常: T1 → T2 → T3 → T4 ✅
失败: T1 → T2 → T3 ✗
↓ 反向补偿
C2 ← C1 (T3 失败,T3 本身没提交,补偿 T2 和 T1)
优点:适合长流程、跨系统的业务,比如旅游订票(机票 + 酒店 + 租车),每一步可能耗时几分钟甚至几天。没有锁,吞吐最高。
缺点:完全没有隔离性,中间状态全程对外可见。补偿逻辑必须自己实现,而且补偿不一定能成功——如果补偿也失败,就需要人工介入。
四种模式横向对比
| 维度 | XA | AT | TCC | SAGA |
|---|---|---|---|---|
| 一致性 | 强一致 | 最终一致 | 最终一致 | 最终一致 |
| 隔离性 | 好(数据库行锁) | 差(读未提交) | 好(资源预留) | 无 |
| 一阶段锁 | 数据库行锁,持有到二阶段 | 本地行锁立即释放,全局锁持有到全局事务结束 | 无锁,靠资源预留 | 无锁 |
| 业务侵入 | 无 | 无(注解即可) | 高(三个方法) | 中(补偿逻辑) |
| 性能 | 低 | 中高 | 高 | 高 |
| 回滚方式 | 数据库回滚 | undo_log 反向补偿 | Cancel 接口 | 反向补偿事务 |
| 适用场景 | 短事务、强一致要求 | 大多数业务场景 | 高隔离要求、非数据库资源 | 长流程、跨系统 |
选型的基本判断:
- 业务能接受最终一致 → 优先 AT,开发成本最低
- 一阶段就要隔离资源(比如秒杀扣库存) → 用 TCC
- 流程跨越多个系统、耗时很长 → 用 SAGA
- 必须强一致且事务很短 → 才考虑 XA
四、AT 模式的三个坑
1. 全局锁持有时间 = 全局事务持续时间
AT 模式里有两把锁,一定要分清楚:
| 本地锁(数据库行锁) | 全局锁(Seata 维护) | |
|---|---|---|
| 谁管的 | 数据库 | TC |
| 何时释放 | 本地事务提交时立即释放 | 全局事务提交/回滚时才释放 |
| 存在哪里 | 数据库锁表 | TC 内存 + 数据库表 lock_table |
| 作用 | 防本地并发写 | 防「写了又被回滚掉」的脏写 |
全局锁的申请时机在 Seata 官方文档里写得很明确:「一阶段本地事务提交前,需要确保先拿到全局锁」。拿不到全局锁,就不能提交本地事务。
所以:事务边界划多大,这把全局锁就持有多久。 这一条是 AT 模式性能的决定性因素,下一篇文章会用实测数据展开。
2. 脏写风险
全局锁能防住大多数脏写,但防不住所有情况。典型场景:
事务 A: 读到 stock.available = 100,执行 UPDATE 改为 99,拿到全局锁,提交本地事务
外部操作(不经过 Seata):UPDATE stock SET available = 50 WHERE id = 1
事务 B 回滚:undo_log 里的后镜像是 99,但当前值是 50 → 不一致!
Seata 的处理方式是回滚前先比对后镜像:用 undo_log 里的 afterImage 和当前数据库的值做比较,不一致就抛异常告警,不强行回滚。
这是个诚实的设计——它承认了「无法自动处理」,把决定权交给人。强行回滚会把别人的修改覆盖掉,造成更严重的数据损坏;停下来告警虽然打断了自动化流程,但至少不会把数据改坏。
3. TC 是新的单点
所有分支都要向 TC 注册、申请全局锁、上报状态。TC 挂了会怎样?
- 正在运行的全局事务:无法提交也无法回滚,只能等 TC 恢复。超时后会被回滚
- 新的全局事务:完全无法开启
所以TC 必须集群部署 + 高可用。Seata Server 的集群模式依赖一个共享存储(通常是数据库)来保存全局事务会话,多个 TC 节点通过数据库行锁竞争来协调。
五、AT 模式的基本接入
<!-- 引入依赖 -->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.7.0</version>
</dependency>
seata:
enabled: true
application-id: order-service
tx-service-group: my_tx_group # 事务分组,TC 按这个找集群
service:
vgroup-mapping:
my_tx_group: default # 分组到 TC 集群的映射
registry:
type: nacos
nacos:
server-addr: 192.168.1.10:8848
application: seata-server
每个参与事务的数据库都要建 undo_log 表:
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;
业务代码只需要一个注解:
@GlobalTransactional(rollbackFor = Exception.class, timeoutMills = 30000)
public void createOrder(OrderDTO dto) {
orderMapper.insert(buildOrder(dto)); // 分支 1:本地事务
stockClient.deduct(dto.getSkuId(), 1); // 分支 2:远程调用,自带 XID
// 抛异常 → TM 通知 TC → TC 驱动所有分支回滚
}
timeoutMills 一定要设。没有超时的全局事务会在 TC 端无限期挂着,全局锁也一直不释放——一旦某个分支卡住,整条业务线都会被拖住。
六、什么时候不该用分布式事务
这是最需要克制的部分。看到「跨服务」就上 Seata 是很常见的误区,代价是引入了 TC 这个新单点、增加了全局锁的串行点、拉长了请求链路。
判断标准很简单:这件事真的需要原子性吗?
| 场景 | 该用什么 |
|---|---|
| 订单创建 + 库存锁定 | ✅ 需要原子性,用 Seata AT |
| 支付成功 + 发放积分 | ❌ 允许延迟,用 MQ 最终一致 |
| 下单 + 发送通知 | ❌ 通知丢了可以重发,用 MQ |
| 下单 + 更新搜索索引 | ❌ 索引晚几秒无所谓,用 MQ |
| 转账扣款 + 入账 | ✅ 需要原子性,但通常是同库同事务 |
大量的「跨服务一致性」需求,本质上根本不需要强一致,只是需要一个「消息一定会被处理」的保证。这类场景用本地消息表或事务消息,成本远低于分布式事务,而且没有全局锁这个性能瓶颈。
一个务实的经验法则:
能用本地事务解决就不用分布式事务;能用最终一致就不用强一致;只有「同一时刻必须原子完成」的那一小段,才值得付出 Seata 的代价。
七、小结
- 分布式事务的难点是已提交的本地事务怎么撤销,四种模式的差异全在于对这个问题的回答
- 三大角色:TC 协调、TM 发起、RM 管理分支,它们靠全局唯一 XID 串联
- XA 靠数据库原生两阶段提交,强一致但行锁持有时间长,并发差
- AT 一阶段就提交本地事务,靠
undo_log的前后镜像反向补偿,业务侵入低是它最大的优势 - TCC 靠资源预留获得最好的隔离性,代价是要实现 Try/Confirm/Cancel 三个方法并处理幂等、空回滚、悬挂
- SAGA 适合长流程,没有隔离性,补偿逻辑要自己写
- AT 模式的两把锁要分清:本地行锁提交即释放,全局锁持有到全局事务结束——这是它性能的决定因素
- 脏写风险靠回滚前比对后镜像来发现,发现不一致就告警人工介入,不强行回滚
- TC 是新的单点,必须集群部署
- 大多数「跨服务一致性」需求用 MQ 最终一致就够,不要看到一个跨服务调用就上分布式事务
模式选对了只是第一步。真正决定这套方案在生产环境跑得好不好的,是事务边界划在哪里——这是下一篇要讲的事。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。