Seata在SpringCloud微服务中的分布式事务实战:AT模式原理与踩坑记录

发布时间:2026/9/16 2:42:29
Seata在SpringCloud微服务中的分布式事务实战:AT模式原理与踩坑记录
先交代下背景。我在生产环境里维护过好几个用SpringCloud搭起来的服务集群最让人绷不住的就是分布式事务本地Transactional在单库单库里跑得好好的微服务一拆订单、库存、账户三个服务各管一个库一次下单操作要么全员成功要么留下一个“库存扣了但订单没生成”的烂摊子。这次要聊的Seata就是我这两年落地用得最多的分布式事务解法。提到Seata就绕不开它官网那句“致力于解决微服务架构下的分布式事务问题”我这边全部精力放在业务代码上不想自己写二阶段提交协议也不想引入消息中间件去手搓最终一致性最终选型就是Seata AT模式。这篇文章我会把Seata怎么在SpringCloud里落地、AT模式的运行原理、以及我实际踩过的几个坑全部展开说。内容偏实战适合已经把SpringCloud基础组件跑起来、正准备处理跨服务数据一致性的同学也适合做技术选型时想搞清楚Seata和其它方案差异的负责人参考。1. 微服务拆完之后分布式事务为什么这么难先别急着装Seata搞清楚问题本身长什么样后面看方案才会通透。这一章我用一个经典的下单场景来拆解也是很多Seata文档和教程都喜欢举例子的场景。1.1 一次下单操作背后藏着多少次跨服务调用假设你的系统从单体拆成了三个微服务订单服务、库存服务、账户服务分别持有自己的数据库。用户在前端点了一下“提交订单”后端实际干的事情是先往订单库里写入一条订单记录再调用库存服务扣掉一件商品的库存接着调用账户服务扣除用户余额最后把订单状态改成“已支付”。在单体时代这个流程就一个数据库连接一个Transactional方法包住任何一步失败整个事务回滚数据保持一致。微服务拆开后三个服务各自拿着独立的数据源本地事务只能保证自己库里的原子性跨库的原子性没有任何机制兜底。比如库存服务扣减成功了账户服务却因为余额不足抛了异常订单服务收到了失败响应直接返给前端“下单失败”但数据库里库存已经少了用户后续查库存发现对不上这就是所谓的分布式事务问题。很多人第一反应是那我把三个服务放到同一个数据库不就行了这种思路在业务耦合度可控的小团队确实能凑合但一旦服务拆分是为了独立部署、独立伸缩、独立团队维护把库再合并回去基本等于推翻架构设计。所以分布式事务要解决的本质问题是多个微服务各自操作各自的库怎么保证这些操作作为一个整体要么全成功、要么全失败。1.2 为什么Transactional救不了跨服务的场景Transactional属于本地事务它依赖数据库本身的事务能力作用范围限于同一个数据源。只要你的代码里出现了跨服务调用比如订单服务里先写了订单记录然后通过Feign调用库存服务扣库存后面的代码在同一个本地事务里但这个本地事务管不到库存服务那个库。我把这个问题的本质总结成一句话本地事务只能控制一个“数据源连接”跨服务意味着跨了多个数据源连接甚至跨了进程、跨了机器本地事务那套commit和rollback机制已经完全触及不到了。有些同学试图在Feign调用失败后在catch块里手动写一段反向下单的补偿SQL这种“手工补偿”在业务简单、失败路径固定时能跑通但一旦出现网络超时、响应丢失、并发叠加补偿逻辑极容易重复执行或漏执行后患无穷。1.3 一致性问题在订单、库存这种场景里尤其致命订单、库存、账户这三个子领域的数据一致性是电商系统的命脉即使最后走“回滚对账”对账窗口期内的脏数据也会直接影响库存余额展示、限流决策、风控判断。我以前接触过一个不算大的电商项目库存服务扣减成功但订单服务超时未返回用户重复提交订单导致超卖客服后台订单和库存对不上只能靠人工定时脚本去修数代价非常大。所以分布式事务不是“要不要用”的问题而是“用哪种方案”的问题。业界给出的方案很多两阶段提交协议XA、可靠消息最终一致性、TCCTry-Confirm-Cancel、SAGA、以及待会儿要重点讲的Seata AT模式。每种方案的侵入性、一致性强度、性能损耗都不一样需要按业务场景去做取舍。2. Seata的核心架构和AT模式原理Seata是阿里开源的一套分布式事务解决方案目前社区活跃度很高SpringCloud Alibaba生态里也把它作为默认的分布式事务组件。它在设计上把分布式事务拆成了三个角色事务协调者TC、事务管理器TM、资源管理器RM。理解这三个角色整个Seata的运行逻辑就清晰了一大半。2.1 三个角色分别干了什么事务协调者TC是独立的服务端进程它负责维护全局事务的状态协调各分支事务的提交和回滚事务管理器TM是业务发起方的角色通常就是你的订单服务里被GlobalTransactional注解标记的方法所在的那个服务TM负责向TC申请开启一个全局事务并且在所有分支操作结束后决定全局提交或全局回滚资源管理器RM是参与全局事务的各个微服务每个RM会管理它操作的那个数据库分支向TC注册分支事务并负责执行本地的一阶段提交和二阶段回滚。这三个角色我们可以用买东西来类比TC是收银台TM是你本人RM是各个柜台。你买东西的过程是这样的先在收银台开一个购物单TM向TC开启全局事务接着去服装柜台买衣服服装柜台的营业员在自己的账本上记账并向收银台报备“这位顾客在我这买了一单”再去食品柜台买零食零食柜台同样记账并向收银台报备。最后你回到收银台说“我要结账”收银台根据所有柜台的报备结果决定这整个购物单是全部确认成功还是通知所有柜台全部撤销。2.2 AT模式一阶段和二阶段到底做了什么Seata有AT、TCC、SAGA、XA四种模式平时项目里用得最多的就是AT模式。AT模式的“AT”是Automatic Transaction的缩写核心思路是利用数据库本地事务配合undo_log回滚日志表用拦截器解析SQL自动生成反向SQL实现分布式事务的自动回滚。业务代码里只需要在方法上加一个GlobalTransactional注解不需要像TCC那样写大量的Try/Confirm/Cancel逻辑。一阶段的具体流程是这样的业务SQL执行前Seata的RM会先解析这条SQL比如“UPDATE inventory SET stock stock - 1 WHERE id 123”把这条SQL影响的数据行镜像记录下来包括修改前快照和修改后快照然后写入当前数据库的undo_log表之后执行业务SQL并提交本地事务。注意一阶段结束时本地事务已经提交了数据已经改了但Seata会在数据库表里加上全局锁防止其它全局事务并发修改同一行数据。二阶段分两种情况。全局提交时RM收到TC的commit指令因为一阶段已经提交了本地事务二阶段其实非常轻量只需要异步删除对应分支的undo_log记录。全局回滚时RM收到TC的rollback指令会根据一阶段写入的undo_log记录生成反向补偿SQL把数据恢复到修改前的状态然后删除undo_log记录。2.3 全局锁与隔离级别的选择这里有个关键点很容易被忽略AT模式为了保证分布式事务的隔离性引入了全局锁机制。所谓全局锁就是一个事务的RM在修改某条数据时会在Seata服务端记录“这条数据被全局事务X锁定”其它全局事务如果也想修改这条数据必须先尝试获取这把全局锁拿不到就进入等待超时后就抛出异常。全局锁保证了脏写不会被覆盖但它的存在也带来两个问题第一事务并发高的时候锁等待会导致RT变长需要通过调大client.rm.lock.retryInterval和client.rm.lock.retryTimes来缓解第二AT模式默认的隔离级别是读未提交也就是说一个全局事务在提交前它修改的数据对其他全局事务是可见的可能会读到中间态数据。业务上如果对隔离级别有严格要求需要自己在SELECT语句上加上FOR UPDATE或者改造为“全局读已提交”方案。我并不建议无脑上全局读已提交因为那需要把所有查询SQL都做额外的处理代码侵入很大。多数订单库存场景读未提交加业务兜底校验已经够了真要严格串行化的场景优先考虑TCC或人为控制并发。2.4 和XA、TCC、SAGA对比AT为什么是首选我接触过的团队做分布式事务选型时通常会在这几个方案里纠结。XA是数据库原生支持的两阶段提交一致性最强但它的缺点是锁定资源时间很长从一阶段到二阶段整个期间数据库资源都被锁住高并发下性能瓶颈非常明显而且需要数据库厂商提供XA驱动支持像MySQL的XA在跨库时传输开销很大。TCC需要业务方自己实现Try、Confirm、Cancel三组方法灵活性最高但代码侵入非常重。我维护过一个用了TCC的业务模块每个联机接口背后都跟着两三个补偿方法还得处理Confirm和Cancel的幂等维护成本相当可观。SAGA适用于长事务和业务流程编排场景比如旅行预订这种跨天跨系统的流程但SAGA缺少隔离性保障补偿是业务级别的写起来也有一定复杂度。Seata的AT模式在“代码侵入小”和“性能可控”之间找到了一个很好的平衡点。业务方不需要写额外的补偿方法只需要保证每个参与全局事务的库里有undo_log表、每个数据源被Seata的代理包装过再在事务发起方方法上加上GlobalTransactional注解。这正是它能成为SpringCloud Alibaba生态默认分布式事务组件的原因。3. 把Seata集成到SpringCloud项目里的完整流程选型定了接下来是落地。这一章我会按我自己验证过的步骤来写版本使用Seata 1.5.2、Nacos 2.2.0作为注册中心和配置中心、SpringCloud Alibaba 2021.0.1.0、SpringCloud 2020.0.2、SpringBoot 2.6.3这套组合目前是我用过最稳的搭配。不同版本组合可能会有兼容性问题建议优先参考官方版本说明。3.1 环境准备和版本搭配的坑先说环境。Seata作为独立的TC服务端需要一个注册中心来注册自己需要一个配置中心来拉取配置还需要一个数据库来存储全局事务会话信息和锁记录。我把Nacos既当注册中心又当配置中心数据库直接用MySQL 8.0。版本搭配的坑我多提一句SpringCloud Alibaba、SpringCloud、SpringBoot、Seata这四个组件的版本必须互相兼容。我最初随便上了个SpringBoot 2.7和旧版Seata 1.4启动时要么报ClassNotFoundException要么报EndPoint相关的初始化失败查下来是SpringBoot自动配置路径变了Seata旧包不支持。建议用一个已经验证过的版本组合别追新组件版本之间的依赖关系比想象中容易出问题。3.2 部署Seata Server第一步是下载Seata Server的二进制包解压后修改conf/application.yml里的store模式。默认的存储模式是file生产环境建议改成db这样全局事务信息不会因为重启丢失。数据库里需要执行Seata自带的script/server/db/mysql.sql它会创建global_table、branch_table、lock_table三张表分别用于存储全局事务、分支事务和全局锁记录。接着把Seata Server注册到Nacos。修改conf/registry.conf里的registry段type选择nacos填入Nacos的地址和命名空间同时把config段也从file改成nacos并设置seataServer.properties所在的配置列表这样Seata Server会从Nacos拉取seataServer.properties中的配置。启动之后打开Nacos控制台能看到serverAddr为你的Seata服务地址整个服务端就起来了。这里有个容易被忽略的点seataServer.properties里的store.db.url、store.db.userName、store.db.password一定要配对否则Server启动时会一直报数据库连接失败。我排查过几次基本都是配置key写错或者Nacos配置列表没同步导致的。3.3 微服务端依赖引入和基础配置服务端就绪后业务服务需要引入依赖。注意这里不要用旧的io.seata:seata-spring-boot-starter而是用SpringCloud Alibaba提供的com.alibaba.cloud:spring-cloud-starter-alibaba-seata它会自动拉取合适的Seata客户端版本避免手动控制版本时踩兼容性坑。引入依赖后在每个参与分布式事务的微服务里配置application.ymlspring: cloud: alibaba: seata: tx-service-group: my_test_tx_group seata: registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: group: SEATA_GROUP application: seata-server config: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: group: SEATA_GROUP >Configuration public class DataSourceConfig { 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 sessionFactory new SqlSessionFactoryBean(); sessionFactory.setDataSource(dataSourceProxy); return sessionFactory.getObject(); } }最后在事务发起方的方法上加上GlobalTransactional。以订单服务为例下单方法需要开启全局事务的地方就一个注解框架会在方法执行前向TC注册全局事务拿到全局XID后续通过Feign调用其它服务时Seata会把这个XID传播到对方线程上下文里这样各个分支事务才能归属到同一个全局事务下。3.5 用一个下单场景验证全局提交和回滚我用一个最小可复现的工程来说明验证方法。工程里有三个模块order-service、inventory-service、account-service对应三张业务表orders、inventory、account。下单接口的核心逻辑大致是GlobalTransactional(name create-order, rollbackFor Exception.class) public void createOrder(Long productId, Long userId, Integer count, BigDecimal amount) { // 1. 写订单表 orderMapper.insert(...); // 2. 调用库存服务扣减库存 inventoryFeignClient.deduct(productId, count); // 3. 调用账户服务扣减余额 accountFeignClient.deduct(userId, amount); }为了验证回滚能力我在account服务里故意抛出一个RuntimeException。你运行这个下单接口后观察数据库订单表不会新增记录库存表数量不变账户余额不变。因为全局事务走到了二阶段回滚三个分支的本地事务全都撤销了。我自己的工程里加了日志观察正常流程的日志顺序大致是order-service注册全局事务成功inventory-service注册分支事务并提交本地事务account-service注册分支事务并提交本地事务最后TC收到全局提交指令各分支删除undo_log记录。回滚流程则是account-service先rollback恢复数据并删除undo_log接着inventory-service、order-service也各自rollback最后全局事务标记为rolledback。3.6 并发场景下验证全局锁等待分布式事务方案只验证回滚还不够还得验证并发情况下会不会产生数据覆盖。我模拟了这样一个场景两个用户同时抢购同一件商品库存只有1两个下单请求同时进来。第一个全局事务先执行库存扣减在库存表对应行上加全局锁第二个全局事务执行到同一条扣减SQL时发现拿不到全局锁会进入重试等待。如果第一个事务提交成功第二个事务会继续执行但它查库存时发现库存已经为0我们代码里可以加一个库存校验逻辑直接抛出业务异常触发第二个事务回滚。如果第一个事务回滚了第二个事务拿到锁后会顺利扣减成功。用JMeter压测或者简单写个并发测试工具观察现象和我们预期是否一致这个验证通过说明全局锁是生效的。如果并发下出现超卖优先检查是不是漏配了数据源代理或者undo_log表没有正常写入导致回滚链路断裂。4. 实战中容易踩的坑和排查思路Seata用起来框架本身的报错信息已经算友好但生产环境的问题往往不那么直接。我把这一两年的实录整理成一个速查表每个问题都是真实在项目里出现过的。现象根本原因解决办法大量Global lock wait timeout并发事务争用同一行数据的全局锁等待超时调大锁等待重试次数和间隔优化事务执行时间降并发undo_log表始终没数据数据源没有被Seata代理或者没有创建undo_log表检查是否配置了DataSourceProxy检查表是否存在回滚失败数据没恢复SQL解析不识别某些语法比如复合UPDATE、批量INSERT尽量用简单SQL大事务拆小对复杂SQL考虑TCC全局事务不生效GlobalTransactional没加在入口方法或XID未通过Feign传播确认注解位置检查Feign配置里是否保留RequestContextSeata Server经常OOMTC端缓存了太多全局会话存储资源有限配置db存储合理设置session超时时间和MyBatis-Plus分页插件冲突数据源代理顺序不对导致插件拿到的不是代理数据源手动配置DataSourceProxy并交付给SqlSessionFactory4.1 全局锁等待超时的排查思路Global lock wait timeout是我遇到频率最高的错误。它并不一定是Seata配置错了更多是业务本身并发高、事务执行时间长。排查时先看日志里是哪个分支事务在等待哪行数据通常日志会打印出lockKey也就是发生锁冲突的表名和主键值。如果锁冲突集中在某一张热门表可以通过预扣库存、SQL改条件、减少单事务耗时来缓解。常见的一个误操作是盲目调大client.rm.lock.retryInterval到10秒以上这会让用户请求卡得更久体验更差。正确的思路是先找到锁冲突代码段看能不能用Redis预扣、改写SQL、降低锁粒度来优化再不行才考虑放宽重试参数。4.2 回滚后数据没恢复先检查undo_log有一次同事反馈“订单回滚了但库存没恢复”我第一反应就是库存服务那侧的undo_log问题。登录库存库一看undo_log表一条记录都没有。原因是这个服务没有使用Seata代理的数据源业务SQL执行时根本没有经过Seata的回滚日志拦截器。后来帮他加上DataSourceProxy配置问题立刻消失。还有一种情况是undo_log有数据但回滚时没生效这通常是因为业务SQL里带了LIMIT或者复杂JOINSeata的SQL解析器解析前后镜像时产生了偏差。我自己在项目里定了一个约束参与全局事务的写操作SQL全部用简单的单表更新不允许写花哨SQL解析稳定也便于排查。4.3 事务不生效先确认XID有没有穿过去Seata的全局事务依赖XID在服务间传递。我们用的是SpringCloud OpenFeign正常情况下RequestContextHolder会把XID带进Feign的请求头但如果你配了自定义RequestInterceptor或者Feign的hystrix.enabledtrue导致线程池隔离上下文没了XID就会断。排查时可以在各个服务接口入口打印RootContext.getXID()如果XID为空就是传播链路断了。解决办法是移除Feign相关的线程池隔离配置或者在自定义拦截器里手动从RequestContextHolder取出XID并放入Feign请求模板。4.4 Seata Server挂了会有什么影响Seata Server作为TC节点一旦挂掉正在执行的全局事务会统一收到异常业务方会看到can not connect to seata server之类的错误已经处于中间态的分支事务会因为收不到二阶段指令而悬挂。所以生产环境TC节点必须至少部署两个以上前面加负载均衡并启用DB存储模式。我自己在生产环境的部署结构是两台Seata Server挂在Nacos后面客户端通过vgroup映射到同一个服务组Nacos会自动做健康的服务发现。如果只有一台机器还用了file存储重启TC会导致历史全局事务状态丢失线上风险极高。4.5 业务隔离和性能优化心得Seata不是银弹它最大的成本是每个被修改的行在提交前都会持有全局锁。写多读少的热点行如果经常被多个事务同时命中全局锁等待会导致吞吐下降明显。我常用的优化手段有几个第一把长事务拆短比如下单流程里把“写订单扣库存扣款”尽可能控制在几十毫秒内别把发MQ、调用外部接口这种慢操作塞进全局事务里第二库存设计成分桶扣减不同请求打散到不同库存桶降低同一行锁竞争第三只对必要的写操作开启全局事务查询、纯读接口完全不走Seata链路减少RM无谓的开销。还有一种业务模型也值得注意很多场景并不需要强一致比如积分累计、消息通知这类数据用可靠消息最终一致性更合适。Seata AT模式适合的是“绝对不允许扣款成功但订单没生成”的强一致场景选型前先把业务一致性等级定清楚否则容易把系统拖得很重。5. 最后的几句话Seata的AT模式从代码侵入度、性能损耗、社区活跃度各个维度看都是SpringCloud微服务架构里处理分布式事务最务实的方案。我个人在实际项目里用下来最舒服的一点是它把二阶段回滚的复杂性全部收进了框架内部业务代码还能保持原来的写法只加一个注解、一张undo_log表、一个数据源代理就够了。最初接入时花了两三天熟悉组件版本、配置链路和锁机制之后维护成本很低比起手写补偿SQL的提心吊胆这个投入是很值的。如果你正在搭新的SpringCloud项目或者老项目已经出现了跨库写不一致的苗头先把Seata Server跑起来找最核心的一条业务链路做接入验证观察回滚和并发两个关键场景。确认没问题再铺开到其它服务千万不要一次性把所有写接口全挂上全局事务那样排查问题的范围一下子扩大到整个系统会非常难受。