做谷粒商城做到seata这一章,真的是我这次系列踩坑里最难忘的一晚。上一坑刚把文件上传那块解决掉,心想总算能往前推了,结果分布式事务一引入,好家伙,报错报得我怀疑人生。连着卡了一个通宵,群里一搜,全是同款问题,有人卡了三天没过去。所以这第九坑,我把自己查到的、实测过的、以及最后验证通过的seata报错处理思路完整梳理一遍。
这篇东西适合两类人看:一是正在跟着谷粒商城敲代码、卡在seata这章的同学,二是做微服务项目需要接入分布式事务、想看明白报错根因的Java开发。我会从TM、TC、RM这三个角色讲起,然后按“版本对齐、配置核对、日志定位、数据库补漏”这个顺序,把项目里最典型的几种seata报错挨个拆掉。保证你能直接照着排查,不用再一遍遍百度。
1. 先搞明白seata在谷粒商城里的角色,再谈报错
1.1 TM、TC、RM三个角色先拎清楚
很多人seata报错折腾半天,其实连它内部有哪些角色分工都没搞清。这不怪你,因为网上的教程一上来就甩配置,跳过了最核心的概念。
seata的分布式事务模型里有三个角色:
- TM(Transaction Manager,事务管理器):负责全局事务的开启、提交和回滚。在谷粒商城里,订单服务就是TM。你在下单方法上加
@GlobalTransactional注解,这个注解就是告诉TM“我需要一个全局事务”。 - TC(Transaction Coordinator,事务协调中心):这是独立部署的seata-server,它负责接收TM和RM的请求,统一管理和调度所有分布式事务的状态。可以把它理解成整个事务体系里的大脑。
- RM(Resource Manager,资源管理器):负责管理分支事务上的资源。谷粒商城里,库存服务接收到订单服务的Feign调用后,本地数据库的操作会被RM接管,RM把这次操作注册成一个分支事务,并定时向TC汇报状态。
把这套角色映射到实际代码里,就非常清晰了。你跑起来的项目实例中,订单服务既当TM又当RM,库存服务是RM,而单独启动的seata-server就是TC。搞清楚这个,你才能明白报错信息到底是谁发出来的、谁连不上谁。
1.2 订单服务为什么必须引入分布式事务
在没引入seata之前,谷粒商城的下单流程是这样的:订单服务本地开事务,保存订单数据,然后通过Feign调用库存服务扣减库存。这中间有一个致命问题:订单的本地事务和库存服务的事务是两回事。
比如用户下单,订单数据落库成功后,Feign调用库存服务时网络超时,库存没扣成功。订单服务捕获异常后,只能回滚自己本地的订单操作,但库存服务那边什么都没做,所以最终结果是订单被创建了,库存却没扣。再严重一点,如果Feign调用超时导致订单服务重启,本地事务已经提交,那就连回滚机会都没有了,直接产生脏数据。
这个场景就是典型的分布式事务问题。Seata要做的事情就是:把下单、扣库存这几个跨服务的操作,打包成一个全局事务,要么一起成功,要么一起回滚。理解了这一点,你再看接下来的报错,就能明白所有配置都是在为这个目标服务。
1.3 版本不对一切白搭:先对齐版本组合
我踩的第一个大坑,就是版本没有对齐。
谷粒商城课程里用的Spring Cloud Alibaba版本是2.2.x,对应的seata控制端版本是1.x。但如果你直接去seata官网下载最新版,比如2.x甚至更高版本,拿回来一配置,大概率会出现各种奇怪的异常,像java.lang.NoSuchMethodError、ClassNotFoundException这类,看起来像代码问题,实际就是客户端和服务端版本协议对不上。
这里我建议你按这个步骤来确定版本:
- 先看你项目里
spring-cloud-alibaba-dependencies的版本号,一般是2.2.x。 - 打开本地Maven仓库,找到
spring-cloud-starter-alibaba-seata的jar包对应的pom文件,看看它传递依赖的seata-all版本是多少。 - 去GitHub下载和
seata-all相同大版本的seata-server。
比如说,spring-cloud-starter-alibaba-seata依赖的是seata-all 1.1.0,那你就用seata-server 1.1.0。版本号一定要精确到小版本,大版本一致都不一定兼容。这个步骤做完,能直接排除掉三成以上的报错。
2. 谷粒商城seata报错类型与核心排查思路
2.1 启动就报错的三大类问题
seata相关报错,按我遇到的频率排,可以分为三大类。
第一类是客户端连不上TC(seata-server)。典型报错是FrameworkException: NoAvailableService或者connect refused。这种问题大概率是版本、注册中心配置或者网络的问题。因为客户端启动时会根据配置去注册中心找seata-server的地址,找不到就抛异常。
第二类是配置文件缺失或字段对不上。比如Could not found property service.vgroup_mapping.my_test_tx_group,这种一般是服务端file.conf和客户端tx-service-group名字没对齐导致的。
第三类是版本冲突和依赖引入不正确。比如项目里同时引入了seata-all和seata-spring-boot-starter,出现依赖冲突,启动时各种类找不到。
有些人一看报错就慌,觉得是自己代码写错了。其实seata的报错大多数是配置和版本问题,代码层面的问题反而很少。这个思路很重要。
2.2 先看日志再看配置:我的排错顺序
踩坑多了之后,我总结了一套比较省时的排查顺序,核心原则是:先看日志,再改配置。
很多人的做法是:报错之后马上打开项目配置文件一顿乱改,改完重启,发现还是报错,来回折腾一晚上。正确做法是先看日志。
如果你遇到的是启动报错,优先看客户端启动日志里seata相关的部分。搜索关键字seata、error、exception,基本能定位到问题点。如果客户端日志显示连接超时,那就再去看seata-server的日志,路径在seata-server目录的logs/seata-tc.log。这个日志会记录TC自身的启动状态、注册中心连接情况、客户端请求处理情况,是最直接的判断依据。
如果你遇到的是运行时报错,也就是发起下单请求时才报错,那还需要看两条链路的日志:一是订单服务的日志,确认XID是否正确传播;二是seata-server的日志,确认分支事务有没有正确注册。两条链路都看,才能确认问题是出在TM、TC还是RM哪个环节。
2.3 三个配置文件里最容易错的地方
seata整个体系里,项目经理、协调者、资源管理器互相通信,三个角色的配置没对齐,报错千奇百怪。最容易出错的配置有这三个地方。
一个是seata-server的registry.conf文件。它的作用是告诉seata-server,我注册到哪个注册中心。谷粒商城课程用的是nacos,所以这里type要改成nacos,并且serverAddr要指向你nacos的地址。很多人这里没改,默认还是file模式,客户端通过nacos找服务端,自然就找不到。
第二个是seata-server的file.conf文件。这个文件里最核心的配置是service.vgroup_mapping。它的意思是把客户端发来的事务分组映射到具体的seata-server集群。项目中默认的事务分组名是my_test_tx_group,如果你客户端配的事务分组名和这个对不上,就会报前面说的Could not found property service.vgroup_mapping错误。
第三个是客户端的application.yml文件,也就是你订单服务和库存服务各自的配置。里面要以seata开头,配置tx-service-group、注册中心类型、nacos地址等信息。这三处必须和seata-server的配置完全对应上,任何一个对不上,客户端和服务端的握手都会失败。
3. 实操:完整处理一遍谷粒商城的seata配置
3.1 配置seata-server的registry.conf
这一节我直接按谷粒商城项目里通常用的配置方式来操作。首先打开seata-server目录下的conf/registry.conf文件。
如果你用的是nacos作为注册中心,那么这个文件的registry部分应该是这样:
registry { type = "nacos" nacos { application = "seata-server" serverAddr = "127.0.0.1:8848" namespace = "" cluster = "default" } }这里重点看三点:第一,type必须是nacos;第二,serverAddr必须指向你本地nacos的地址和端口,默认是127.0.0.1:8848;第三,namespace默认留空就行,不要随便填。cluster名称默认是default,这个要和后面客户端配置保持一致。
有一个坑我一定要提醒你:文件里的config部分,也就是配置中心那一段,默认也是nacos。如果你暂时没打算用nacos做配置中心,可以先把config.type改成file,否则seata-server启动时会尝试从nacos拉配置,拉不到配置也可能导致启动异常。
保存文件后重启seata-server,然后去nacos控制台的服务列表里看一眼,是否出现了seata-server这个服务。如果出现了,说明注册成功了,这是排查后续问题的基础。
3.2 配置file.conf里的事务分组映射
接下来修改seata-server目录下的conf/file.conf文件。这个文件负责事务分组和集群的映射关系。
打开文件,找到service配置块,核心内容如下:
service { vgroup_mapping.my_test_tx_group = "default" default.grouplist = "127.0.0.1:8091" enableDegrade = false disableGlobalTransaction = false }这里我要展开讲一下,因为很多人不理解这两行的含义,改半天也不知道为什么报错。
vgroup_mapping.my_test_tx_group = "default"这行,意思是客户端如果请求事务组my_test_tx_group,就把它路由到名为default的集群里。这个集群名必须和registry.conf里nacos的cluster = "default"对应上。很多人在nacos里配置了集群名是default,但file.conf里映射却写成了my_test_tx_group = "fsp",结果客户端拿着事务组名来找TC,TC发现这个组映射到了一个不存在的集群,自然就返回错误。
default.grouplist = "127.0.0.1:8091"是直连方式下TC的地址列表。虽然用了nacos注册中心后,grouplist不是必须的,但我建议还是保留着,因为当你用直连方式调试时能省事不少。8091是seata-server默认的RPC通信端口,一般不用改。
改完file.conf后,同样需要重启seata-server才能生效。这里再强调一次,seata-server的配置文件改完一定要重启,很多同学改了文件不重启,然后二次排查的时候浪费大量时间。
3.3 客户端yml接入seata
服务端配置好之后,客户端也需要做对应的配置。谷粒商城的订单服务和库存服务都要接入seata。
在订单服务(以及其他需要开启全局事务的服务,如库存服务)的application.yml里添加如下配置:
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 config: type: nacos nacos: server-addr: 127.0.0.1:8848关键点在于application-id要改成当前服务自己的名字,tx-service-group必须和前面file.conf里的vgroup_mapping前缀完全一致,这里是my_test_tx_group。
这里有一个容易被忽略的细节:不同版本的seata客户端,配置字段略有不同。比如有些版本用service.vgroup-mapping,老版本用vgroup_mapping,新版本用tx-service-group。如果配置了但启动时提示某个字段不存在,你优先去查一下当前版本客户端代码里实际读取的配置项是什么,而不是硬抄别人的配置。
还有一个必须注意的点:订单服务作为TM,需要在开启全局事务的方法上添加@GlobalTransactional注解。谷粒商城项目里一般加在下单方法submitOrder上面。如果你忘了加这个注解,整个请求根本不会走seata事务链路,但又不报错,这个属于隐藏问题。排查的时候可以看seata-server的日志,如果请求过去了但完全没有全局事务创建记录,那就查一下注解是不是漏了。
3.4 别忘了业务库的undo_log表
这一步是新手最容易漏的。seata的AT模式(自动补偿模式)做回滚,依赖一张叫undo_log的日志表。如果这个表不存在,一旦事务执行到二阶段回滚,就会出现数据库报错,比如Table 'gulimall_oms.undo_log' doesn't exist。
每个涉及分支事务的业务数据库都要建这张表,而不是只建一个。谷粒商城项目里,订单库gulimall_oms和库存库gulimall_wms都要建,商品库gulimall_pms如果也参与了全局事务,同样需要建。
建表SQL直接用官方提供的就行:
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这一步,耽误时间。
4. 常见报错与解决方法速查表
这一节我把实际调试中遇到的典型报错整理成一个速查表。表格的意义在于:你可以先按报错关键字匹配,快速找到处理思路,再去结合自己的具体上下文做调整,省掉大部分无效检索的时间。
| 报错关键字 | 常见原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| FrameworkException: NoAvailableService | 客户端通过注册中心找不到seata-server | 检查seata-server是否启动、是否注册到nacos、cluster名是否一致 | 重启seata-server,核对registry.conf和file.conf的集群名 |
| Could not found property service.vgroup_mapping.xxx | 客户端事务分组和服务端vgroup映射不一致 | 对比客户端tx-service-group和file.conf里的vgroup_mapping | 统一事务分组名,重启服务 |
| Table xxx.undo_log doesn't exist | 业务库缺少undo_log表 | 登录数据库查看是否存在 | 在涉及的每个业务库执行建表SQL |
| NoSuchMethodError / ClassNotFoundException | seata版本与Spring Cloud Alibaba版本不匹配 | 查看spring-cloud-starter-alibaba-seata依赖的seata版本,检查服务端版本 | 统一seata-all和seata-server版本,清理Maven冲突 |
| TimeoutException: xxx | 全局事务执行超时或全局锁获取超时 | 查看是哪个分支事务超时,检查SQL执行慢的原因 | 优化SQL,调整seata超时参数 |
| Failed to fetch schema of table | 数据库连接配置异常,比如时区、SSL问题 | 检查JDBC连接串参数 | 在数据源URL加上useSSL=false&serverTimezone=Asia/Shanghai |
| connect refused | 客户端访问TC的地址不通 | 测试8091端口连通性,确认seata-server启动状态 | 启动seata-server,检查防火墙和端口 |
| jakarta/x XID丢失导致分支事务未注册 | Feign调用时XID没有传递 | 查看feign调用链上下文 | 确认引入seata对feign的集成包,不要手动拦截头 |
你看这个表就能发现,绝大多数seata报错都集中在版本和配置这两块,真正需要在代码层面解决的问题非常少。所以遇到报错先别改业务代码,把配置表和版本表对一遍再说。
关于Failed to fetch schema of table我再多说一句。这个问题看上去像是seata内部报错,但根因往往是你数据源的连接串没有加时区参数。我遇到过的情况是数据库和本机时区不一致,seata在执行分支事务预处理时需要读取表结构,结果时间转换异常,导致读取失败。你如果在排查这类问题时看到了和CST、UTC相关的字眼,直接去检查JDBC连接串,加一句serverTimezone=Asia/Shanghai基本就好了。
5. seata面试会问什么:原理和考点一次讲透
5.1 AT模式的二阶段提交到底怎么运作
理解了报错之后,顺势把seata的核心原理过一遍。这不仅是面试常考的内容,而且搞懂原理之后,你调试起来会明显更有方向感。
seata AT模式的核心是二阶段提交,但和传统XA两阶段提交有区别。在AT模式下,一阶段时,RM执行本地的业务SQL提交事务,同时生成回滚日志(undo_log),记录数据修改前后的快照,并且注册分支事务到TC。这时候数据已经提交了,但全局锁还拿着。
到了二阶段,TM来决定最终是提交还是回滚:
- 如果全局事务正常,TC通知所有RM异步删除undo_log快照,释放全局锁,完事。
- 如果全局事务需要回滚,TC通知RM,RM根据undo_log里的before镜像反向生成补偿SQL,把数据恢复成修改前状态,然后释放全局锁。
这个设计让你可以在不高成本侵入业务代码的前提下,实现跨服务的数据一致性。数据库在本阶段内不阻塞,并发性能上比传统方案好不少。
5.2 TM、TC、RM的面试话术怎么讲
面试时如果被问到seata的三个角色,光说全称是不够的,你要把职责和应用场景串起来。
我建议这样答:TM是全局事务的发起者,负责开启全局事务、发起全局提交或全局回滚的指令;TC是独立部署的全局事务协调中心,负责接收TM和RM的通信,管理全局事务状态和分支事务状态;RM负责管理分支事务上的资源,处理本地SQL执行、分支注册、状态上报、回滚日志生成这些底层细节。业务上常见的组合是,订单服务是TM,库存服务是RM,中间再挂一个TC。
顺带把XID的传播讲一下:TM开启全局事务后会生成全局唯一XID,通过Feign调用链传递到下游服务,下游服务拿到XID后,自己的RM才能加入同一个全局事务。这个传播过程由seata集成层自动完成,你只需要保证服务都接入seata配置就行。
5.3 全局锁、脏读和隔离级别问题
seata面试还有一个高频点,就是它如何处理写隔离和读隔离的。
写隔离靠的是全局锁。在AT模式一阶段提交后,RM会拿到全局锁,其他事务想修改同一行数据,必须等全局锁释放。如果等不到,就会抛出全局锁获取超时的异常,这个就是我们在报错表里提到的TimeoutException场景之一。所以如果你的下单接口在高并发下频繁超时报错,除了数据库慢,也可以往全局锁竞争这个方向排查。
读隔离上,AT模式的默认全局隔离级别是读未提交。也就是说,一阶段提交后、二阶段回滚前,其他事务是可能读到未提交的中间数据的。如果业务上不能接受脏读,可以在查询语句后面加for update,通过数据库本身的锁机制配合全局锁,做到读已提交。
这块内容面试官一般会追问一两个场景题,比如“如果两个全局事务同时改同一行会怎样”,你只要记住全局锁互斥这个核心,基本就能答到点上。
做谷粒商城seata这一章,我个人最大的体会是:卡住你的往往不是代码逻辑,而是版本和配置的多层对应关系。Spring Cloud Alibaba版本、seata-all版本、seata-server版本、nacos地址、事务分组名,这五个变量里任何一个出问题,报错都不一样,排查的难处就在这里。
最后再分享两个小技巧。第一个,改完seata-server的配置后,除了重启,最好顺手把seata-server目录下的logs和sessionStore这些临时文件清一下,不然某些状态缓存会导致新配置不生效,看起来就像改了个寂寞。第二个,排查问题时,比如你开了debug日志,启动seata-server时加-Dseata.log.level=debug参数可以获得更详细的日志输出,对定位nacos注册、事务分支这类问题很有帮助。
谷粒商城做到这一步,分布式事务的坑算是踩过去了。后面Redis缓存、搜索、秒杀那一堆坑,咱们接着慢慢聊。