1. 先从订单和库存说起:为什么需要分布式事务
如果你做过微服务改造,大概率遇过这个场景:用户下单时,订单服务要写一条订单记录,库存服务要扣减一个商品库存。在单体应用里,这俩操作在同一个数据库事务里,要么都成功、要么都失败,一句 @Transactional 就搞定。但拆成微服务之后,订单库和库存库变成了两个独立的数据库,甚至可能部署在不同的机器上——这时候你再想把两个库的操作放进一个本地事务里,就完全行不通了。
常见的做法是“先扣库存、再创建订单”,但这套方案有个隐患:如果订单服务在写完订单记录后、返回结果前宕机了,库存已经扣了,订单却没生成,用户钱付了货没发,售后电话直接被打爆。反过来“先建单、再扣库存”也有问题:库存扣减失败后订单已经生成,用户下单成功但无法发货,依然要人工介入。
这就是典型的分布式事务问题。每个服务的数据一致性没问题,但跨服务的数据一致性没人保证。业界解决方案不少:两阶段提交(XA)、TCC、SAGA、本地消息表、最大努力通知……各有各的适用场景,也都各有各的痛点。今天要聊的Seata AT模式,是其中实现成本最低、对业务侵入最小的一种,很适合“下单扣库存”这类场景。
Seata是阿里巴巴开源的一套分布式事务解决方案,AT模式是它的核心模式。AT是Automatic Transaction的缩写,字面意思就是“自动事务”。它为什么叫自动?因为它的设计目标就是:让你在写业务代码的时候,几乎感觉不到分布式事务的存在。你只需要在方法上打一个 @GlobalTransactional 注解,剩下的交给框架去做。
这听起来有点像是玄学,但它背后的原理其实不复杂。这篇文章我会从AT模式下的事务模型、核心机制、环境搭建、实际编码、常见坑排查这几个维度,把它拆开讲清楚。内容主要面向正在做微服务改造、被跨库事务问题困扰的Java后端开发,以及准备面试想系统梳理分布式事务方案的候选人。读完你不仅能跑通一个Demo,还能理解Seata AT模式为什么这么设计、它在什么情况下会失灵。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Seata整体架构与AT模式的定位
2.1 三大组件:TC、TM、RM
先搞清楚Seata的协作模型。它借鉴了XA两阶段提交的思路,但做了大量优化,把原本需要数据库层面支持的分布式事务,通过拦截SQL、记录镜像、反向补偿的方式实现。整个框架里有三个角色:
- TC(Transaction Coordinator):事务协调者,独立部署的服务端,负责全局事务的开启、提交、回滚决策。对应Seata Server。
- TM(Transaction Manager):事务管理器,嵌入在业务应用里,负责开启全局事务、提交或回滚全局事务。对应
@GlobalTransactional注解背后的逻辑。 - RM(Resource Manager):资源管理器,同样嵌入在业务应用里,负责管理分支事务的资源,向TC注册分支、上报状态、执行回滚。对应Seata对DataSource的代理。
说人话就是:TM是“发令枪”,它说“开始”,整个全局事务就开始了;TC是“裁判”,它记录每个人的状态,最后决定这局判赢还是判输;RM是“参赛选手”,它管好自己负责的那部分数据,执行指令并回传结果。
这三者的关系是:TM向TC申请开启全局事务,拿到一个全局事务ID(XID);XID会通过Dubbo、Spring Cloud等框架的链路透传,从调用方传递到被调用方;被调用方里的RM感知到XID存在,就向TC注册分支事务;当TM发起全局提交或回滚时,TC通知所有RM执行各自的提交或回滚。
2.2 四种模式对比:AT、TCC、SAGA、XA
Seata一共支持四种事务模式,聊AT之前,先把它的竞争者们放在一起比较一下,这样才能理解AT的定位和取舍。
XA模式是最标准的分布式事务实现,依赖数据库对XA协议的原生支持。它的优点是强一致性、实现简单,但缺点是数据库资源被锁定时间过长,并发能力差,而且要求数据库必须支持XA协议(MySQL的XA支持实际上也坑不少,比如主从切换时状态丢失的问题)。TCC模式把事务拆成Try、Confirm、Cancel三个阶段,需要业务方自己写大量的补偿逻辑,灵活性高、性能好,但开发成本极高,等于要把每个业务操作反过来再写一遍。SAGA模式把长事务拆成一串本地事务,每个本地事务成功后触发下一个,失败则执行反向的补偿操作,适合业务流程长、允许最终一致的场景,但没有隔离性保护,处理起来也很繁琐。
AT模式走的是另一条路:一阶段直接提交本地事务,通过记录数据变更前后的镜像实现二阶段的回滚;二阶段提交时异步删除镜像日志,回滚时用镜像数据覆盖当前数据。它不需要业务方写任何补偿逻辑,比TCC省事得多;它也不依赖数据库的XA协议,普通MySQL就能用;同时它的性能比XA好,因为一阶段本地事务直接提交,不用长时间持有数据库锁。
这里的代价是:AT模式牺牲了全局读一致性。它能保证写隔离,但读隔离需要额外加SELECT FOR UPDATE才会触发自动补偿。有关这部分细节,后面章节会展开。
四种模式的选择其实很看场景:有强一致需求且并发不高的场景,选XA或AT;需要高并发且能接受补偿开发成本的,选TCC;业务流程非常长、对实时一致性要求不高的,选SAGA。但对于绝大多数“订单+库存”这类跨库写操作的场景,AT模式是性价比最高的入门选择。
2.3 AT模式设计目标:让分布式事务像本地事务一样简单
AT模式之所以叫“自动”,核心在于它对业务代码的侵入做到了最低。对比TCC,你需要为每个参与分布式事务的方法写Try、Confirm、Cancel三套逻辑;对比SAGA,你需要为每个步骤设计对应的补偿方法。而AT模式下,你的业务代码就是一个普通的@Transactional方法,加上@GlobalTransactional标记即可,不需要额外的补偿接口。
支撑这个设计目标的是Seata的两个核心思想:
第一,把“提交”和“回滚”的决策权上移到TC。每个参与方只向TC报告自己的执行状态,最终是提交还是回滚,由TC统一裁决。这样避免了分布式环境下各节点各自为政导致的数据不一致。
第二,把“数据修复”转化为“镜像对比”。每个分支事务在执行SQL时,Seata会先记录这条SQL影响的数据在修改前的样子(beforeImage),执行完修改后再记录修改后的样子(afterImage)。一阶段结束时,本地事务正常提交,数据库数据已经被改了,但如果需要回滚,Seata会根据beforeImage生成反向SQL去覆盖当前数据。这个过程不需要业务方介入,Seata的代理数据源在底层自动完成。
理解了这个设计目标,AT模式的各种机制就都有的放矢了。
3. AT模式核心机制拆解
3.1 写隔离:全局锁如何防止脏写
AT模式要解决的第一个问题是:如果多个全局事务同时操作同一行数据,怎么防止互相覆盖。Seata的做法是引入全局锁机制。
Seata Server端维护了一张全局锁表,锁的粒度是“资源ID + 行主键”。当一个分支事务要更新某行数据时,RM会先向TC申请该行的全局锁。只有拿到全局锁之后,本地SQL才会继续执行;拿不到锁则自旋等待,超时后报错。
这里有一个关键点:全局锁必须在一阶段本地事务提交前申请成功,否则会出现“脏写”窗口。设想一个场景:全局事务A扣减库存,它在执行UPDATE stock SET count = count - 1后、尚未提交本地事务时,另一个全局事务B也来扣减同一行库存。如果B在拿到数据库行锁之前直接去申请全局锁,由于A已经持有该行的全局锁,B会一直等待,直到A释放。但如果流程控制得不好,A可能因为持有数据库行锁而等待B提交,B因为等待A释放全局锁而停滞,造成死锁。
Seata的解决方式是:一阶段执行SQL前先向TC申请全局锁,拿到全局锁后再执行本地SQL并提交本地事务。因为本地事务提交后数据库行锁就释放了,不会与全局锁互相阻塞。B事务此时要么阻塞在全局锁上,要么拿到锁后继续执行,不会出现互相等待的僵局。
这个机制保证了两个全局事务不会同时修改同一行数据,即全局写隔离。但要注意,它只隔离“全局事务之间的写操作”,如果某个本地事务直接操作数据库、没有经过Seata代理的数据源,它是感知不到全局锁的。这是AT模式的一个使用边界:必须确保所有涉及分布式事务的数据库操作都走Seata的数据源代理。
3.2 读隔离:为什么默认是读未提交
理解了写隔离,再来看读隔离就简单了。AT模式默认的全局隔离级别是读未提交(Read Uncommitted),也就是说,在一个全局事务还没提交之前,其他事务能读到它修改但未提交的数据。
这确实和单机数据库的“读已提交”习惯不一样。原因在于,如果要把全局隔离级别提升到读已提交,Seata在每次读操作时都要检查该行数据是否被其他全局事务加锁了,如果加锁就要等待。这个检查对性能影响很大,而且会让读操作被阻塞,在高并发场景下根本扛不住。
那如果业务上必须要有读已提交怎么办?Seata提供了一种方式:在查询语句上使用SELECT FOR UPDATE。Seata的代理数据源会拦截带FOR UPDATE的查询SQL,先向TC查询该行数据是否被其他全局事务持有写锁,如果是,则等待锁释放后再执行查询。这样既保证了读取到的是已提交的数据,又只在显式声明时才付出性能代价。
实际项目中我见过不少团队在这里踩坑:线上数据出现“读到了不该读到的中间状态”,排查后发现是团队成员在全局事务未提交前,通过其他服务查询了同一份数据。这种问题不是AT模式的Bug,而是使用方没有理解它的隔离级别约束。解决方案就是给对应的查询加上FOR UPDATE标签,或者调整业务时序,确保全局事务提交后再进行读取。
3.3 undo_log表:AT模式回滚的基础设施
AT模式回滚依赖的是每张业务表对应的undo_log表。这张表是整个回滚机制的数据底座,它的核心字段包括:
- branch_id:分支事务ID
- xid:全局事务ID
- rollback_info:JSON格式的数据镜像,包含beforeImage和afterImage
- log_status:日志状态,0表示正常,1表示已回滚
- log_created和log_modified:记录创建和修改时间
每个参与全局事务的数据库都必须创建一张undo_log表。这个表由Seata的SQL脚本提供,数据库初始化的时候一次性建好。
写入流程是这样的:分支事务执行SQL前,Seata的代理数据源会先解析SQL,生成一个查询语句(比如对UPDATE stock SET count = count - 1 WHERE id = 1,Seata会生成SELECT * FROM stock WHERE id = 1),查询出修改前的数据作为beforeImage;执行SQL后,再查询一次修改后的数据作为afterImage。这两份镜像数据连同SQL本身,被序列化后写入undo_log表,和业务SQL在同一个本地事务中提交。
3.4 二阶段回滚:镜像对比与脏写校验
现在到了AT模式最核心的部分:回滚的时候,Seata到底做了什么。
假设全局事务执行到一半,某个分支事务失败了,TC通知所有分支事务回滚。对于某个分支事务,RM会做这几件事:
第一步,从undo_log中读取该分支事务对应的回滚日志,拿到beforeImage和afterImage。
第二步,根据afterImage的主键,执行一条查询SQL,查出该行数据的当前状态。然后用afterImage逐字段对比当前数据。如果一致,说明该数据从分支事务提交后没有被修改过,可以安全回滚;如果不一致,说明有“脏写”,此时Seata不会直接覆盖,而是记录异常并人工介入。
第三步,如果对比通过,根据beforeImage生成反向SQL,把当前数据恢复成修改前的样子。对于UPDATE语句,会生成对应的UPDATE,把每条记录设置为beforeImage中的值;对于INSERT语句,生成DELETE删除主键对应的记录;对于DELETE语句,生成INSERT恢复删除前的数据。
第四步,回滚完成后,删除对应的undo_log记录,或者将log_status改为1。
这个过程听起来很完美,但有个隐藏问题:如果业务SQL操作的数据量很大,比如一次UPDATE影响了10000行,undo_log中的镜像和后续的反向SQL都会非常庞大,对数据库性能造成明显冲击。我在实践中见过因为一次批量更新导致undo_log表暴涨的情况,所以建议在业务上控制单条SQL影响的数据量,避免大批量更新出现在全局事务中。
另外一个容易忽略的点:undo_log的回滚逻辑依赖主键定位行数据。如果业务表没有主键,或者主键在事务过程中被修改了(虽然这很少见),回滚会失败。所以在设计表结构时,确保参与全局事务的每张表都有稳定且唯一的业务主键。
4. 实操:从零搭建一个基于Seata AT模式的订单库存Demo
4.1 环境准备与版本选型
先说明我实际验证过的环境组合:
- Seata Server:1.5.2,使用Nacos作为注册中心和配置中心
- Spring Boot:2.4.2
- Spring Cloud Alibaba:2021.1,对应Seata 1.5.2
- MySQL:5.7 / 8.0均可,这里用8.0
- Nacos:2.0.3
版本选型是第一个容易踩坑的地方。Spring Cloud Alibaba和Seata版本有严格的对应关系,用错版本会出现各种莫名其妙的问题,比如注册中心连不上、注解不生效等。建议先确认自己项目里的Spring Cloud Alibaba版本,再查它内置的Seata版本,保持一致。比如Spring Cloud Alibaba 2021.1内置了Seata 1.5.2的适配逻辑,如果你单独引入Seata 1.6.x,可能出现数据结构不匹配的问题。
选择Nacos作为注册中心是因为大多数Spring Cloud项目本身就在用Nacos,可以减少额外依赖。如果没有Nacos,也可以用Seata自带的file模式,把注册和配置直接写在本地文件里,适合单机调试,但不如Nacos方便观察各个服务的注册情况。
4.2 启动Seata Server(TC)
Seata Server的启动流程分几步:
第一步,下载seata-server压缩包并解压。配置文件在conf目录下,核心是registry.conf和file.conf。1.5版本之后,配置信息可以统一放到Nacos配置中心,registry.conf里只需指定配置中心的连接方式。
第二步,修改registry.conf,配置注册中心和配置中心都使用Nacos:
conf复制registry {
type = "nacos"
nacos {
application = "seata-server"
serverAddr = "127.0.0.1:8848"
group = "SEATA_GROUP"
namespace = ""
username = "nacos"
password = "nacos"
}
}
config {
type = "nacos"
nacos {
serverAddr = "127.0.0.1:8848"
group = "SEATA_GROUP"
namespace = ""
username = "nacos"
password = "nacos"
}
}
如果不想依赖Nacos,也可以把type改为file,然后直接编辑同目录下的file.conf。但需要记住:配置文件里的service.vgroupMapping配置必须和客户端保持一致,否则事务组找不到对应的TC,这是非常常见的报错原因。
第三步,创建Seata所需的数据库。Seata Server本身不用数据库存储事务状态,但如果你配置了某种存储模式(比如db模式),就需要建对应的表。这里直接用file存储事务日志,不依赖数据库。
第四步,执行启动命令,确认Nacos服务列表中出现seata-server。
启动完成后,去Nacos控制台看一下服务列表,如果seata-server显示在列表中,说明注册成功。这一步也是排查连接问题最直观的检查点。
4.3 在业务库中初始化undo_log表
每个参与分布式事务的数据库都需要执行下面这条建表语句。注意:如果后面发现回滚不生效,先检查是不是这张表没建或者建错了。
sql复制CREATE TABLE IF NOT EXISTS `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 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 COMMENT ='AT transaction mode undo table';
这里有个细节:rollback_info字段建议使用LONGBLOB而不是LONGTEXT。因为beforeImage和afterImage在Seata内部是序列化后的二进制数据,用TEXT类型存储会在某些字符集下端到端转换时出现编码问题,导致反序列化失败、回滚失败。我见过不止一次因为表结构里用了LONGTEXT导致回滚时报ClassNotFoundException的情况,排查过程相当痛苦。
如果业务库已经存在undo_log表,但版本不一致(比如字段缺失),建议直接drop掉重建。Seata升级时可能会调整undo_log表结构,旧表不兼容会导致分支事务注册失败。
4.4 客户端依赖配置
以Maven项目为例,需要在业务服务(订单服务、库存服务)中引入Seata依赖。如果是Spring Cloud Alibaba项目,建议直接用starter方式引入:
xml复制<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.5.2</version>
</dependency>
引入依赖后,在application.yml中配置Seata相关参数:
yaml复制seata:
enabled: true
application-id: order-service
tx-service-group: my_test_tx_group
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
config:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
service:
vgroup-mapping:
my_test_tx_group: default
data-source-proxy-mode: AT
这几个配置项的含义:
- tx-service-group:事务组名称,可以理解为给当前应用所属的全局事务组起个名字。它和service.vgroupMapping组合起来,决定了客户端去注册中心找哪个TC。
- vgroup-mapping:把事务组映射到Seata Server的服务名。上面配置里的
my_test_tx_group: default,表示当前事务组使用名为default的TC集群,对应上一步注册到Nacos的seata-server。 - data-source-proxy-mode:指定分支事务类型为AT。
这里有一个我经常提到的坑:tx-service-group的值必须和Seata Server端的service.vgroupMapping配置对得上。如果你改了事务组名,但Server端对应的配置没有同步,客户端启动时就会报no available service found之类的错误。
4.5 自定义DataSource代理配置
Seata要拦截SQL、生成undo_log,就必须让所有数据库操作都经过它的代理数据源。你的项目里数据源通常是由Druid或HikariCP创建的,需要在配置类中把这个数据源包装成Seata的DataSourceProxy。
java复制@Configuration
public class SeataDataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource dataSource() {
return new DruidDataSource();
}
@Bean
public DataSourceProxy dataSourceProxy(DataSource dataSource) {
return new DataSourceProxy(dataSource);
}
@Bean
public SqlSessionFactory sqlSessionFactory(DataSourceProxy dataSourceProxy) throws Exception {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSourceProxy);
return factoryBean.getObject();
}
}
这个配置的关键在于:MyBatis的SqlSessionFactory使用的是dataSourceProxy而不是原始数据源。如果这里配错了,SqlSession拿到的还是原始DataSource,Seata无法拦截SQL,全局事务就不会生效。
如果项目用了MyBatis-Plus,配置方式类似,只要确保dbConfig中的DataSource注入的是代理后的DataSourceProxy即可。
另外提一个细节:如果你同时使用了动态数据源(如@DS注解切换数据源),要小心代理顺序。Seata的DataSourceProxy必须包在最外层,这样才能对切库后的数据源也生效。顺序不对的话,切库操作会绕过Seata,导致分支事务脱离全局事务管理。
4.6 编写核心业务代码
这里用一个最经典的“下单+扣库存”案例,完整展示AT模式的用法。
订单服务负责创建订单,库存服务负责扣减库存。订单服务在创建订单后,通过OpenFeign调用库存服务扣减库存。正常情况下,两个操作都成功;如果扣减库存失败,整个全局事务回滚,订单也不会创建。
订单服务的核心代码:
java复制@Service
public class OrderService {
@Autowired
private OrderDao orderDao;
@Autowired
private StorageFeignClient storageFeignClient;
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
public void createOrder(Order order) {
// 1. 本地事务:创建订单
orderDao.insert(order);
// 2. 远程调用:扣减库存
storageFeignClient.deduct(order.getProductId(), order.getCount());
// 3. 模拟异常:用于测试回滚效果
// int i = 1 / 0;
}
}
库存服务这边,扣减库存的方法必须是一个普通的本地事务方法,由Seata的RM代理自动注册为分支事务:
java复制@Service
public class StorageService {
@Autowired
private StorageDao storageDao;
@Transactional(rollbackFor = Exception.class)
public void deduct(Long productId, Integer count) {
int result = storageDao.deductStock(productId, count);
if (result == 0) {
throw new RuntimeException("库存不足");
}
}
}
这里有一个很容易误解的点:@GlobalTransactional应该加在哪个方法上?答案是发起全局事务的入口方法上,通常是最外层的服务调用方法。库存服务内部的@Transactional是分支事务的本地事务,不需要也不应该使用@GlobalTransactional,否则会出现嵌套全局事务的问题。
4.7 验证全局提交与回滚
代码写完后,启动Seata Server、Nacos、订单服务、库存服务,然后做两个验证:
第一个验证全局提交:调用下单接口,传入合法参数,观察订单表新增一条记录,库存表对应商品的库存减少。同时查一下undo_log表,里面会有两条记录:一条是订单库的,一条是库存库的。这是因为一阶段提交后,回滚日志还没被清除。全局事务提交后,异步删除undo_log记录,过一会儿再查会发现记录消失。
第二个验证全局回滚:把createOrder方法里的模拟异常放开(int i = 1 / 0;),再调一次下单接口。此时订单服务在本地插入订单后抛出异常,全局事务标记为回滚。TC通知库存服务的RM执行回滚,库存表数据恢复为扣减前的值。查询数据库,确认订单表没有新记录、库存表没有变化,说明回滚生效。
两个验证都通过后,再去看Seata Server的日志,里面会输出全局事务从begin到commit/rollback的完整过程。这一步能帮你建立对AT模式运行流程的直观认知。
5. 常见问题与排查技巧实录
5.1 问题表:按症状定位原因
我在实际项目里遇到过不少Seata相关的问题,这里整理成一张速查表,方便遇到问题时直接对照:
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
启动报错no available service found |
tx-service-group配置错误,或TC未注册到Nacos | 检查Nacos服务列表里是否有seata-server;检查vgroupMapping配置是否与Server端一致 |
| 全局事务没有生效,异常时不回滚 | 数据源未被DataSourceProxy代理 | 检查SqlSessionFactory是否使用了代理后的数据源 |
回滚时报xid找不到 |
分支事务不在同一个全局事务上下文 | 检查RPC调用链的XID透传配置,确认Feign/Dubbo拦截器生效 |
报错Could not found global transaction xid |
调用了参与全局事务的服务,但入口方法没有加@GlobalTransactional |
检查最外层入口方法是否加了注解 |
大量Global lock wait timeout |
高并发下全局锁竞争激烈 | 检查是否存在长事务;考虑优化业务逻辑缩短事务时间;评估是否需要调整全局锁超时时间 |
| undo_log表数据量暴涨 | 大量全局事务一阶段已提交、二阶段尚未清理 | 检查二阶段提交是否正常运行;确认TC服务没有频繁宕机 |
| 回滚后数据不对(脏写) | 事务期间有其他非Seata代理的写操作修改了同一行 | 检查是否有定时任务、报表系统绕过Seata直接修改数据库;排查数据变更规范 |
5.2 案例一:分布式事务未生效的排查过程
有一次在客户现场,他们的业务是“创建工单+同步创建设备记录”,两个服务跨库写入。代码里已加了@GlobalTransactional,但测试的时候发现:设备记录同步失败时,工单居然还是创建成功了,完全没有回滚。
第一次排查时,我先确认了Seata Server在线、服务注册正常、日志没有报错。然后怀疑是数据源代理问题,但检查一遍配置发现DataSourceProxy已经包上了。最后在日志里看到一条关键信息:当前线程中XID为空。
这就奇怪了。入口方法明明加了@GlobalTransactional,为什么RM的XID是空的?进一步检查发现,这个项目里有一个自定义的AOP切面,它的执行顺序在Seata的切面之前,把异常直接吞掉了。Seata的全局事务拦截器根本没来得及执行回滚逻辑,异常就消失在自定义切面里。
这个案例给我们的教训是:@GlobalTransactional的回滚依赖于异常能顺利传播到Seata拦截器。如果项目里有复杂的AOP嵌套,一定要留意切面顺序,确保Seata的切面在最外层,或者至少能捕获到异常。
5.3 案例二:全局锁等待超时
另一个高频问题是高并发场景下的Global lock wait timeout。有次做秒杀活动,下单接口在高峰期频繁报错,错误信息指向全局锁申请超时。
排查后发现,问题出在业务代码把耗时操作放进了全局事务里:一个下单方法里调用了外部的会员积分服务,这个调用耗时不固定,导致全局事务长时间持有库存记录的全局锁,后续的所有下单请求都在排队等锁,最终超过默认的60秒超时直接失败。
解决方案有二:一是把外部调用移出全局事务,通过MQ异步通知会员积分服务,降低事务时间;二是如果无法移出,就把事务拆得更小,比如先锁定库存、再处理其他逻辑。分布式事务的本质是让事务范围尽可能小,把无关操作挪出去,锁竞争自然就缓解了。
5.4 实用避坑清单
最后整理一份避坑清单,都是实操中容易忽略的细节:
- 参与全局事务的表必须有主键,否则Seata无法定位行数据,回滚会失败。
- 一阶段SQL尽量控制影响行数,大范围UPDATE会产生巨大的undo_log,影响性能和存储。
- 不要在大事务里调用RPC接口,除非你对对方的响应时间有严格预期。
- 保证Feign或Dubbo调用链上的XID透传。Spring Cloud Alibaba的Seata集成会自动透传,但要检查是否自定义了Feign拦截器覆盖了默认行为。
- 如果使用了ShardingSphere或MyCat等中间件,确认它们和Seata的兼容性。分库分表场景下,Seata的全局锁和undo_log表的位置需要特殊设计。
- Seata Server的存储模式如果是db,需要提前初始化global_table、branch_table、lock_table三张表,否则TC启动时不会报错,但事务状态无法持久化,重启后所有全局事务状态丢失。
- 事务超时时间要结合业务场景设置,不要一味调高。全局锁持有时间越长,阻塞范围越大,整个系统的吞吐量越低。
6. 写在最后的一点个人体会
用AT模式做了几个项目之后,我最大的感受是:它的确解决了一部分分布式事务的问题,但它不是银弹。它把“编写补偿逻辑”这个负担从业务代码转移到了框架内部,这确实很香,但框架替你做的每一件事,背后都藏着约束——你需要为每张表建undo_log,你需要让所有数据库操作走代理数据源,你要接受默认的读未提交隔离级别,你要控制事务的粒度。
我实际踩过的坑里,最典型的两个:一个是忽略AOP顺序导致回滚失效,另一个是长事务导致全局锁竞争拖垮性能。这两个问题都不会在Demo阶段暴露,只会在线上流量压力下现出原形。所以,用AT模式之前,一定要想清楚自己的业务里有没有不可控的外部依赖、有没有大范围的批量操作、有没有绕开Seata的数据入口。
如果你刚开始接触分布式事务,我建议从AT模式入手,它是最容易跑通的方案,用它理解清楚全局事务、分支事务、回滚日志这些概念后,再去研究TCC和SAGA,会觉得顺畅很多。希望这篇内容能帮你少走一些弯路。
