做微服务,只要跨了服务去写数据,分布式事务就是绕不过去的坑。这一篇拿Spring Cloud集群里最常见的订单、库存、账户三个服务来做分布式事务,方案选的是Seata的AT模式,从部署Server端到写注解再到排查脏读,一次走通。写完你会发现,Seata真正要命的不是搭环境,而是理解它“两阶段提交”背后的事务边界和控制逻辑。这篇适合已经会用Nacos、OpenFeign,但还没碰过分布式事务的同学,照着做大概半天能跑通。
## 1. 分布式事务方案选型:为什么选Seata而不是自己写
1.1 先盘一盘微服务下的事务困局
单体应用里,一个事务从开启到提交都在同一个数据库连接上完成,ACID由数据库保证。但拆成微服务后,订单服务写订单库,库存服务写库存库,账户服务写账户库,一次用户下单要同时操作三个库。这时候如果库存扣减成功而账户扣款失败,整个业务就处在“订单已创建、库存已扣、钱没扣”的脏状态里。
我说个最典型的场景:用户下单支付100元买一个商品。订单表insert一条记录,库存表update库存减1,账户表update余额减100。这三步分别落在三个独立的服务上,每个服务各连各的库。传统单库事务在这里完全失效,因为跨库、跨连接,数据库自己的事务机制管不到别人家的库。
解决这个问题的思路大体有四条路:XA强一致性、TCC补偿型、本地消息表最终一致性、以及Seata这种以AT模式为代表的“增强型两阶段提交”。它们各有适用场景,不是说哪个绝对好,而是看你的业务允许多大窗口期的不一致。
1.2 各类方案的优缺点对比
我直接先用一个表格把这几种方案的核心区别捋清楚,后面再展开讲为什么实际项目里Seata的AT模式是性价比最高的选择。
| 方案 | 一致性强度 | 代码侵入性 | 性能损耗 | 典型使用场景 |
|---|---|---|---|---|
| XA | 强一致,数据库原生支持 | 低,但要求数据库支持XA协议 | 高,锁持有时间长 | 银行转账、财务对账等高要求场景,微服务中较少直接用 |
| TCC | 最终一致 | 高,需要手写Try/Confirm/Cancel三段逻辑 | 中 | 电商下单、支付、账务类业务,适合团队有能力封装框架 |
| 本地消息表 | 最终一致 | 中,需自己维护消息表和定时任务 | 低 | 异步场景,比如下单后发通知、积分累计,允许延迟 |
| Seata AT | 最终一致(但窗口期极短) | 极低,注解即可,自动生成回滚SQL | 中,主要多一次undo_log写入 | 绝大多数Spring Cloud业务系统,性价比最高的选择 |
选Seata还有个现实原因:很多团队已经有Nacos做注册中心和配置中心,而Seata对Nacos的支持非常完善,不需要额外引入一套中间件。从运维角度讲,部署成本很低;从开发角度讲,业务代码里只加一个@GlobalTransactional注解,不像TCC那样要把每个接口拆成三段来写。
1.3 Seata三种模式怎么选
Seata自身也提供AT、TCC、Saga、XA四种模式。我实际项目里用得最多的是AT,其次是TCC和XA在某些特殊场景的补充。
AT模式的核心思想是:在本地事务里额外记录数据变更前后的镜像,事务提交前先检查数据有没有被其他事务改过,如果没被改则提交并删除镜像,如果被改了则走合并回滚或通知人工处理。这个思路对开发者来说几乎是透明的,业务代码不用写任何补偿逻辑,这也是它在Spring Cloud社区普及度高的根本原因。
TCC模式则需要业务方自己实现三个方法:Try做资源预留,Confirm做确认提交,Cancel做补偿回滚。优点是控制力强,适合那种数据一致性要求极苛刻、并且业务本身具备“预留”语义的场景,比如库存预占。缺点是代码量急剧增加,每个参与方都要写三套逻辑,还要处理Confirm和Cancel的幂等。
我这里强调一下:小团队、业务快速发展期、接口数量多,优先AT模式。TCC适合那种已经明确知道某个核心链路性能压力大、且需要精细控制锁范围的场景。Saga则更适合长流程编制类业务,比如订单状态机流转,它不要求每个参与方同时在线,靠事件驱动推进。
## 2. Seata核心组件与AT模式原理拆解
2.1 TC、TM、RM三个角色到底各管什么
Seata把分布式事务拆成三个角色,理解这三个角色的分工,后面排查问题才能不看日志两眼一抹黑。
TC(Transaction Coordinator,事务协调器)是独立部署的Server端,负责维护全局事务的状态,协调分支事务的提交或回滚。你下载的seata-server就是这个角色,它需要注册到Nacos,让其他服务能找到它。
TM(Transaction Manager,事务管理器)在客户端,就是你的业务服务里加了@GlobalTransactional注解的那个方法所在的服务。它的职责是向TC申请开启一个全局事务、提交全局事务、回滚全局事务。你可以把它理解成全局事务的“总开关”。
RM(Resource Manager,资源管理器)也在客户端,负责管理分支事务的资源,向TC注册分支事务,并上报分支事务的执行状态。订单服务、库存服务、账户服务里只要参与了全局事务的数据操作,每个服务都算一个RM。
我打个比方:TM是项目经理,TC是项目协调中心,RM是各个施工队。项目经理到协调中心报备“我要开工了”,然后各施工队各自干活,干完向协调中心汇报,协调中心根据所有施工队的汇报决定整个项目算成功还是返工。
2.2 AT模式的“两阶段提交”到底做了什么
AT模式的两阶段提交和数据库原生两阶段提交不是一个概念。这里说的两阶段是指全局事务的第一阶段与第二阶段。
第一阶段:业务SQL在本地事务里执行并提交。在执行SQL之前,RM会先获取该SQL将要影响的数据行的原始快照,执行完后获取新的快照,并将这两份快照都写入undo_log表。然后通过本地事务把业务SQL的变更和undo_log的写入一起提交。注意,这时候本地事务已经提交了,数据库行锁已经释放,这也是AT模式比XA并发性能好的关键。
第二阶段:TC收到所有分支事务都成功的报告后,发提交指令。RM收到后异步删除undo_log记录。如果某个分支失败了,TC发回滚指令,RM根据undo_log里的原始快照生成逆向SQL,把数据恢复回去。
这里有个非常容易踩坑的点:undo_log表必须和业务表在同一个数据库里,否则本地事务无法保证业务变更和undo_log写入的原子性。很多人回滚失败,第一反应是代码问题,结果查了半天发现undo_log建错了库。
2.3 全局锁与隔离级别的关系
AT模式为了保证不回滚出错,引入了全局写锁的概念。在本地事务提交前,RM会向TC申请全局锁,TC记录下当前这个数据行被哪个全局事务锁定了。如果此时另一个全局事务也想操作同一行数据,就得等锁。
用实例解释:事务A扣库存,库存行被全局锁锁定,事务A本地提交后锁还没释放。此时事务B也想扣同一个商品的库存,它去申请全局锁时发现锁被A持有,只能等待A完成全局提交或回滚后才能继续。这个机制避免了“A还没决定最终成败,B就把数据改了”的脏写。
但这里有个隔离级别的缺口:AT模式的默认隔离级别是读未提交(Read Uncommitted)。也就是一个全局事务还没提交,另一个事务直接读数据,可能读到中间状态。很多开发者在需求评审时没注意这一点,等到实际出现显示库存和真实库存不一致才开始排查。解决办法也有,后面实操部分我会专门讲@GlobalLock和select for update的用法。
## 3. Seata在Spring Cloud里的完整落地步骤
3.1 环境准备与版本选择
我自己的习惯是先确认版本组合,再动手搭建。Seata对Spring Cloud Alibaba的版本非常敏感,版本不匹配会出现注册失败、事务分组找不到等玄学问题。
这里给一组我测试过的组合:Spring Boot 2.7.x + Spring Cloud 2021.0.x + Spring Cloud Alibaba 2021.0.5.0 + Seata 1.6.x。如果你用的Spring Boot 3.x,那要选Seata 2.x并注意Jakarta命名空间的调整,不要在旧教程里硬套。
准备阶段要做三件事:第一,安装一个可用的Nacos,Seata Server和所有微服务都要注册进去;第二,准备一个MySQL数据库,initSQL脚本建三张表:global_table、branch_table、lock_table,这三张表是Seata Server自己的;第三,在每个业务数据库里建一张undo_log表。
undo_log的建表SQL我直接贴出来,注意字段类型要跟你的数据库版本匹配,MySQL 5.7和8.0都测过没问题:
CREATE TABLE IF NOT EXISTS `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;3.2 Seata Server部署:从压缩包到Nacos注册
去Seata官方GitHub Release页面下载对应版本的server压缩包。我以Seata 1.6.0为例,解压后进入conf目录。
新版Seata Server使用application.yml做配置。核心配置有两块:注册中心和事务存储。
server: port: 7091 seata: registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 group: SEATA_GROUP namespace: "" username: nacos password: nacos cluster: default config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP namespace: "" username: nacos password: nacos store: mode: db db: datasource: druid db-type: mysql driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai user: root password: yourpassword这里有个容易被忽略的细节:store.mode要设成db,否则默认文件存储,重启后事务状态容易丢,而且并发高时文件锁竞争严重。生产环境务必用db模式。
启动命令也很简单,Linux或Mac下执行sh seata-server.sh,Windows下执行seata-server.bat。启动后去Nacos控制台的服务列表里看,应该能看到seata-server这个服务,cluster是default。
3.3 客户端集成:配置事务分组是最大的隐藏门槛
服务端部署好之后,开始改业务服务。第一步是引入依赖,注意不是直接引seata-spring-boot-starter,而是引Spring Cloud Alibaba整合好的依赖:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> <version>2021.0.5.0</version> </dependency>这个starter会自动帮你引入Seata客户端相关组件,但有几个配置必须手动指定。最关键的配置在application.yml里:
spring: application: name: order-service cloud: alibaba: seata: tx-service-group: my_test_tx_group seata: registry: type: nacos nacos: server-addr: 127.0.0.1:8848 application: seata-server group: SEATA_GROUP username: nacos password: nacos config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP username: nacos password: nacos service: vgroup-mapping: my_test_tx_group: defaulttx-service-group是事务分组名称,可以随便起,但必须和vgroup-mapping里的key一致。这个配置的意思是:我这个应用属于my_test_tx_group事务分组,这个分组映射到Seata Server集群中的default集群。
事务分组这层概念是新手最容易懵的地方。简单说,nacos上注册的seata-server实例有cluster属性,默认值就是default。客户端通过vgroup-mapping得知自己应该找哪个cluster的TC。如果这里配错,会报类似No available service named 'default' found的错误,这个报错十有八九是vgroup映射问题。
3.4 业务代码改造:注解+数据源代理缺一不可
依赖配完,代码改造本身很轻,核心就两步。
第一步是启动类或配置类里把数据源换成Seata的代理数据源。虽然新版部分版本里AT模式可以自动代理,但为了稳妥我建议显式配置:
@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties(prefix = "spring.datasource") public DruidDataSource druidDataSource() { return new DruidDataSource(); } @Bean public DataSource dataSource(DruidDataSource druidDataSource) { return new DataSourceProxy(druidDataSource); } }注意一个坑:@ConfigurationProperties(prefix = "spring.datasource")需要和spring.datasource.druid下的配置对应上。很多人因为前缀没对齐,导致数据源初始化失败。
第二步是在发起全局事务的入口方法加注解:
@Service public class OrderServiceImpl implements OrderService { @GlobalTransactional(name = "create-order", rollbackFor = Exception.class) public void createOrder(OrderRequest request) { // 1. 本地事务:插入订单表 orderDao.insert(order); // 2. 远程调用库存服务扣减库存 storageClient.decrease(request.getProductId(), request.getCount()); // 3. 远程调用账户服务扣减余额 accountClient.decrease(request.getUserId(), request.getAmount()); } }@GlobalTransactional可以理解成Seata版的@Transactional,它开启的是全局事务。方法里第一次执行数据库操作时,RM会向TC注册分支事务,后面的远程调用会通过微服务间传递的XID来串联同一个全局事务。
这里必须强调:XID传递是Seata分布式事务能否生效的命脉。Spring Cloud Alibaba的starter会自动在Feign调用的Header里附加XID,前提是请求经过Feign客户端。只要你的远程调用用的是Feign,不需要手动处理。但如果某些链路里用了RestTemplate直接HTTP调用,XID不会自动传递,需要自己从RootContext.getXID()取值塞到请求头里。
3.5 库存服务和账户服务的改造
库存服务和账户服务作为分支事务的参与方,改造更简单。不需要加@GlobalTransactional,只需要把数据源换成DataSourceProxy,确保Feign接口能接到上游传来的XID即可。
比如库存服务里正常的扣减库存方法:
@Service public class StorageServiceImpl implements StorageService { @Transactional(rollbackFor = Exception.class) public void decrease(Long productId, Integer count) { storageDao.decrease(productId, count); } }这里用本地@Transactional即可。注意一个关键点:分支事务的本地事务会默认加入到全局事务中,Seata的RM通过XID感知当前环境。如果你把这个本地事务的传播行为改成REQUIRES_NEW,就会脱离全局事务,回滚时这个分支不受控制,这是一个非常隐蔽的错误。
我实际验证的结论是:参与方服务保持默认的事务传播行为即可,REQUIRED传播行为会正确加入全局事务。
3.6 验证分布式事务是否真的生效
代码写完怎么验证?最直接的方式是制造一次人为失败。
我常用的验证方法是:在账户服务扣减余额的代码里临时加一行throw new RuntimeException("模拟扣款失败"),然后调用下单接口。如果Seata配置正确,订单表里不会留下那条新增订单,库存表也不会扣减。如果订单没回滚或者库存被扣了,说明事务链路有问题,基本是XID没传递或数据源代理没生效。
等验证通过后,把模拟异常的代码删掉,一个标准的Seata分布式事务链路就算完整跑通了。
## 4. 事务失效的典型场景与隔离性控制
4.1 最常见的失效场景:方法内部自调用
这是我在答疑群里见过最多的问题。同一个类里,一个方法调用另一个加了@GlobalTransactional的方法,注解不生效。
原因是Spring的声明式事务基于AOP代理实现。外部调用进入代理对象时,代理才能开启全局事务;内部this.xxx()调用直接绕过代理,注解形同虚设。
解决办法是把全局事务方法放到另一个Bean里,或者在调用方注入自己的代理对象。实操时我建议直接拆Bean,逻辑清晰,性能也更好,因为代理对象自调用还会带来重复AOP开销。
4.2 事务失效场景二:try-catch吞掉异常
@GlobalTransactional(rollbackFor = Exception.class) public void createOrder(OrderRequest request) { try { storageClient.decrease(...); accountClient.decrease(...); } catch (Exception e) { log.error("下单失败", e); } }这段代码有个致命问题:异常被catch住,方法正常返回,TM向TC报告全局事务成功,但实际账户服务已经回滚了。结果全局事务标记成功,订单却留在库里,形成“假成功”。
正确的做法是:让异常继续抛出去,或者catch之后手动抛出新的RuntimeException并设置rollbackFor。如果确实想在catch里做补救处理,至少要在最后throw new RuntimeException(e)。
4.3 脏读与脏写:如何用GlobalLock绕开
前面提到AT模式默认隔离级别是读未提交。落到业务上就是:订单事务还在运行中,商品库存被扣减但尚未提交,此时运营后台拉实时库存报表,可能读到扣减后的库存量,再过一秒事务回滚了,报表数据就对不上了。
实际项目里彻底解决这个问题的标准姿势是:对需要强一致读的查询方法加@GlobalLock注解,并在SQL里使用select ... for update。加了GlobalLock后,这个查询会先尝试获取全局锁,如果对应数据被全局事务锁住,查询会等待锁释放,从而避免读到中间状态。
@GlobalLock public Integer getStorage(Long productId) { return storageDao.selectStorageForUpdate(productId); }select stock from storage where product_id = #{productId} for update这个方案只解决当前事务对锁定行的读一致性,不需要把所有查询都加上,只针对那种“必须看到最终值”的关键查询。一般报表类需求可以接受延迟,不要无脑加锁,否则性能损耗得不偿失。
4.4 全局事务超时导致的回滚异常
Seata默认全局事务超时时间参数是60秒。如果全局事务里链路特别长,比如还调用了第三方接口超过60秒,TC会主动标记这个全局事务超时并回滚。但此时业务方并不知道,还在继续往下走,就会出现“下游成功,上游回滚”的割裂状态。
排查这类问题时就去看TC日志里有没有Global transaction timeout字样。有的话,确认这个链路的正常耗时是多少,超时时间设置合理值,不要盲目调到非常大的值。调大超时会增加全局锁持有时间,并发能力会断崖式下跌。
## 5. 高频报错与排查经验汇总
5.1 事务分组相关报错
报错信息示例:Could not found global transaction xid或者No available service named 'default' found。
这类问题几乎都指向vgroup-mapping和cluster映射。排查步骤是:先确认seata-server的registry.nacos.cluster值是多少,再看客户端seata.service.vgroup-mapping里映射到哪个cluster,两者必须一致。另外注意客户端所在namespace要在Nacos上可见,namespace配错表现为注册中心找不到TC实例。
5.2 回滚失败相关报错
回滚失败的日志里通常会出现rollback info not found或者Branch transaction rollback failed。第一反应查undo_log表和数据源代理。
rollback info not found八成是undo_log表和数据源不匹配。常见原因有两个:一是undo_log表建在了别的库,二是业务代码里手动把数据源换成了非代理数据源,导致RM根本不知道该写undo_log。
还有一种情况:业务表结构里字段类型和undo_log快照里存的不一致,比如数据库是datetime,而代码映射成LocalDateTime时JDBC驱动存储格式有差异,回滚时会产生类型转换异常。这种问题排查起来比较隐蔽,看到的报错五花八门,实际根因就在数据类型映射,一般通过SQL模式调整或者升级驱动版本能解决。
5.3 客户端总是注册不上TC
启动日志一直打retry registering session之类的提示,说明客户端连不上TC。从三个方向排查:第一,网络通不通,本地直接telnet一下seata-server的端口;第二,Nacos上seata-server服务是否存在,如果服务列表里根本没有,说明server端没注册成功;第三,客户端和服务端的group、namespace是否一致。
这只说一点,很多人在服务端注册了,但客户端把自己也注册成了seata-server的消费者,两边集群名不一致,导致客户端拿着default去找,而服务端集群名是别的。统一集群名是最快的解决路径。
5.4 高性能场景下的建议
Seata不是银弹。如果单条链路事务频率极高、数据操作量大,AT模式多一次undo_log写盘和多一次全局锁申请的开销就会被放大。我见过某个项目把Seata用在秒杀扣减库存的链路里,压测秒级上万请求直接拖垮TC,后来改成TCC模式才缓解。
实际业务里我的建议是:低频写、跨服务多、对一致性要求高的接口,放心用AT;高频写、链路短的场景,优先考虑解析业务逻辑,把多个写合并成单服务写,或者使用TCC / 本地消息表来分摊压力。分布式事务的本质是权衡,不是无脑套框架。
## 6. 事务链路扩展:Seata集群高可用与监控
6.1TC集群部署的核心配置
单机TC能支撑大部分学习场景和中小项目,生产环境必须做高可用。Seata Server集群的思路比较简单:启动多个Server实例,设置相同的registry.nacos.cluster,让它们作为同一个cluster的多个节点注册到Nacos。
客户端事务分组映射到该cluster后,TC之间如何协调?这依赖Seata Server的存储模式。db模式下,各TC通过共享数据库表global_table、branch_table、lock_table协调状态,任何一台TC宕机,其他节点可以从数据库恢复该全局事务的状态,继续推进提交或回滚。
实操时注意:db模式下各个TC节点必须连接同一个数据库实例,不能用各自独立库。这跟普通无状态服务横向扩容不一样,TC节点是有状态的,状态存数据库里,节点本身保持无状态化。
6.2 全局事务状态监控方案
排查问题不能总靠看日志。实际项目里我建议给TC加一层监控方案,比较轻量的做法是定时扫描global_table表,统计status字段分布情况,把异常状态数量加到告警系统里。
global_table的status字段常见值包括:0表示Begin,1表示GlobalCommitting,2表示CommitRetrying,3表示Rollbacking,4表示RollbackRetrying。如果大量事务长时间停留在2或4状态,说明TC在反复重试提交或回滚,基本是分支服务响应超时或网络抖动,需要介入排查。
还有一种更直接的方式:Seata提供的metrics接口,开启后暴露性能计数器,可以接入现有监控体系。这块很多开发者容易忽略,等到线上出事务问题才想起来,其实提前配好,日志采集和告警都能受益。
## 7. 我的实操体会
Seata这套东西,搭起来不难,难在对事务边界的认知。我最初踩的最深的坑,就是以为加了@GlobalTransactional就万事大吉,结果内部自调用、Feign调用异常被吞、数据源没代理,三个经典错误轮着来了一遍。后来形成了一套自检顺序:先确认数据源被代理,再确认Feign调用链路里能拿到XID,最后确认异常路径能把信息抛回给TM。每次出问题,按这个顺序排查基本都能定位。
还有一点建议:事务分组的命名要有规划,尽量把同一业务域的服务放进同一个事务分组,这样后续做TC集群迁移时只需要改映射关系。如果每个服务乱起分组名,vgroup-mapping配置会变得一团乱麻,维护成本极高。
另外说个容易被忽略的小技巧:全局事务方法的日志里把RootContext.getXID()打出来。开发和排查问题时,在日志里看到XID,就能迅速确认这个调用属于哪个全局事务,再配合TC端日志看全局事务状态,很多疑难杂症都能快速定位。这个习惯我从第一版项目保持到现在,已经帮我省了无数个查日志的夜晚。