做微服务的时间长了,几乎都会撞上分布式事务这道坎。单体应用里一个 @Transactional 就能搞定的原子性,拆成多个服务、多个数据库之后就变了味:订单服务扣了库存,账户服务扣款失败,这两笔数据怎么收场?Seata 的 AT 模式(Automatic Transaction)就是为这种场景设计的一套开源分布式事务方案,它把两阶段提交的大部分复杂度收进中间件,业务侧往往只需要加一个注解。这篇文章我从原理讲到踩坑实录,适合刚准备接 Seata 的同学,也适合已经在用却在为全局锁、回滚失败头疼的人。
1. 项目概述与核心思路拆解
1.1 分布式事务到底在解决什么问题
先说清楚需求。假设你手下有一套微服务下单链路:订单服务负责落订单表,库存服务负责扣 sku 库存,账户服务负责扣用户余额。三个服务,三套库,甚至可能是三套不同的数据存储。如果它们是三个独立的本地事务,就会出现最经典的对账事故:库存扣了、余额扣了、订单没生成,用户骂街;或者订单生成了、库存没扣,超卖,仓库骂街。
单体时代我们没有这个烦恼,一个关系型数据库里的事务天然具备 ACID。但服务一拆,本地事务的作用域就只在自己的库内,跨库、跨服务的一致性问题就冒出来了。这个问题的本质是:我们需要一个“全局事务”,它能把多个参与者(服务/数据库)的状态变更串成一个整体,要么全部成功,要么全部不生效。
分布式领域有一条 BASE 理论:基本可用、软状态、最终一致。它是对强一致性的妥协。Seata 的定位正好落在“Base+两阶段提交”的加强版上:它不是纯理论框架,而是一个可落地的中间件,把全局事务的协调工作收归到一个独立的 TC(事务协调器)来统一管理。
我习惯把问题分层来看:
- 一致性要求有多强:订单这种链路频繁出现但失败率低,适用“最终收敛为一”的模式;
- 业务侵入有多深:能不能只加注解不改业务逻辑;
- 性能开销能忍多少:每次业务操作多出来的 SQL 和网络调用是多少。
这三条是后面选型和对齐问题的锚点,也是一旦出问题时要回到原点重新思考的地方。
1.2 AT 模式的整体设计思路
Seata 里有三个核心角色,理解了它们,后面所有配置都不会晕:
- TC(Transaction Coordinator):独立部署的服务端,记录全局事务状态、协调各分支的提交回滚。它的作用像一个全局的“交管中心”。
- TM(Transaction Manager):嵌入在应用里的组件,负责开启全局事务、提交或回滚整体事务。通常一个注解就能驱动它。
- RM(Resource Manager):也是嵌入在应用里的组件,管理每个数据源的分支事务,执行分支注册并上报执行结果。
TM 开启全局事务后会拿到一个全局唯一标识 XID。XID 会被塞进 RPC 调用的上下文里,跟着 Feign 或者其他调用链一路传播到下游服务。下游服务只要在同一个 XID 下执行事务操作,就会被识别成同一个全局事务的分支。这个“XID 链路透传”是 AT 模式能不能跨服务生效的关键,我下面的常见问题里还要专门讲。
AT 模式里的“自动”两个字是核心卖点。它借鉴了两阶段提交协议,但对业务基本无感。秘密武器是三样东西:数据源代理(DataSource Proxy)、镜像快照(before/after image)、undo_log 表。数据源代理会在真实执行 SQL 之前做一次旁路解析,自动记录这行数据的“执行前快照”,然后放行业务 SQL,执行完再抓一张“执行后快照”。这两张快照连同分支信息写进一个专门的 undo_log 表,整个过程跟业务 SQL 处于同一个本地事务里。
为什么敢称“自动”?因为回滚所需的补偿 SQL 不需要你写,中间件根据 before image 反推生成。这就是它比 TCC 省事的地方:TCC 要求每个业务手写 Try/Confirm/Cancel 三段逻辑,AT 模式直接替你干了。
从设计目标看,AT 模式是“用空间换时间、用协议换侵入”:多写一张 undo 表、多几次镜像查询,换来业务代码几乎零改造,换取比 XA 强一致协议好得多的可用性。
2. AT 模式核心机制原理解析
2.1 一阶段:本地事务 + 镜像快照
现在我们把一个全局事务的执行过程放大来看。假设 A 服务是全局事务的发起方,B 服务被远程调用后作为分支参与者,下面这串动作在每个参与者本地都会发生。
第一步,TM 获取 XID 并开启全局事务。第二步,业务 SQL 走到 Seata 代理的数据源上。注意,这里要求所有写操作都必须经过代理连接,这是很多人埋坑的地方,后面会说。
进入代理之后,整个过程大致是:
- 解析业务 SQL,根据主键或更新条件定位目标行;
- 在执行前执行一次查询,拿到 before image(修改前那一瞬间的行数据);
- 放行真实的 INSERT/UPDATE/DELETE;
- 再次查询,拿到 after image(修改完成后的行数据);
- 把 before image、after image 连同 XID、branchId 编码后写入 undo_log 表;
- 向 TC 注册分支事务,申请全局锁;
- 拿到全局锁之后,提交本地数据库事务。
为什么要在一个本地事务里同时完成“业务改动”和“undo_log 写入”?道理很简单:如果业务改了、日志没写,系统恰好在这时崩溃,回滚就没依据了;如果日志写了、业务没改,后续校验又会不对。只有把两者放进同一个事务,才能保证要么同时成功、要么同时失败。
还有一个值得品的设计:注册分支和申请全局锁发生在本地事务提交之前。这意味着,某个全局事务 T1 虽然本地数据已经改完、但还没拿到 TC 的最终裁定,这期间其他全局事务不能在同一个数据行上继续“抢跑”。全局锁就是在这里起作用的。
2.2 二阶段:异步提交与镜像回滚
全局事务进入二阶段后,TC 会根据所有分支的上报结果来定夺:全部成功就发“提交”,有任何一个分支失败就发“回滚”。
先看提交路径。二阶段提交时,RM 接到 TC 的通知,要做的事情非常轻量:删除对应分支的 undo_log。这不是非删不可的强同步操作,在实际实现里可以异步批量删除。为什么提交不用回滚业务数据?因为业务数据在本地事务提交那一刻已经真实落库了,所谓全局提交,本质上只是“确认不需要反悔了”,把退路(undo_log)清掉即可。
再看回滚路径。这是 AT 模式最见功力的一环。RM 拿到 XID 和 branchId 之后,找到对应的 undo_log,先把 after image 拿出来跟数据库当前行对比。这一步叫“脏写检查”。如果当前数据和 after image 一致,说明这段数据从分支执行完毕到现在没人动过,可以放心回滚。中间件就根据 before image 生成一条反向 SQL:原来是 UPDATE,就回滚成 BEFORE 行的全部字段值;原来是 DELETE,就按 before image 重新 INSERT;原来是 INSERT,就按 before image 的主键 DELETE。回滚完成后删除 undo_log,分支回滚结束。
脏写检查绝不是走过场。假如有一条数据被分支事务改过,快照是“余额从 100 变 50”,但回滚时发现当前余额已经变成 20,说明这期间有一个脱离全局事务控制的操作动了它。这时候中间件不会盲覆盖,因为一旦按 before image 覆盖成 100,就把别人的 20 这个合法结果也抹掉了。它会标记失败、打印异常日志,把问题留给人处理。这个设计是安全底线,也是大家在日志里偶尔看到“无法回滚”的真正原因。
2.3 全局锁与并发冲突控制
Seata AT 模式里其实有两把锁。一把是大家熟知的数据库本地行锁,持锁期间就是一阶段那段本地事务的执行期,事务提交即释放。另一把是全局锁,记录在 TC 侧,锁的粒度精确到某个数据资源(实际实现里通常按主键或唯一键)。
全局锁的存在意义,是为了解决一个微妙的时间窗口:服务 A 的业务数据已经在本库提交,但从全局视角看,整个分布式事务还没有被裁定。此时如果服务 B 的另一个全局事务也想改同一行,它必须先拿到全局锁。全局锁被 T1 占着,T2 就得等。等不到就抛“获取全局锁超时”的异常,T2 的本地事务回滚,业务看到的就是明确失败而不是静默覆盖。
这个机制会在热点行的场景下放大成一个明显的性能问题。比如秒杀场景里大量请求对同一个库存行做扣减,每个全局事务都要排队等全局锁,吞吐量会掉得很难看。所以选型之前一定要清楚自己的写冲突密度,否则上线后才发现被锁排队拖垮。调优层面主要有两个方向:调大 RM 侧获取锁的重试次数和重试间隔(不同版本参数名略有差异,比如 client.rm.lock.retry-times 和 client.rm.lock.retry-interval),以及在业务侧缩短事务的持续时间——锁只在该全局事务存续期间持有,事务窗口越短,竞争越小。
3. 实操:从零接入 Seata AT 模式
3.1 环境准备与基础配置
做一次最简单的本地联调,我建议按下面顺序来。
第一步,下载并启动 TC 服务端。我用的是 seata-server 的 file 模式,日志和会话都落本地文件,适合学习。命令大致是这样:
sh seata-server.sh -p 8091 -m file -n 1生产环境不要用 file 模式,TC 的状态需要放到共享存储里并做集群部署,否则 TC 一挂全局事务状态全丢。这点切记。
第二步,在每个参与全局事务的业务库中,建一张 undo_log 表。官方标准 DDL 类似下面这样,我一般会在建表语句后面额外加注释标明这个表的用途,方便后来的人一眼看懂:
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 AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;注意几个点:表名必须是 undo_log(除非你改过自定义前缀的配置);字段名和类型都要和中间件期望保持一致;引擎一定要是 InnoDB,原因你应该懂——回滚操作本身就是事务操作,MyISAM 在这里连基本条件都不满足。
第三步,在服务里引入 starter 依赖。以 1.8.0 版本为例:
<dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>1.8.0</version> </dependency>第四步,在 application.yml 里配上最简的 file 模式配置:
spring: application: name: order-service seata: enabled: true application-id: order-service tx-service-group: default_tx_group registry: type: file config: type: file service: vgroup-mapping: default_tx_group: default grouplist: default: 127.0.0.1:8091 client: rm: lock: retry-times: 30 retry-interval: 1000这段配置里最容易出错的,是 tx-service-group 与 vgroup-mapping 的对应关系。客户端用事务群组名去找 TC 集群,群组映射必须跟 TC 侧配置对上,否则你会看到服务启动正常,但一发起全局事务就报 no available service 或者注册不到 TC。
3.2 核心代码写法与数据源代理
接入之后,业务侧的改动小到让人怀疑是不是漏了什么。给最外层的方法加一个 @GlobalTransactional 就行:
@Service public class OrderService { @GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class, timeoutMills = 10000) public Long createOrder(OrderCreateRequest req) { Order order = buildOrder(req); orderMapper.insert(order); // 本地分支操作 inventoryClient.deduct(req.getSkuId(), req.getCount()); // 远程扣库存 accountClient.deduct(req.getUserId(), order.getAmount()); // 远程扣余额 return order.getId(); } }@GlobalTransactional 的三个属性值得花 30 秒理解:name 只是全局事务的名字,方便排查日志;rollbackFor 决定了哪些异常触发回滚,默认只对 RuntimeException 和 Error 生效,遇到 checked exception 时想回滚必须显式指定 Exception.class,这是我见过最多的“注解失效”原因;timeoutMills 是全局事务的超时时间,超过它 TC 会强制把事务标记为超时回滚,所以这个值要留足余量,不能比最长的业务链路还短。
数据源代理这块,starter 默认会开启自动代理(seata.enable-auto-data-source-proxy 默认 true),它会把业务方的 DataSource 包装成 DataSourceProxy。但有几种情况代理不生效,后续常见问题里我详细说。核心原则只有一条:你的所有写 SQL 都要走被 Seata 代理过的连接,别图省事自己在代码里 new 一个裸连接。
还有一个容易被忽略的细节是 XID 透传。如果你们用的是 Feign,而 starter 里自带的拦截器没生效,就需要自己补一个:
@Component public class FeignXidInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { String xid = RootContext.getXID(); if (xid != null) { template.header(RootContext.KEY_XID, xid); } } }这个拦截器做的事情很单纯:把当前线程里的 XID 塞进 HTTP 头,下游服务再从请求头里把它取出来塞回自己的线程上下文。一旦这一步丢链,下游就永远意识不到自己属于哪个全局事务。
3.3 一个完整的下单链路演示
我习惯直接用“余额不足”的场景来验证 AT 模式是否真的在工作。步骤很简单:调 createOrder,让账户服务在扣款时故意抛异常,然后看库存服务那边的库存是否回滚。
链路是这样的。TM 在订单服务里开启全局事务,XID 生成。订单服务先落订单(本地事务提交)。随后调用库存服务,库存服务通过透传过来的 XID 加入同一全局事务,扣减库存并写好 undo_log,注册分支,提交本地事务。最后调用账户服务,账户服务同样加入全局事务,但在余额校验时抛了业务异常。账户服务的本地事务回滚,分支上报失败。TC 裁决全局回滚,通知库存服务回滚。
库存服务的 RM 拿到 undo_log,对比 after image 和当前行,确认数据没被额外改动,于是依据 before image 把扣掉的库存加回来,再把 undo_log 删除。整个下单接口最终表现为“订单不存在、库存没变、余额没少”,三库数据归位。
我在本地复现时会在日志里盯着三行关键输出:全局事务开启的 XID;库存服务注册分支的 branchId;回滚完成后 undo_log 被删除的记录。三者都能对上,说明 AT 模式的闭环真的走通了。A 同学踩过一个很经典的坑:他只给订单服务开了异步线程池里的 Feign 调用,XID 在异步线程里丢了,下游库存服务根本没进全局事务,全局回滚时自然没有库存分支,数据就乱了。后面我用代码显式传递了 XID 上下文才解决。
4. 常见问题与排查经验
4.1 回滚失效与脏写检查
先看一个高频现象:接口确实抛了异常,全局事务日志里也显示回滚了,但数据库里数据没还原。
这种“假回滚”的根源,90% 是分支根本没进全局事务,另外 10% 是回滚路线上被脏写卡住。分支没进全局事务,常见原因有三个。第一,XID 没有透传到下游:Feign 或 RPC 的拦截器缺失,下游不认识这个全局事务,它只是在普通本地事务里各自为政。第二,数据源没有被代理:你自己手动创建了 DataSource 或者在特定代码路径直接拿了原始连接,中间件就没机会生成 undo_log。第三,tx-service-group 映射错误:分支服务映射到的 TC 集群跟发起方不一致,看起来都连上了,实际上不是同一个全局事务。
脏写检查失败又是另一种情况。日志里如果出现 “Status of data changed in global transaction” 这类表述,意思是回滚时拿到的当前行数据跟 after image 对不上。我在真实项目中碰到过一次:一个定时任务用裸 JDBC 直接改了订单表的备注字段,恰好订单行处于某个全局事务的窗口期内,回滚校验就失败了。这种问题没法靠中间件自动解决,只能限定所有写路径都走代理,或者把这种离线任务统一收口。
4.2 全局锁等待超时
另一个高频且更折磨人的问题:全局锁获取超时。日志往往长这样:
Could not get global lock, xid = xxx, branchId = yyy原因几乎都是同一个:某个热点行被另一个未结算完的全局事务占着。常见于秒杀、抢购、库存扣减、余额扣减这类场景。
我的排查套路是三步。第一步,看 TC 消息或日志,确认持锁的 XID 是谁、持了多久。第二步,看这个 XID 的全局事务为什么迟迟不结束——是不是业务里混了大查询、慢 SQL 甚至手工 sleep,把事务窗口拉长了。第三步,才会去考虑调参数。调参也只是缓解,不是根治:把锁重试次数和间隔调大,能容忍更长的排队,但吞吐上不去。
真正的解决方案在前面提过:缩短事务时间,拆小事务粒度,或者对热点资源做前置串行化。如果业务上允许,把热点更新从全局事务里摘出去,改用消息驱动的最终一致,是比死磕锁参数更健康的选择。
4.3 其他高频坑速查表
我把这几个月的实战问题整理成了一张速查表,基本可以贴在 IDE 旁边:
| 现象 | 常见原因 | 处理建议 |
|---|---|---|
| 发起全局事务时报 no available service | 客户端 tx-service-group 与 vgroup-mapping 对不上 | 核对所有参与方的群组映射和 TC 地址 |
| 回滚流程没有覆盖下游服务 | XID 没有通过 Feign/异步链路透传 | 启用或补一个 Feign/HTTP 拦截器传递 XID |
| 日志显示回滚成功但数据没还原 | 分支 SQL 没有走代理数据源 | 检查自建数据源、原生连接等旁路路径 |
| 回滚时脏写检查失败 | 窗口期内有非代理操作改了同一行 | 所有写路径统一走代理,必要时业务补偿 |
| @GlobalTransactional 对 checked exception 不回滚 | 默认只回滚 RuntimeException/Error | 显式设置 rollbackFor = Exception.class |
| undo_log 表持续膨胀 | 二阶段异步删除不及时或事务频繁回滚 | 定期清理,评估能否开异步 commit 优化 |
| 热点行场景吞吐骤降 | 全局锁排队严重 | 缩短事务窗口,或考虑改用 TCC/消息方案 |
这张表里每一行都是我或者身边同事真金白银踩出来的。建议先收藏,上线前对着过一遍,能省掉好几个凌晨的告警电话。
5. 使用边界与实战心得
5.1 到底什么场景适合 AT 模式
选型的时候,我的判断顺序是这样的:先问业务能不能接受最终一致,如果能,优先考虑消息异步;如果必须强一致,再看能不能接受 AT 模式的锁竞争和额外开销。
AT 模式最舒服的场景,是写冲突不高、失败率低、但失败后必须严格回滚的链路。典型的比如普通商城的下单、出库、支付回调。这类业务日常并发不夸张,热点行竞争不明显,代码改造量又小,AT 模式几乎是性价比最高的选择。
反过来,这些场景要慎重:极高并发下对同一个行反复读写(热点竞赛);单个全局事务里揉进大量慢 SQL、外部 HTTP、文件操作,导致事务窗口动辄几十秒;数据库不是 InnoDB 或不具备造 undo_log 表条件的环境。另外,如果你只追求跨服务调用最终一致、且对数据丢失不敏感,直接上消息队列做可靠异步投递,比引入分布式事务中间件都轻。
5.2 常见分布式事务方案对比
做个直给的对比,方便你心里有个坐标系:
| 方案 | 一致性 | 侵入性 | 性能 | 适用场景 |
|---|---|---|---|---|
| XA 两阶段 | 强一致 | 中 | 低,锁持有时间长 | 低并发强一致场景 |
| Seata AT | 读已提交加强 | 低,注解+代理 | 中,多一次镜像和全局锁 | 绝大多数微服务写链路 |
| TCC | 最终一致 | 高,手写三方法 | 中高 | 强约束的资源预留,如库存预占 |
| Saga | 最终一致 | 高,手动补偿 | 高 | 长流程业务编排 |
我个人的感受是,AT 和 TCC 不是替代关系,而是梯度关系。项目初期链路简单,先上 AT 把一致性兜住;等业务增长到热点冲突成为瓶颈,再针对那一条热点链路替换成 TCC 或者异步消息,其他链路继续保持 AT。死磕单一方案往往得不偿失。
5.3 个人经验与调优建议
最后分享几条压测和运维层面的建议。
第一,先量一下“同一条热点行的并发写”能扛到多少。我见过一个项目,前期联调都正常,压测到一定并发后全局锁排队超时像是约好了一样集体爆发。提前压测,心里有数。第二,事务窗口能缩多短就缩多短。把无关的远程调用、大查询挪到事务外面,只为真正需要一致性的几步开全局事务。第三,别忽略 TC 的高可用。TC 挂了全局事务就没法收工,生产必须用共享存储加多节点部署,再配合配置中心和注册中心做动态发现。第四,日志和监控要让 XID 贯穿始终,不然出问题你连从哪个分支开始查都找不到。
如果只是查询场景需要保持一致性,别轻易套全局事务,用 @GlobalLock 或者 select for update 往往更轻快。这个细节容易被忽略,但压测时差距很明显。
我在实际操作中的体会是:Seata AT 模式是“成本最低的分布式事务入门方案”,但它不是银弹。你越早把它当成一个有锁竞争、有额外开销的工程组件来看待,越少在线上被它打脸。好好设计事务边界,配合合理的压测和监控,它会比你想的可靠得多。