Seata AT模式分布式事务实战:基于Dubbo微服务的低侵入方案

📅 发布时间:2026/10/14 17:32:14
Seata AT模式分布式事务实战:基于Dubbo微服务的低侵入方案
先说结论AT模式是目前接入成本最低、对业务代码侵入最小的一种分布式事务方案如果你们团队正在用Dubbo做微服务拆分又不想把现有代码改造得面目全非那Seata的AT模式基本就是首选。这篇内容来自我前段时间帮一个熟人团队处理的真实问题。他们做的项目某电商项目X典型的订单-库存分离架构订单服务和库存服务通过Dubbo接口互相调用平时单机跑得好好的一到活动大促就出事库存扣了订单没生成订单建了库存没扣对账的时候对出一堆脏数据。我介入的时候他们代码里已经堆了三四种补偿逻辑连事务都谈不上纯粹靠定时任务扫数据补救修一个漏一个。最后我们选了Seata的AT模式来统一收口整个过程踩了不少坑有些问题官方文档里写得很含蓄实际跑起来才知道怎么回事。这篇文章把我落地的完整过程、配置思路、踩坑记录都整理出来给同样被分布式事务折磨的朋友一个参考。1. 项目背景与AT模式的选型逻辑1.1 分布式事务事故现场真实的数据不一致场景先说他们当时的痛点。这个项目规模不大订单服务、库存服务、账户服务一共六个微服务Dubbo做RPC调用注册中心用的Nacos数据库还是最普通的MySQL单库唯一做的就是按业务拆了服务数据库没拆。听起来很简单但问题恰恰出在简单的跨服务调用上。我当时看到了三处典型的分布式事务脏数据第一处下单接口。用户下单时订单服务先写订单表然后调用库存服务的DeductStock接口扣库存再调用账户服务的DeductBalance接口扣余额。如果扣库存成功扣余额超时了Dubbo默认会抛异常此时订单已经写进去了库存也扣了但用户看到的是下单失败钱也没扣库存却少了。这种问题最恶心用户投诉、补单、人工核对全都在这个环节上浪费时间。第二处订单超时自动关单。定时任务扫描超时未支付订单调用库存服务回补库存。结果回补接口超时定时任务重跑又执行了一次回补库存变成负数或者回补了两次账目对不上。第三处活动库存预扣。运营后台一次性导入大量活动库存同时映射到多个商品ID分多批写入。中途某一批失败前一批已经提交了后一批没进去数据处于一半新一半旧的状态。这些场景的共性是都在跨服务调用之后有一部分参与方的数据已经提交了其余参与方要么超时要么异常要么网络抖动导致结果不确定。分布式事务要解决的就是这样一种“事务的参与方不在同一个进程、不在同一个数据库连接、甚至不在同一台机器上”的情况。很多人对分布式事务有个误解以为它是为了追求更高性能或者更复杂的架构才引入的东西。其实恰恰相反分布式事务是很多系统在业务走到一定复杂度之后被逼出来的一个逃不过的去处。你可以在前期靠业务补偿、对账、重试来兜底但这些本质上都是事后补救。你在凌晨两点爬起来核对订单库存数据的时候就会发自内心地认识到总是靠事后校准人迟早会崩。1.2 AT模式的核心执行路径一阶段是不是真“快”Seata的AT模式全称是Automatic Transaction自动事务。它的核心思路是不要求你在业务代码里显式写回滚逻辑而是通过拦截SQL和生成反向SQL的方式在二阶段提交失败时自动把数据恢复回来。整个执行路径可以这样理解全局事务开始时用户代码里开启了一个全局事务IDXID的上下文。Dubbo调用发起时Seata会把XID通过隐式传参的方式带给下游服务。下游服务一旦感知到XID就知道自己处于一个全局事务中。接下来每个本地事务执行时Seata会做两件事。第一解析业务SQL生成一条镜像数据。说白了就是执行SQL之前先把这条数据当前的样子查出来存到undo_log表里。比如你要执行UPDATE inventory SET stock stock - 1 WHERE product_id 100Seata会先执行一次SELECT把这个商品当前的库存值、版本号等字段值记录下来。第二执行业务SQL生成后镜像。SQL执行之后Seata再查一次这条数据的现貌和之前的前镜像一起存进undo_log表的两个字段里。前镜像和后镜像的作用是什么前镜像是为了二阶段回滚用的。如果全局事务最终要回滚Seata就把前镜像的数据直接UPDATE回去让数据恢复到执行SQL之前的样子。后镜像则是用于冲突检测。如果另外一个事务在Seata提交之前把这条数据改了后镜像就会和当前数据对不上这时Seata就知道发生了脏写会尝试重试加锁或者抛出异常避免覆盖别人的修改。一阶段的过程说复杂也复杂说简单也简单无非是多了一次查询、一次写入undo_log业务SQL本身在本地事务里正常执行。整个过程是相对轻量的。不少文章喜欢讲“一阶段快、二阶段慢”这个说法背后真正的区别在于一阶段只需要在本地数据库事务里操作不涉及协调者、参与者的网络通信所以整体延迟几乎都落在业务SQL本身而二阶段则要把所有分支事务的结果汇总给TC事务协调者做决策如果分支多、网络差二阶段的通信开销就会凸显出来。我们当时的压测结果也很说明问题单链路下单场景引入Seata前后接口P99延迟只多了大约6到8毫秒来源主要是镜像查询和undo_log写入。这个代价对绝大多数业务来说完全可接受。后面我会详细讲这部分。1.3 为什么是AT模式而不是TCC、消息队列或最终一致性方案说到分布式事务方案其实不止一种。常见的对比维度主要有四个性能损耗、代码侵入、实现复杂度、对底层设施的依赖。先看TCC。TCC要求业务方提供Try、Confirm、Cancel三个方法比如扣库存你需要写预扣库存、确认扣库存、取消扣库存三套逻辑。这套方案性能最好锁粒度可以做到最小但它对业务的改造巨大每个参与事务的服务都要自己维护事务状态、处理幂等、设计悬挂等一系列问题。我们那个团队本身就是想减少补偿代码结果引入TCC等于要重新写一遍业务逻辑直接不干。再看消息事务和本地消息表。这些方案的思路是把事务的重点从“多个服务同时提交”转移到“核心服务先提交其他操作异步跟进”。比如下单时先写订单表然后发一条MQ消息给库存服务库存服务消费消息后扣库存。如果扣库存失败再发补偿消息重试。这套方案很实用很多大厂都在用但它有一个适用范围它只能保证最终一致性不能保证强一致性。对于下单这种对一致性要求高的场景尤其是用户可能紧接着查询订单和库存数量的时候最终一致往往意味着短暂的数据错乱窗口期体验上还是不太行的。Seata的AT模式相对于这两者最大的优势就是它在应用层看起来就像本地事务一样。业务代码里只需要在一个方法上加上GlobalTransactional注解方法内部调用各个服务不需要改各服务的本地事务逻辑也不需要写补偿代码。全局事务期间所有参与方的数据对全局可见提交或回滚是统一的最终结果一致性强度介于本地事务和最终一致性之间但对业务代码的侵入是最低的。我们当时做过一个粗略的计算如果要靠TCC改造这套系统涉及订单、库存、账户三个服务十几个接口每接口都要重写难说需要几周的工作量。而用Seata AT模式基本上一周内可以全部接完这就是选择AT模式的决定性理由。2. 项目整体设计与环境准备2.1 服务拆分和调用链路规划接入任何分布式事务方案之前强烈建议先把当前的调用链路画清楚。我们当时梳理出的核心链路有三条主链路是下单。下单接口首先由订单服务创建订单然后Dubbo调用库存服务的DeductStock扣减库存再调用账户服务的DeductBalance扣减余额。这三步是强一致的必须在同一个全局事务里。第二条链路是关单。延迟队列触发关单后订单服务更新订单状态为超时关闭同时Dubbo调用库存服务RestoreStock回补库存。这也是强一致链路。第三条链路相对简单是后台活动库存导入。运营一次上传多个SKU的库存值服务端逐条更新。这个更新过程涉及多个数据库表的修改也是跨服务调用的。这三条链路分别对应三个全局事务。我们规定好了每个全局事务的发起方是入口服务也就是订单服务。库存服务和账户服务只作为参与方它们不需要定义全局事务边界只需要感知XID的存在正常执行自己的本地事务即可。这个角色划分很重要如果每个服务都各自开启全局事务那全局事务就是嵌套的容易造成事务边界混乱。2.2 版本选型与依赖引入细节版本选型是第一步容易踩坑的地方。我们当时环境情况是Spring Boot 2.7.xSpring Cloud Alibaba 2021.xDubbo 3.0.9Nacos 2.2.3作为注册和配置中心数据库MySQL 8.0。Seata选的是1.8.0版本这个版本对应Spring Cloud Alibaba的版本适配关系是你需要在pom里同时引入seata-spring-boot-starter和seata-dubbo这两个依赖。有一点要注意如果只引入seata-spring-boot-starterDubbo的隐式传参不会被自动处理XID传递会失效。网上很多教程喜欢直接用spring-cloud-starter-alibaba-seata这个依赖在Spring Cloud Alibaba 2021版本里其实已经不再强制推荐了更建议直接使用官方Seata的starter配合手动引入Dubbo支持包这样版本更可控。我们项目的依赖清单大致如下dependency groupIdcom.alibaba.nacos/groupId artifactIdnacos-client/artifactId version2.2.3/version /dependency dependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId version1.8.0/version /dependency dependency groupIdio.seata/groupId artifactIdseata-dubbo/artifactId version1.8.0/version /dependency dependency groupIdorg.apache.dubbo/groupId artifactIddubbo/artifactId version3.0.9/version /dependency这里有个关键点seata-dubbo这个依赖包官方文档的位置藏得很深如果不去查Seata官方仓库的集成文档很多人根本不知道要引入它。如果不引入这个包最典型的症状就是本地两边单独调试都能通但通过Dubbo调用的时候XID传递不过去全局事务完全失效。我在下文排查章节会详细说明这个现象。2.3 Seata服务端部署模式选择与配置Seata 1.8.0支持多种服务端部署模式包括单机文件模式、DB模式、集群模式。我们用DB模式也就是把全局事务会话信息、锁信息持久化到数据库而不是默认写到文件里。单机文件模式简单但Seata重启后事务记录可能丢失而且不支持多节点高可用一旦TC挂了所有全局事务都会卡住。DB模式配合集群部署才是生产可用的形态。Seata服务端的配置分为两个层面registry.conf和config.txt。registry.conf主要定义注册中心和配置中心的对接方式config.txt则是Seata内部的各类参数比如事务日志存储方式、锁超时时间、客户端配置等。我们的registry.conf里注册中心和配置中心均配置为nacosregistry { type nacos nacos { application seata-server serverAddr 127.0.0.1:8848 namespace group SEATA_GROUP } } config { type nacos nacos { serverAddr 127.0.0.1:8848 namespace group SEATA_GROUP } }这里有一个非常经典的坑namespace如果是空的Seata默认是空字符串但Nacos控制台显示是public。这个就不要纠结了网上大部分教程说填public实际上Seata读取的是空字符串。你填public反而对不上。凡是遇到服务端连不上Nacos先检查这个配置。服务端的store配置我们用的是db模式store.mode db store.db.datasource druid store.db.dbType mysql store.db.url jdbc:mysql://127.0.0.1:3306/seata_server?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai store.db.user seata store.db.password seata_pass store.db.initialSize 5 store.db.maxActive 20这个seata_server库只需要建两张表global_table和branch_table分别是全局事务表、分支事务表。官方SQL文件在Seata服务端解压目录的script/server/db目录下。我们建表的过程没有加任何索引之外的额外配置跑了一段时间之后唯一遇到的问题是高并发场景下branch_table的status字段经常被锁后面加了一个查询频率较高的索引才解决。2.4 业务数据库与undo_log表准备这是AT模式引入时最容易被忽视的一步。AT模式要求每个业务数据库都要有一张undo_log表这张表是Seata做事务回滚的核心依赖。为什么每个数据库都要有因为AT模式的回滚原理是执行反向SQL更新数据。假设库存服务返回了一个失败这时订单服务已经提交了自己的本地事务订单数据已经写进数据库了。如果order库没有undo_logSeata就不知道订单数据之前长什么样子也就不知道该用什么SQL把订单恢复原状。官方建表语句在不同版本间有些差异。1.8.0版本建议使用的建表语句如下CREATE TABLE IF NOT EXISTS undo_log ( id BIGINT NOT NULL AUTO_INCREMENT, branch_id BIGINT NOT NULL, xid VARCHAR(128) NOT NULL, context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, log_status INT 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;注意几个细节。第一xid和branch_id上加了唯一索引这是为了在极端并发下避免同一个分支事务重复写undo_log。第二rollback_info字段用的是LONGBLOB因为要把多个SQL的前后镜像JSON序列化后存进去普通TEXT长度不够。第三log_status字段标记了这条undo_log的状态0表示有效1表示已被清理。我们踩过一个很实在的坑库存服务的数据库是独立的一个实例建表的时候用了一个旧版本的建表语句里面rollback_info是VARCHAR(2000)。跑了一段时间后发现回滚信息稍微长一点就存不进去全局事务静默失败回滚不生效。排查了半天才发现是这个字段类型的问题。所以建表尽量以官方文档为准不要东拼西凑从旧文章里复制。3. 核心细节解析与实操要点3.1 客户端侧核心配置客户端的配置总让我想起早期Spring项目的繁琐配置期但本身其实没有太多复杂度。关键在于每个服务都要有自己的file.conf和registry.conf。不过现在的Seata版本已经支持把这些配置全部迁到Nacos里通过配置中心统一管理省掉了每个服务本地维护配置的麻烦。我们当时为了降低复杂度还是选择在本地放file.conf然后通过registry.conf指向Nacos获取其他公共配置。file.conf的关键配置如下transport.type TCP transport.server NIO transport.heartbeat true transport.enableTmClientBatchSendRequest false transport.enableRmClientBatchSendRequest true service.disableGlobalTransaction false service.enableDegrade false client.rm.asyncCommitBufferLimit 10000 client.rm.lock.retryInterval 10 client.rm.lock.retryTimes 30 client.rm.reportRetryCount 5 client.rm.tableMetaCheckEnable false client.rm.tableMetaCheckerInterval 60000 client.tm.commitRetryCount 5 client.tm.rollbackRetryCount 5 client.undo.onlyCareUpdateColumns true client.undo.logSerialization jackson client.undo.logTable undo_log client.undo.compress.enable true client.undo.compress.type zip这些配置里有四个我实际运行后发现特别关键的client.rm.lock.retryInterval和client.rm.lock.retryTimes。这两个参数控制的是分支事务获取全局锁时的重试次数和间隔。我们的业务场景是下单高频操作同一件商品可能被很多用户同时抢购如果没有设置足够的重试次数秒杀场景下很容易撞到锁冲突随即报数据资源冲突的异常。默认值其实还好但我们调大了retryTimes到30实际效果是冲突率明显下降。client.rm.tableMetaCheckEnable。这个默认是true意思是在启动时检查你业务表的结构元数据用于生成前后镜像。如果业务表很多这个检查会消耗一定时间。我们项目里表不是很多这个开关影响不大但如果你们的服务数据库里宽表、大表非常多可以关掉它让元数据在第一次访问某个表时才加载。client.undo.onlyCareUpdateColumns。默认值false表示二阶段回滚时会对比整行数据但如果表有大量大字段序列化开销会大。我们把它设成true只关心变更过的列回滚效率有提升但也带来一个前提所有UPDATE语句必须显式更新到业务需要的字段不能依赖数据库默认值否则那个字段就不会被记录在undo日志里。这块在文档里写得很隐晦建议谨慎调整。client.undo.logSerialization用jackson而不是默认的protostuff主要是考虑到后续如果要排查undo_log表里的内容jackson序列化出来的JSON可以直观看懂定位问题会方便很多。生产环境其实两者差异不大这点按团队习惯来就行。3.2 GlobalTransactional注解与事务边界设计全局事务的边界用一个注解就搞定在入口方法上加上GlobalTransactional。这个注解最终会通过Spring AOP切到TM事务管理器的逻辑上TM负责向TC注册全局事务拿到XID然后事务结束时发起全局提交或回滚。我们下单接口的写法大致如下GlobalTransactional(timeoutMills 30000, name create-order-tx) public OrderResult createOrder(CreateOrderRequest request) { Order order createOrderRecord(request); stockService.deductStock(request.getSkuId(), request.getQuantity()); accountService.deductBalance(request.getUserId(), order.getPayAmount()); return OrderResult.success(order); }重点讲两个设计原则。第一个原则GlobalTransactional只能放在入口不能放在每个服务方法上。如果库存服务的deductStock方法上也加了GlobalTransactional那就变成了全局事务嵌套全局事务Seata官方明确不支持这种用法最终行为会很令人困惑。我们后面排查过一次某个同学在参与方服务上加了注解导致XID被覆盖回滚逻辑找错了分支。第二个原则全局事务的超时时间设置。timeoutMills默认是60000毫秒但我们下单链路里有Dubbo调用最慢的可能是下游数据库锁等待的时间所以我们把这个值设为30000不要设太大。如果设置太长你在开发环境很难验证超时到底会不会生效一不小心就掩盖了网络问题。另外要提醒的一个点是GlobalTransactional注解与Spring的Transactional注解同时使用时本地事务是默认行为。Seata不会覆盖本地事务全局事务本身依赖每个分支的本地事务先提交或回滚二阶段再来统一决策。因此参与方服务的方法上应该仍然保留Transactional别把本地事务去掉否则会出很多意外。3.3 Dubbo调用链中的XID隐式传参机制很多人在接入时都会有一个疑问Seata是怎么知道Dubbo的每次调用都要带XID的如果链路里既有Dubbo调用又有HTTP调用呢先说Dubbo部分。Seata针对Dubbo的Filter机制做了一套根因传递实现在Dubbo的provider端会注册一个SeataDubboProviderFilterconsumer端会注册一个SeataDubboConsumerFilter。consumer发起调用前从当前线程上下文拿XID通过Dubbo的隐式参数attachment塞进本次调用的RPC context里。provider收到请求时再从attachment里把XID取出来设置到当前线程的RootContext中。整个过程中业务代码完全无感知。这也就是我强调必须引入seata-dubbo依赖的原因没有这个依赖上面说的两个Filter根本不会被注册XID在实际Dubbo调用中就是传不过去的。但如果链路里存在HTTP调用比如订单服务通过HTTP调用库存服务那就不能直接靠Filter机制了。Seata官方提供了SEATA拦截器的方式通过Servlet过滤器从HTTP头里读取XID然后在服务端恢复上下文。如果你用的是Spring Cloud OpenFeignSeata同样提供了feign的集成支持。我们项目里很多内部服务还是用Dubbo所以这一块没有直接用到但如果你们存在混用的情况务必在HTTP调用里手动传递XID到请求头header不然断链是必然的。另外提醒一个细节XID本身是字符串但它在方向上的传播是单向的。也就是说只有从全局事务发起方到参与方这个方向才会传递参与方再把XID传给更下游的服务也是可以的因为内部也遵循同样的机制。但反向的返回值里不会携带XID这个属于正常行为。3.4 数据源代理与连接池参数调整AT模式的另外一个关键实现是数据源代理。Seata通过DataSourceProxy包装你的业务数据源让所有业务SQL在真正执行之前都会被Seata的内部逻辑拦截。你可以理解为原来的数据源是裸的业务代码拿到的connection直接执行SQLSeata则在中间层插了一个代理每次执行SQL前后自动完成镜像查询、undo_log写入等操作。使用Seata的spring-boot-starter时数据源代理通常会自动配置不需要手动声明DataSourceProxy。但如果你在项目里自定义了数据源比如多数据源或者使用Druid手动创建就必须手动包装一次Bean public DataSource dataSource() { DruidDataSource druidDataSource new DruidDataSource(); druidDataSource.setUrl(jdbcUrl); druidDataSource.setUsername(username); druidDataSource.setPassword(password); return new DataSourceProxy(druidDataSource); }这里有个很重要的连接池考量。Seata的镜像查询和undo_log写入都发生在业务SQL的同一个本地事务里因此连接池必须保留足够多的连接。我们当时把Druid的最大连接数从20调到50原因是高并发下单场景下每个全局事务在本地事务阶段可能持有两个连接——一个执行业务SQL一个执行undo_log写入虽然实际上不是同时持有但连接不够时等待获取连接的时间会显著上升而等待本身又会加重全局锁冲突的概率。关于连接池还有一个容易被忽视的点连接池的初始化连接超时时间不要设置得太小。因为Seata在启动时会检查数据源的元数据默认会执行一些系统表的查询如果连接数不够或者超时时间太小启动过程可能报“get connection timeout”被归类到奇怪的错误堆栈里。第一次遇到的时候排查了很久才发现是连接池参数问题。4. 实操过程与核心环节实现4.1 完整业务改造下单场景的AT事务落地全过程接下来是整套流程在真实业务中的落地记录。整个过程大致可以分为环境确认、代码改造、联调验证、压测调参四个阶段。第一阶段先做基础环境确认。首先启动Nacos确保Seata服务端能正常注册。接着在MySQL中分别给订单库、库存库、账户库建好undo_log表。最后启动Seata服务端注册成功后在Nacos服务列表里能看到seata-server这一步就算过了。第二阶段代码改造。以订单服务为例改动主要集中在三个方面引入依赖配置file.conf在入口方法上添加GlobalTransactional注解。参与方库存服务和账户服务只需要引入依赖和配置file.conf不需要加注解。第三阶段联调验证。这一阶段最关键如果链路通了立刻能看到一个现象在下单接口的日志里出现了全局事务XID。XID的格式一般是ip:port:事务ID比如192.168.1.20:8091:3245203498。看到这个XID才能说明全局事务确实开始了。第四阶段压测和调参。我们当时用了一个简单的并发脚本模拟用户下单观察Seata服务端的监控和数据库里的undo_log增长情况。结合我们的实际案例下面给出一条完整的可复现路径。先看下单服务的核心代码。订单服务先创建订单Service public class OrderService { DubboReference private StockService stockService; DubboReference private AccountService accountService; GlobalTransactional(timeoutMills 30000, name create-order-tx) public void createOrder(OrderCreateRequest request) { // 1. 生成订单 orderDao.insert(buildOrder(request)); // 2. 调用库存服务扣库存 stockService.deductStock(request.getSkuId(), request.getQuantity()); // 3. 调用账户服务扣余额 accountService.deductBalance(request.getUserId(), request.getPayAmount()); } }库存服务的DeductStock实现Service public class StockServiceImpl implements StockService { Transactional Override public void deductStock(Long skuId, Integer quantity) { Stock stock stockDao.selectBySkuIdForUpdate(skuId); if (stock.getStock() quantity) { throw new BizException(库存不足); } stockDao.updateStock(skuId, stock.getStock() - quantity); } }账户服务的DeductBalance实现Service public class AccountServiceImpl implements AccountService { Transactional Override public void deductBalance(Long userId, BigDecimal amount) { Account account accountDao.selectByUserIdForUpdate(userId); if (account.getBalance().compareTo(amount) 0) { throw new BizException(余额不足); } accountDao.updateBalance(userId, account.getBalance().subtract(amount)); } }这里说明一下为什么库存服务和账户服务的方法上都加了Transactional。这是必须的因为Seata的二阶段整体回滚依赖每个本地事务已经执行完毕且提交成功。如果参与方不开启本地事务SQL的提交行为就不受控制二阶段决策时数据可能处于半提交状态回滚逻辑没法保证。4.2 关键执行过程拆解undo_log是怎么生成的用下单接口为例走一遍真实的执行过程。第一步用户请求到达订单服务的createOrder方法。GlobalTransactional被AOP拦截TM向TC发起全局事务注册请求拿到XID。此时的XID还没随Dubbo调用传到下游只是存在于订单服务当前线程的RootContext中。第二步执行订单服务里的orderDao.insert。在Seata的本地代理数据源下这条INSERT同样走了一遍捕捉流程Seata根据SQL语句解析出需要操作的表和主键然后查询出当前表结构信息把执行前状态记录到一个undo快照中。INSERT的前镜像是空的后镜像是新插入的行数据。这里有一个细节INSERT操作在undo_log里记录的其实是一条DELETE语句反向SQL。你回头看undo_log表时会发现rollback_info字段里保存的是一长串JSON里面包含一个undoLog列表每个log里记录了sqlTypeINSERT/UPDATE/DELETE、tableName、primaryKey、beforeImage、afterImage。Seata正是基于这些信息在回滚时生成逆操作的SQL。第三步orderDao.insert本地事务提交。此时订单数据已经是持久化状态但全局事务尚未完成。第四步stockService.deductStock开始执行。由于SeataDubboProviderFilter的作用XID已经从Dubbo调用上下文中被取出并放入库存服务当前线程的RootContext。库存服务执行UPDATE语句时同样生成一条undo_log记录该商品的库存旧值10和新值9。这条UPDATE的本地事务提交后库存数据也处于持久化状态。第五步假设accountService.deductBalance抛出了一个余额不足异常。这个异常最终会被订单服务的GlobalTransactional的AOP拦截到TM向TC发起全局回滚请求。第六步TC根据全局事务ID要求所有参与方执行二阶段回滚。库存服务收到回滚请求后读取自己的undo_log根据beforeImage生成反向SQLUPDATE inventory SET stock 10 WHERE id 100;执行完反向SQL之后库存数据恢复到事务开启前的10。订单服务的undo_log也会触发类似的处理。整个分布式事务最终状态就是订单表没有插入、库存表恢复到10、账户表没有变化——所有参与者回到了事务开始前的一致状态。整个链路在日志里会显示类似下面的信息2025-XX-XX 10:00:01.123 {} [Dubbo server handler] INFO RootContext - xid: 192.168.1.20:8091:123456789 2025-XX-XX 10:00:01.456 {} [Dubbo server handler] INFO RootContext - bind(192.168.1.20:8091:123456789) 2025-XX-XX 10:00:01.789 {} [business] INFO undo log - undo log create, branchId: 654321, xid: 192.168.1.20:8091:123456789看到这样的日志可以确认XID绑定成功undo_log也已经正常写入了。如果全程没有出现过任何RPC异常同时TC上也没有全局回滚请求那基本可以判定这次调用是成功情况。4.3 多分支事务并发时的全局锁机制AT模式之所以能解决跨服务的数据一致性问题除了回滚日志还有一套全局锁机制在起作用。Seata在TC端维护了一张全局锁表用于锁住那些被全局事务修改过的数据主键。当库存服务执行UPDATE时它会先向TC申请全局锁。TC检查后发现没有任何其他全局事务持有这条记录的锁就放行如果有其他全局事务正在修改同一条记录TC会拒绝当前请求客户端则按照client.rm.lock.retryInterval和retryTimes进行重试。这个锁是TC端管理的不属于任何一个业务数据库因此即使业务数据库已经提交了本地事务全局锁也可能还占着直到整个全局事务二阶段结束才释放。这个机制带来的影响很直观某一个订单事务还没结束时另一个用户想一起抢购同一个商品后者的分支事务会在获取全局锁时被阻塞直到前一个全局事务结束。这也是为什么前面提到要把retryTimes调大。集群规模大、并发量高时这个等待时间会感知为接口响应变慢但实际上是在保护数据逻辑的正确性。如果业务上允许可以适当用限流手段压低瞬时并发不要一味靠增加锁重试来解决。还有一种特殊情况要提醒一下如果某个参与方服务崩溃了一直没能上报分支事务的状态全局事务会一直悬挂全局锁也无法释放这块数据就会被“锁死”。解决办法是给GlobalTransactional设置合理超时以及确保参与方能及时上报分支事务状态。生产上还建议定时扫描global_table里长时间未结束的记录主动介入处理。4.4 常用SQL与数据库排查入口排查分布式事务问题最直接入口就是Seata服务端的global_table和branch_table以及业务库里的undo_log。以下几条SQL基本是日常运维必备。查询未结束的全局事务SELECT * FROM global_table WHERE status 0 ORDER BY gmt_create DESC LIMIT 50;查询某个全局事务的所有分支SELECT * FROM branch_table WHERE xid 192.168.1.20:8091:123456789;查询某个分支的undo_log确认回滚数据是否正确生成SELECT xid, branch_id, rollback_info FROM undo_log WHERE xid 192.168.1.20:8091:123456789;清理异常遗留事务DELETE FROM global_table WHERE status 1 AND gmt_create DATE_SUB(NOW(), INTERVAL 7 DAY);生产环境操作时建议先读后清确认status确实为已结束状态再清理。乱删global表记录可能导致锁信息残留反而引发更长的问题。5. 常见问题与排查技巧实录5.1 接入阶段最经典的三个报错接入阶段的报错千奇百怪但真正高频的集中在下面三种。第一个启动时报错找不到registry.conf。这个一般不是文件不存在而是classpath路径没找到。Seata的spring-boot-starter默认会从classpath根目录读取registry.conf如果你把配置文件放在src/main/resources/config或自定义目录下就必须通过spring.cloud.alibaba.seata.config.type和config.nacos等配置项指定或者把文件挪回根目录。这个报错信息会明确提示registry config load failed比较好定位。第二个启动时逻辑报错no available service found in cluster。这个报错含义是客户端连不上TC。排查看四个地方TC是否成功注册到Nacos客户端namespace和group是否和服务端一致客户端和服务端的端口是否可达registry.conf里的application名字是否与Nacos服务列表里的seata-server一致。最常见的是端口没放行或者namespace不一致导致服务发现失败。第三个服务运行时后台疯狂报错undo_log table doesnt exist。这个报错就是扁平到直白的某参与方数据库没有创建undo_log表。但有时候它并不是启动时报而是第一次执行全局事务时才报这就显得非常隐蔽。我们当时排查了一个多小时才定位到是账户服务的库漏建了表。老版本里这个表还有可能因为字符集问题导致中文内容写不进顺带把数据库默认字符集也检查一遍。5.2 全局事务失效的隐形杀手接入过程中最让人头疼的问题不是报错而是“不报错但没有事务效果”。最典型的现象下单接口正常能走但人为在支付阶段制造一个库存扣减异常全局事务仿佛不存在库存照样扣了订单也建了根本没有回滚的动静。这种情况十有八九和XID断链有关。排查方式很简单在全局事务发起方的日志里搜xid然后在参与方的日志里也搜xid。如果发起方日志有xid参与方完全没有那就是XID传播过程中断了。断链原因通常是三个第一个没有引入seata-dubbo依赖。这个问题最普遍项目依赖里只有seata-spring-boot-starterDubbo的Filter没有生效XID根本无法传到下游。第二个Dubbo版本和Seata过滤器实现不兼容。比如某些自定义的Dubbo Filter会提前消费attachment导致XID在SeataDubboProviderFilter之前被清理。如果项目里还挂了其他过滤器要注意它们的顺序。第三个参与方不在同一个Seata集群里。有些中间件实践会在测试环境起多个Seata服务端参与方各自连了不同的集群TC之间不认识对方的全局事务当然也就不会有任何关联。5.3 性能到底影响多少压测数据复盘接入Seata后不少人会担心性能问题。我们当时做了前后对比压测这里把我记录的数据分享出来但说明一下这只是我们项目的真实数据不同的机器、网络、表结构、业务逻辑都会对结果有影响。压测环境三台8C16G云主机一台部署Seata服务端另外两台部署订单、库存、账户服务。MySQL和Nacos分别独立部署。压测场景模拟100个并发用户同时下单每个请求完整走完下单、扣库存、扣余额三个步骤连续压测10分钟。压测前的P99延迟是412毫秒压测后是458毫秒增长约46毫秒吞吐量从725 TPS降到680 TPS损失约6%。这个结果的核心是我们业务的SQL本身并不复杂每个分支事务平均只涉及1到2条SQL所以镜像查询和undo_log写入带来的额外开销有限。但如果你们的业务SQL本身很重比如一次UPDATE涉及上万行那前后镜像的查询和存储开销会大得多。另外一点容易被忽略的是TC的压力。Seata服务端的全局锁表和事务会话都落在它的存储里并发量高时TC会成为一个新热点。我们用了DB模式锁表在高并发下的锁竞争是真实的。后期如果并发继续上涨建议考虑把TC做集群部署并把锁表拆分或使用更高性能的存储方式。单机TC在500 TPS以内扛得住再多就得看具体机器和网络了。5.4 配置清单与上线前自检最后整理一份配置清单照着核对基本能覆盖大部分接入死角。服务端注册中心和配置中心类型、连接地址、namespace、group是否一致store.mode是db还是file生产建议dbglobal_table和branch_table是否已创建TC服务端口是否对客户端开放客户端seata-spring-boot-starter和seata-dubbo依赖是否引入registry.conf是否在classpath根目录namespace和group是否与TC一致file.conf里client.rm.reportRetryCount、client.tm.commitRetryCount、rollbackRetryCount是否合理每个业务库是否都建了undo_log表字段类型是否正确入口服务方法是否有GlobalTransactional注解参与方服务方法是否没有加链路调用链路上是否有HTTP、消息队列等非Dubbo通道如果有XID是否手动传递参与者大事务里是否嵌套了子事务数据库连接池参数是否足够尤其是最大连接数和获取连接超时时间按这份清单走一遍大概率能避开大部分坑。我踩过的那些问题基本都能在前面几节的细节里找到对应。结尾一点经验整套方案落地到现在我最大的体会是AT模式不是银弹它没办法覆盖所有分布式事务场景但它在“RPC调用链上的强一致需求”这块场景里提供了非常少见的低侵入解法。接入之前如果你心里有这个认知后续排障会顺利很多。另外如果你们系统里还有一部分事务可以接受最终一致比如操作日志更新、消息通知发送这些建议继续用消息队列或者本地事件表去做没必要全塞进Seata。全局事务每多一个参与者二阶段决策的范围就大一圈锁竞争和协调成本都会上升。能把“强一致”和“最终一致”分开设计比试图把所有东西都塞进一个事务里要优雅得多。一个小技巧送给大家生产版本上线前可以写一个测试接口在最后一个参与方的方法里故意抛异常然后在数据库里检查所有相关表的数据是否恢复到事务开始前的状态。这个验证成本很低但对整个分布式事务链路是否真正生效的检验比任何代码Review都可靠。