订单创建这个操作,单体时代就是同一个数据库里插订单表、扣库存表、减资金表,一个本地事务就完事。微服务拆开之后,订单、库存、账户各占一个独立库,一次下单要跨三个数据源写数据。前端刚点完支付,后端任何一个环节抛异常,都会在某个库里留下半截数据,最常见的就是订单表有记录但库存没扣,或者库存扣了但账户没减。这类账不平的问题,就是分布式事务要解决的。Seata的AT模式是这些年大家接受度最高的折中解法,配合Dubbo做链路改造时,工作量集中在配置和验证上,业务代码侵入极小。这篇文章就围绕Dubbo集成Seata AT模式这条主线,把版本选型、Server部署、客户端接入、机制原理和踩坑经验都梳理一遍,适合已经完成微服务拆分、正在补事务短板的团队参考。
1. 先想清楚:AT模式到底解决了什么问题
1.1 一次扣款事故引出的分布式事务难题
刚接触这个问题的同学第一反应往往是:数据库不是有事务吗?把三个库放同一个事务里不就行了。这在分布式场景下做不到,跨库跨连接的Commit是没法保证同时成功的。早期的XA方案可以做到,让数据库自己参与两阶段提交,代价是事务期间数据库连接被长时间占用,性能损耗大,而且很多业务库用的中间件和数据库类型还不一样,XA的部署成本很高。后来业界普遍转向TCC这类业务侵入式补偿方案,让每个参与方自己实现try、confirm、cancel三个方法。TCC确实可行,但改造量大得可怕,每个业务方法都要额外写两套逻辑,普通项目根本背不动这个成本。
我见过一个真实案例。某团队做订单系统微服务化,代码拆了三周很顺利,联调时出了大问题:账户服务扣款成功,库存服务扣减失败,订单表里却已经有了订单。没有分布式事务兜底,最终靠着人工跑脚本把库存补回来。那次之后团队才开始认真调研分布式事务方案,最后选型就是Seata AT模式。原因很直接:AT模式对现有代码的改造量最小,不需要像TCC那样重写业务方法,也不像XA那样绑死数据库。
1.2 AT模式的自动化思路:镜像与undo_log
AT模式的全称是Automatic Transaction,自动事务。它解决“自动”这个问题的核心手段,是在一阶段做SQL拦截和镜像记录。举个具体例子,你执行一条“update account set balance = balance - 100 where id = 1”,AT模式会先查一次当前数据,把balance=1000这一行完整记录记为前镜像;执行完update后再查一次,把balance=900记为后镜像;两份镜像连同SQL类型一起写入当前业务库的undo_log表,最后业务表和undo_log在同一个本地事务里一起提交。
这里最关键的一点是:业务数据变更和undo_log是同一个本地事务提交的。只要本地事务成功,undo_log一定在;本地事务失败,业务数据也没生效,自然不需要回滚。这种“把补偿日志和业务数据放同一个事务”的设计,是整个AT模式自动补偿的基石。二阶段如果所有分支都成功,就异步清理undo_log;只要有一个分支失败,就根据undo_log里的前镜像生成反向SQL,把数据恢复原样。整个反向补偿过程业务侧完全无感。
1.3 用Dubbo做链路时的最大难点:事务上下文传递
Seata实现全局事务时,交易发起方会分配一个全局事务ID,也就是XID。后续所有参与分支的数据库操作都要登记到这个XID名下。Dubbo服务之间的远程调用,从下游服务再打到下一个服务,XID必须跟着请求一路透传。如果哪一段没传过去,就会出现全局事务断链,分支事务跑到一个孤儿事务里去,失败后根本不会被回滚。
Dubbo的Filter机制天然适合做这件事。Seata给Dubbo提供了专门的Filter,会在发起调用时把XID注入到RPC attachment里,消费端取出来放进当前线程上下文。看起来简单,真正落地时问题不少。比如Dubbo版本升级后Filter没加载、线程池复用导致上下文串位、异步调用丢上下文等。这些坑我在后面专门展开,先说结论:集成Seata时,客户端一定要引入seata-dubbo这个依赖,否则XID透传的Filter根本不会注册到Dubbo容器里,业务代码全程不报错,但全局事务形同虚设。
2. 集成前必须先定下来的几件事
2.1 Seata服务端与客户端的版本搭配结论
Seata的版本兼容是第一个容易踩雷的地方。Seata Server端和客户端版本必须对齐,否则分支事务注册经常报错。我自己踩过Server用1.6.1、客户端用1.5.2,结果事务分支一直注册失败,日志里各种RPC异常。所以落地的第一准则就是:两端用同一个版本号。
我测试下来比较稳定的一套搭配是:Seata Server 1.8.0 + seata-spring-boot-starter 1.8.0 + Dubbo 2.7.15 + Spring Boot 2.7.x。Dubbo 3.x也能跑,但要注意Dubbo Filter的加载方式和attachment的传输逻辑跟2.7有所差异,没有特殊要求先用2.7.x最省心。Seata也有2.x版本了,但我建议大多数追求稳定的项目先不升大版本,1.8.0已经覆盖了AT模式所有常用能力,社区资料也最多。
Seata客户端依赖有几种组合。Spring Boot项目最常用seata-spring-boot-starter,但它只包含核心模块,Dubbo集成还需要额外引入seata-dubbo。这个包就是负责加载XID透传Dubbo Filter的。如果不引,业务代码全程不会报错,但是XID传不过去,全局事务形同虚设,这是最隐蔽的问题之一。
2.2 注册中心、配置中心和事务分组的选择
Seata的Server端和Client端都需要注册中心。Server用注册中心对外暴露服务端口,让客户端能找到它;客户端通过注册中心获得Server列表。最常用的方案是Nacos。如果项目原本没有引入Nacos,也可以用file方式,把Seata Server地址直接写在客户端配置文件里,省去注册中心组件,适合小规模单机部署。
配置中心负责下发Seata自身的配置项,比如事务存储模式、锁重试参数、超时时间。同样可以用Nacos集中管理,也可以全部写在本地file.conf里。我的建议是:先全用本地file方式把链路跑通,再迁移到Nacos,减少排查面。把Nacos、Seata、Dubbo、业务库四方同时上马,出问题根本分不清是哪一环。
事务分组(tx-service-group)是另一个必须理解的概念。客户端配置的事务分组名,比如my_test_tx_group,要通过vgroup-mapping映射到Seata Server的集群名,比如default。客户端拿着集群名去注册中心找对应的Server。这个映射关系写错,最常见的表现就是日志一直打印“no available server to connect”,但Nacos里明明有服务。排查时顺着“事务分组 -> 集群名 -> 注册中心服务名”这条线走就很快。
2.3 什么时候不该用AT模式
写这个是因为见过太多团队把AT当银弹,最后中途放弃。AT模式在两类场景下会很痛苦。第一类是热点资源高频扣减,比如秒杀场景下同一个商品库存字段被大量并发更新。AT的全局锁机制会强制同一时刻只有一个全局事务能改这一条记录,其他事务阻塞等锁,等待时间一长就报“Global lock acquire timeout”,业务量一大根本扛不住。第二类是超大表或复杂SQL,AT要生成整行数据的前后镜像,如果update语句扫了几万行,镜像数据量和undo_log写入量会非常夸张,性能会被拖垮。
如果业务命中这两类场景,可以考虑把热点段拆出来单独用TCC或本地事务加MQ最终一致性去处理,其余普通链路继续用AT。没有一种分布式事务方案是万能的,方案边界比方案本身更值得花时间分析。这也是集成前最该做的事:评估业务,而不是一上来就搭环境。
3. Seata Server部署与客户端接入实操
3.1 Seata Server怎么部署起来
从Seata官方发布的release包下载seata-server压缩包,解压后目录里有两个关键文件:registry.conf和file.conf。registry.conf决定注册中心和配置中心类型,file.conf是本地配置文件。
registry { type = "nacos" nacos { application = "seata-server" serverAddr = "127.0.0.1:8848" group = "SEATA_GROUP" namespace = "" cluster = "default" username = "" password = "" } } config { type = "file" file { name = "file.conf" } }如果本地没有Nacos,可以把registry.type改成file,这样服务端通过file.conf读取所有配置,客户端也配同样的file方式。file.conf里主要改store段,决定事务存储模式:
store { mode = "db" db { datasource = "druid" dbType = "mysql" driverClassName = "com.mysql.cj.jdbc.Driver" url = "jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&characterEncoding=utf8" user = "root" password = "123456" minConn = 5 maxConn = 20 } }store.mode有file、db、raft三种。file模式零依赖,数据落在一个本地文件里,适合测试;db模式把全局事务状态写进数据库,服务和库一起重启也不会丢,生产环境建议用db;raft模式是1.5之后新增的高可用方案,至少三节点,对运维要求高一些。部署时注意,Server端日志默认是info级别,排查分支事务注册问题时往往需要临时调成debug,可以在seata-server的yml配置里把logback的level改掉。
3.2 初始化全局事务相关表
使用db模式时,需要建一个seata库,然后执行三张表,分别是global_table、branch_table、lock_table。这三张表文件一般在官方release包自带的script/server/db/mysql.sql里,不用自己手敲。global_table记录全局事务状态,branch_table记录每个分支事务,lock_table记录全局锁占用的行。后面排查全局事务卡住时,看这三张表的数据量和水位非常直观。
客户端每个业务库则必须建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, `ext` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;这里最容易漏的是:有几个业务库就要建几份undo_log。订单库、库存库、账户库各建各的,少建一张,对应分支事务执行成功后会直接报错,提示找不到undo_log表。
3.3 客户端依赖与application.yml配置
拿一个Spring Boot + Dubbo项目举例,pom里需要加三个核心依赖:
<dependency> <groupId>org.apache.dubbo</groupId> <artifactId>dubbo-spring-boot-starter</artifactId> <version>2.7.15</version> </dependency> <dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>1.8.0</version> </dependency> <dependency> <groupId>io.seata</groupId> <artifactId>seata-dubbo</artifactId> <version>1.8.0</version> </dependency>再次强调seata-dubbo不能漏。很多教程只提starter,不提seata-dubbo,漏掉之后全局事务依然能发起,但Dubbo调用时XID不会自动透传,下游分支全部脱离全局事务控制。做验证时经常出现“上游回滚了,下游没回滚”的诡异现象,十有八九是这个包没引。
application.yml里的完整配置:
seata: enabled: true tx-service-group: my_test_tx_group enable-auto-data-source-proxy: true application-id: order-service registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: "" group: SEATA_GROUP cluster: default username: "" password: "" service: vgroup-mapping: my_test_tx_group: defaulttx-service-group要和Server端映射一致,vgroup-mapping里的my_test_tx_group把事务分组指向cluster=default,这样客户端才知道去注册中心里找cluster为default的Server实例。如果Server端cluster写的是default,客户端映射也必须是default。Nacos的namespace、group、cluster三个字段,两端要完全一致,这几个字段只要有一个对不上,客户端就找不到服务,排查起来很费时间。
3.4 数据源代理的两种写法
AT模式的关键在于框架要能拦截到业务SQL,并在执行前自动生成镜像、执行后写undo_log。这个过程依赖数据源代理,没有代理,Seata就拿不到SQL执行的机会。老版本写法是手动包一层:
@Configuration public class DataSourceConfig { @Bean @Primary public DataSource dataSource(DataSource dataSource) { return new DataSourceProxy(dataSource); } }新版本seata-spring-boot-starter支持自动代理,默认开启,对应application.yml里的enable-auto-data-source-proxy: true。它会在容器启动时自动包装所有数据源。但如果项目里有多数据源、动态数据源,或者用了一些第三方连接池定制逻辑,自动代理经常会有漏网之鱼。一个简单排查方法:在发生分支事务的Mapper执行日志里,如果看到SQL之前先执行了查询前镜像的select操作,说明代理生效了;如果执行完业务SQL直接提交,没有任何镜像查询,说明代理没包上。
还有个容易忽略的点:如果项目同时引入dynamic-datasource这类动态数据源组件,务必确认代理顺序,让Seata的代理在最外层生效。否则业务代码切数据源之后,真正执行的其实是原数据源,Seata拦不到SQL,事务照样失效。这个问题开发环境数据量小很难发现,生产出问题才追悔莫及。
3.5 入口方法上的@GlobalTransactional
在所有参与全局事务的客户端都配置好之后,业务侧要做的就是在事务发起方的方法上加上注解:
@GlobalTransactional(rollbackFor = Exception.class) public void createOrder(OrderRequest request) { // 本地订单数据写入 // Dubbo调用库存服务 // Dubbo调用账户服务 }关键点有三个。第一,rollbackFor一定写成Exception.class,默认只在RuntimeException下回滚,遇到受检异常会静默提交,账单就乱了。第二,注解加在事务发起方的Service方法上,不是加在Dubbo接口实现上,也不是加在Controller上。第三,一个全局事务只有一个入口,入口里调下游方法时不要再加@GlobalTransactional,嵌套使用会引发事务边界混乱。如果下游确实需要加锁保护但不想纳入全局事务,用@GlobalLock。
还有一个容易被忽略的点:入口方法里如果同时开启了本地Spring事务,要确保@GlobalTransactional在最外层。Seata会协调内层本地事务和分支的提交关系,本地事务先提交,分支调用后若失败,Seata会根据undo_log回滚,所以本地提交是允许的。但如果两层事务边界设计得特别绕,还是以“全局注解在最外层,Spring事务注解在业务方法上”为准则比较稳。
4. AT模式运作机制拆解:全局锁、undo_log与两阶段
4.1 一阶段:业务SQL和undo_log一起提交
理解一阶段是理解AT模式的基础,因为后面所有问题排查都离不开它。AT模式的第一步是拦截全部业务SQL。Seata内部的SQL解析器会先识别出SQL类型,比如insert、update、delete、select for update,然后从语法树上提取出主键条件和被变更的字段。以update account set balance = balance - 100 where id = 1为例,解析器能拿到表名account、主键id=1、更新字段balance。
接下来会依次执行几个动作。先按主键查一次当前数据,得到前镜像,也就是balance=1000那一行完整记录。然后执行真正的update语句。执行后再按同一个主键查一次,得到后镜像,balance=900这一行。最后把前镜像、后镜像、SQL类型、涉及的表名等包装成一条undo_log,插入到当前业务库的undo_log表。业务表和undo_log在同一个本地事务里一起提交。
这五个动作里,最后一个动作是整个设计成立的前提。因为业务数据和undo_log在同一本地事务里提交,要么都成功,要么都失败。这样二阶段回滚时,只要找到undo_log,就说明业务数据一定已经提交了,可以安全地做反向补偿。如果业务数据提交了而undo_log没写进去,回滚就无从谈起,也正因为这个,undo_log必须和业务表在同一个库、同一个事务里。
4.2 二阶段:提交是异步清理,回滚是反向补偿
如果全局事务的所有分支都执行成功,事务管理器会通知事务协调器提交全局事务。此时各个分支的二阶段提交动作其实很轻:把对应的undo_log删除、释放全局锁。因为业务数据在一阶段已经提交过了,二阶段不需要再动数据库,这也是AT性能优于XA的主要原因。清理undo_log是异步的,对业务链路几乎无感。
如果任何一个分支事务失败、超时或触发回滚,事务协调器会通知所有分支做回滚。这个回滚动作就复杂了。首先根据branch_id找到undo_log里的前镜像,然后校验数据是否被其他事务改过。做法是查当前数据,跟后镜像比对:如果当前值和后镜像一致,说明一阶段之后没人动过这行,可以安全恢复;如果不一致,说明存在脏写,需要人工介入。校验通过后,用前镜像生成反向SQL,update的反向仍是update,insert的反向是delete,delete的反向是insert。最后执行反向SQL,把数据恢复为一阶段之前的状态。
这套反向补偿的完整流程,业务侧完全无感。业务代码不需要写任何恢复逻辑,这也是AT最吸引人的地方。但反向SQL的生成依赖主键,所以AT强制要求业务表必须有主键。对没有主键的表,AT直接不支持,启动时就会报错。还有一点要记住:反向SQL是整行恢复的,如果业务表字段非常多,镜像数据量和恢复成本都会升高,这属于AT模式的固有成本。
4.3 全局锁冲突与脏读问题
AT模式在数据一致性上做了一个权衡:它不解决脏读,只解决脏写。原因是分布式事务在很多场景下得到最终一致即可,读侧允许读到未提交的中间状态,但写侧必须严格串行。这个定位在选型时要想清楚,如果你的业务对读一致性要求极高,AT模式可能不适合。
Seata通过全局锁来约束写。每个全局事务在修改某一行记录之前,会先在lock_table里登记这行的全局锁。其他全局事务要改同一行,发现锁被占,只能等待。默认的锁重试是30次,每次间隔10毫秒,超过就抛“Global lock acquire timeout”。这个机制保证了不同全局事务之间不会出现脏写,但也带来了明显的性能问题:同一行记录在同一时间段只能被一个全局事务处理,热点数据并发越高,等待越长。
实际调优时,可以调大锁重试次数和间隔,比如把client.rm.lock.retry-times改成60,retry-interval改成100。但这只是缓解,不是根治。真正高并发写入热点,应该从业务上分流或者换方案。我用过一个粗暴有效的办法:热点扣减操作改成队列串行化,把并发从数据库层挪到应用层,AT只负责非热点链路的兜底,效果比单纯调锁参数好得多。
5. 完整案例:Dubbo三服务链路改造实录
5.1 案例场景设计
用三个服务模拟一条典型的电商下单链路:order-service负责创建订单,inventory-service负责扣减库存,account-service负责扣减余额。三个服务各自独立数据库,通过Dubbo互相调用。下单时要完成三步操作:本地插入订单、远程扣库存、远程扣余额。任意一步失败,全局回滚,订单记录删除,库存和余额恢复。
参与方是三个独立的Spring Boot工程,共用一个Nacos,各自数据库里都需要建undo_log表。这里的表结构不复杂,核心是演示AT模式下三个服务之间的事务边界如何串联。实际项目里表字段会比这多得多,但改造逻辑是一样的。
5.2 订单服务改造:入口方法的改动
订单服务是全局事务的发起方,也是TM所在的位置。核心方法如下:
@Service public class OrderServiceImpl implements OrderService { @org.apache.dubbo.config.annotation.Service(version = "1.0.0") @GlobalTransactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class) public Order createOrder(OrderRequest request) { Order order = new Order(); order.setUserId(request.getUserId()); order.setProductId(request.getProductId()); order.setAmount(request.getAmount()); orderMapper.insert(order); // 远程扣减库存 inventoryService.deduct(request.getProductId(), request.getCount()); // 远程扣减余额 accountService.deduct(request.getUserId(), request.getAmount()); return order; } @org.apache.dubbo.config.annotation.Reference(version = "1.0.0", timeout = 5000) private InventoryService inventoryService; @org.apache.dubbo.config.annotation.Reference(version = "1.0.0", timeout = 5000) private AccountService accountService; }这里有一个我自己踩过的坑:Dubbo的@Service注解和Spring的@Service注解完全同名,如果import写错,Dubbo服务根本不会注册到注册中心,调用方直接报No provider。示例里org.apache.dubbo.config.annotation.Service对应Dubbo 2.7的注解,如果是Dubbo 3.x则用@DubboService。两套注解混用时特别注意。
下单流程中,orderMapper.insert是本地事务操作,库存、账户是远程Dubbo调用。全局事务注解放在最外层,下面再套Spring的@Transactional也没问题,但一定要保证全局注解在外层。两个@Reference注入的远程服务,timeout建议给到5秒以上,因为全局事务的锁等待时间本身就可能超过默认的1秒RPC超时,这点在调整锁参数时尤其要注意。
5.3 库存服务与账户服务的配套改造
库存服务和账户服务都属于RM,也就是资源管理器。它们本身不发起全局事务,只需要保证业务SQL能被Seata代理拦截,所以改造点集中在数据源代理和配置上,业务方法本身基本不动。
库存服务:
@Service public class InventoryServiceImpl implements InventoryService { @org.apache.dubbo.config.annotation.Service(version = "1.0.0", timeout = 5000) public void deduct(Long productId, Integer count) { int affected = inventoryMapper.deduct(productId, count); if (affected == 0) { throw new BusinessException("库存不足"); } } }账户服务:
@Service public class AccountServiceImpl implements AccountService { @org.apache.dubbo.config.annotation.Service(version = "1.0.0", timeout = 5000) public void deduct(Long userId, BigDecimal amount) { int affected = accountMapper.deduct(userId, amount); if (affected == 0) { throw new BusinessException("余额不足"); } } }两个服务里同样要配好application.yml里的seata配置,以及数据源代理。库存Mapper和账户Mapper都是普通的update语句:
UPDATE inventory SET stock = stock - #{count} WHERE product_id = #{productId} AND stock >= #{count} UPDATE account SET balance = balance - #{amount} WHERE user_id = #{userId} AND balance >= #{amount}这两个update加入了库存或余额充足的条件,返回affected = 0就说明不够,主动抛异常触发全局回滚。这是很常见的防超卖写法,也是AT模式下回滚能正常工作的前提。一定要让异常传播出来,别在catch里吞掉。我在一些项目里看到过catch(Exception e)之后只打日志不抛异常的情况,结果全局事务永远不触发回滚,账照样错。异常不往外抛,Seata再强大也感知不到。
5.4 验证全局事务生效的两种方式
部署启动三个服务后,用两个方法验证。第一种是看日志。订单服务发起全局事务时,日志会出现“Begin new global transaction [xid:xxx]”。库存、账户服务执行分支事务时,会出现分支事务注册成功的日志。如果只有下游日志、没有全局事务ID,说明XID没有透传,检查seata-dubbo依赖和Filter是否生效。
第二种是做注入异常。在账户服务的deduct方法里临时加一行if(true) throw new RuntimeException("测试回滚"),然后调用下单接口。正常情况下,订单记录会被回滚删除,库存和余额保持不变。再去库里查订单表,发现下单失败后没有脏数据,链路就是通的。测试完把异常代码删掉。
这个注入测试我建议每个服务都要做一遍,特别是第一次接入AT时。它能同时验证数据源代理、undo_log、XID透传、二阶段回滚四件事,比单纯看日志可信得多。我见过太多团队只看日志里出现了XID就认为配置成功了,结果注入异常测试一跑,下游分支根本没回滚,问题一大堆。
6. 问题排查与避坑清单
6.1 高频问题速查表
根据实际踩坑经验,我把高频问题整理成一张速查表,遇到问题先对号入座:
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 日志一直提示no available server to connect | 事务分组映射错了,或Server没注册到对应cluster | 检查vgroup-mapping与Server的cluster/namespace/group |
| 分支事务执行成功但全局不感知 | seata-dubbo依赖缺失,XID没透传 | 引入seata-dubbo,检查Dubbo Filter是否加载 |
| 下单失败后数据没回滚 | @GlobalTransactional里的rollbackFor问题,或异常被catch吞掉 | 检查rollbackFor和异常传播 |
| 分支注册报分支异常 | Server与client版本不一致 | 统一两端版本 |
| 提示undo_log表不存在 | 业务库没建undo_log | 每个业务库建一份undo_log |
| 提示Global lock acquire timeout | 热点行并发冲突,或锁等待时间太短 | 调整锁重试参数,热点场景重新评估方案 |
| 二阶段回滚时报UndoLogException | 数据被其他事务改写,后镜像比对不通过 | 分析脏写来源,检查事务边界设计 |
| 数据源代理无效,镜像SQL没有出现 | 自动代理失效,或手动代理被覆盖 | 手动包DataSourceProxy,并确保@Primary |
这只是一个速查索引,遇到具体问题还是要把异常堆栈和日志完整保留下来再排查。下面展开讲三个我实际处理过且最容易复发的坑。
6.2 坑一:XID透传失效导致下游没回滚
某项目接入Seata后,下单链路中库存扣减成功,但订单服务在账户扣减时抛了异常。全局回滚时,订单数据成功删除了,库存却恢复不了,数据库里库存已经是扣减后的值。从现象看,库存服务的分支事务完全没有参与回滚。
排查过程分两步。第一步看库存服务日志,发现account-service的异常根本没有传递到inventory-service的全局事务上下文里。第二步看库存服务的MyBatis执行日志,发现只有一条update语句,没有出现任何镜像查询SQL。结合这两个现象基本能判断:业务代码虽然执行了,但Seata的RM没有介入。
进一步查看依赖树,发现工程里只有seata-spring-boot-starter,没有seata-dubbo。查询源码后确认,没有seata-dubbo包就不会自动注册XID透传的Dubbo Filter。加上依赖后,库存服务日志开始出现分支事务注册成功的记录,再跑注入异常测试,库存正常回滚。这类问题的排查思路就是先问两句话:有没有走代理?有没有拿XID?两个前提缺一个,AT就会退化成普通本地事务。
6.3 坑二:版本不一致导致分支注册失败
Seata Server用了1.6.1,客户端starter用了1.7.0,看起来只是小版本差异,实际上双方通信使用的协议和部分消息字段有差异。现象是全局事务一发起,下游执行完业务SQL后,注册分支时报“Can not register branch because of config error”。这个错误容易误导人,以为配置中心没配好,实际上就是协议版本不匹配。
混用版本还会出现另一种隐性现象:下游分支事务注册成功了,但一阶段等待提交时超时,全局事务被标记为失败。原因是不同版本的客户端和服务端对分支状态上报的通道不同,部分场景下状态没有及时同步。这个坑的解法很朴素:把Server和Client版本严格对齐,升级的时候也要两端一起升,别只升一端。
6.4 坑三:数据源代理失效,回滚时干瞪眼
另一个项目内部自定义了一个切面,会在Service方法执行前后对数据库连接做一些统计,用ThreadLocal把Connection缓存起来。接入Seata后,这个自定义切面拿到的Connection是原始的,绕过代理直接执行SQL。结果是业务数据确实写入了,但Seata完全没感知,更不会写undo_log。回滚时找不到任何镜像,事务直接失败。
这个问题的本质是线程内Connection被提前获取并复用,导致数据源代理只代理了入口,实际执行SQL的Connection绕过了代理。解决办法是避免在Seata代理之外缓存Connection,所有获取连接的动作都必须经过代理对象。
排查时最典型的现象是:分支事务日志里没有开始前镜像查询的日志,也没有undo_log插入记录,只有业务SQL本身。这种问题光看业务代码不一定看得出来,最好的做法是在开发环境用debug日志或断点确认每次获取连接都经过DataSourceProxy。
7. 一点个人体会
把这套东西完整跑通之后,我最大的体会是:AT模式真正的难点不在原理,而在配置链路和边界意识。配置链路涉及Seata Server、注册中心、事务分组、数据源代理、XID透传五个环节,任何一个环节悄悄失效,表面都不报错,直到业务出问题才会暴露。所以每次改动配置或升级版本,都一定要重复做注入回滚测试,这是成本最低的保障。
另外,不要被“自动”两个字迷惑。AT模式自动完成的是镜像和补偿,但事务边界、隔离级别、热点处理这些设计决策还是要人来定。把AT当作普通本地事务的替代品,到处乱加注解,只会换来更多锁冲突和脏数据。先评估业务,再选方案,最后才是动手集成。这套流程走下来,你的分布式事务才真正落得了地。