Feign调用下Seata全局事务XID为空?从原理到排查一次说清

发布时间:2026/10/24 1:04:27
Feign调用下Seata全局事务XID为空?从原理到排查一次说清 搞分布式事务最难受的排查不是报错满天飞而是“看起来一切正常但就是没生效”。尤其Feign调用这种场景服务A调服务BA侧日志里全局事务XID清清楚楚B侧日志里却找不到任何全局事务痕迹写库操作照样提交等异常发生的时候才发现该回滚的没回滚。如果你也遇到过“被调用服务Seata事务不开启”或者直接看到XID为空这篇就是给你写的。先说清楚一件事Seata的XID传播不是“自动魔法”。它依赖两条腿走路——RPC框架把XID塞进请求头传到下游下游再把它解出来绑定到当前线程上下文。Feign走的是HTTP协议所以XID需要挂在Header上传递。只要中间任何一个环节被覆写、被绕过、被拦截器吞掉下游就拿不到XID全局事务自然就断了。这篇文章我会从现象排查到源码原理带你把整条链路梳一遍。1. 先看现象XID为空和事务不开启其实是一回事1.1 故障现场的典型日志与表现最早遇到这个问题是某服务中心通过Feign调用另一个服务的扣减接口。当时做了个全链路回滚验证人为在B服务的方法里抛出异常期望A和B的数据库操作应当整体回滚。实测结果是A侧数据回滚了B侧扣减却留下来了。当时翻日志A服务的日志里能看到如下几行Begin new global transaction: 192.168.1.1:8091:123456789 Register TCC resource: jdbc:mysql://xxxB服务日志翻完没有找到任何Branch Register相关的日志也没有“global tx id”之类的关键字。于是临时在B服务接口里加了一行代码打印根上下文String xid RootContext.getXID(); log.info(当前线程XID {}, xid);结果输出的是当前线程XID null。这一下就把范围缩小了不是B服务Seata没装好而是XID压根没传到B服务这一层。1.2 XID在Seata中扮演的角色Seata的工作机制里XID是贯穿全链路的老大。它包含事务组标识、事务协调器地址、全局事务编号三部分。发起方在方法上加GlobalTransactional进入方法时Generate一个XID并绑定到当前线程的RootContext之后每一次分支事务注册、提交、回滚都要拿着这个XID去找事务协调器。被调用服务想参与全局事务前提只有一个当前执行SQL的线程绑定了有效的XID。这里很多人会误解为“被调用方必须加GlobalTransactional”。其实不是。被调用方只要满足两个条件就能加入全局事务请求链路把XID透传到了被调用服务并成功绑定到RootContext被调用服务的数据源被Seata数据源代理包装过。加不加GlobalTransactional只决定“这个服务是不是全局事务的发起方”。哪怕B服务里只有一个普普通通的Transactional只要XID过来了代理数据源在执行SQL前就会自动检查RootContext里的XID从而注册成分支事务。所以“事务不开启”和“XID为空”本质是同一个故障的两面XID没传到位后面的一切都不会发生。2. 第一轮排查被调用服务的Seata依赖与配置2.1 依赖缺失与版本混乱排查的第一个门槛是B服务到底有没有接入Seata的相关依赖。乍一听觉得不可能但实际中我就见到过某个服务是后来拆出来的从老服务拷贝代码时漏掉了pom里的Seata依赖但业务代码里的Transactional照样能用所以一直没暴露问题。直到做全局事务验证才发现它压根没连上事务协调器。需要确认的依赖主要有两种典型形态!-- Spring Boot 接入方式 -- dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId /dependency或是直接依赖Seata Spring Boot Starterdependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId /dependency另外要注意版本一致性。老项目里如果本身引了seata-all又额外引了starter很可能出现全局事务扫描器重复创建、拦截器不生效之类的问题。我当时排查那个线上问题时发现B服务引的是1.4.2版本A服务引的是1.5.2两个版本的RootContext实现存在差异导致Feign拦截器生成的Header头字段虽然一样但后续解析时因为类加载版本不同直接没绑定上。这类问题非常隐蔽。建议的做法是整个微服务体系的Seata版本用同一套不管是server端还是client端尽量对齐。版本不一致不一定会报错但会造成各种“静默失效”比报错更难查。2.2 配置项检查事务分组与注册中心依赖没问题接下来查配置。B服务想要正常参与全局事务至少需要告诉Seata我是谁、我归哪个事务组、我去哪里找事务协调器。一个最小的Seata客户端配置长这样seata: enabled: true application-id: ${spring.application.name} tx-service-group: order-tx-group enable-auto-data-source-proxy: true registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: seata-server group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: seata-server group: SEATA_GROUP同时还需要在配置中心的service.vgroupMapping里配置事务分组与Seata服务集群的映射关系。举例来说seata: service: vgroup-mapping: order-tx-group: default grouplist: default: 127.0.0.1:8091这里有个极易出错的地方服务A和服务B的tx-service-group必须一致否则A发起全局事务时B拿着XID去找事务协调器因为分组不同会被判定为不可用。有些团队每个服务各配各的分组名这本身没问题前提是全局事务链路里所有参与方都指向同一个事务协调器逻辑集群。排查时可以看B服务启动日志中Seata相关的初始化信息。正常情况会看到类似Initializing GlobalTransactionScanner: [order-tx-group] Transaction Manager Client is initialized, ...如果日志里连GlobalTransactionScanner都没出现大概率是依赖没引进去或者自动配置没生效。2.3 数据源代理是否生效配置通过后另一个隐蔽点是数据源代理。B服务可能Seata依赖和配置都正常但DataSource没有被Seata代理包装。只要没代理RootContext里的XID就算绑定成功也白搭因为SQL执行过程中不会去注册分支事务。绝大多数Spring Boot项目里seata.enable-auto-data-source-proxy: true会开启自动代理。但有一种情况会覆盖它业务代码里手动使用Bean重新定义了数据源且没有用DataSourceProxy做包装。比如有些人为了做多数据源或动态数据源写了一个自定义的DataSourceConfig类Bean public DataSource dataSource() { DruidDataSource dataSource new DruidDataSource(); dataSource.setUrl(url); dataSource.setUsername(username); dataSource.setPassword(password); return dataSource; }这种方式如果被自动配置覆盖Seata可能不会对你的DataSource做代理。虽然SeataAutoDataSourceProxyCreator理论上会拦截所有数据源Bean创建后置处理但在某些自定义初始化顺序下确实会出现代理“漏网”的情况。验证方法很简单在B服务接口里加个断点或者在启动时打印数据源类型PostConstruct public void printDataSource() { log.info(当前DataSource类型: {}, dataSource.getClass().getName()); }如果输出里能明显看到包含io.seata.rm.datasource.DataSourceProxy字样说明代理生效。如果打印的还是原生Druid或HikariDataSource类型那就要检查为什么代理没生效了。3. 核心排查点Feign调用链路的XID透传3.1 Seata如何通过Feign传递XID先聊原理。Seata对Feign的集成核心是一个Feign的请求拦截器。它做的事情很简单发起HTTP调用前从当前线程的RootContext中取XID写入请求Header。Header的名字是TX_XID这是Seata内部定义的分布式事务常量。被调用服务收到请求后再通过相同的拦截器从Header中把XID取出来绑定到自己线程的RootContext上。这套机制在1.4.2版本及以前和1.5.0之后实现略有差异。1.4.2之前的版本通常由用户手动注入SeataFeignInterceptor或由spring-cloud-starter-alibaba-seata引入自动配置类。1.5.0之后引入了更完整的自动配置一般不需要额外写代码。那为什么线上会失败核心在于Feign的拦截器是基于Feign客户端实例的如果项目中自定义了Feign的Builder、RequestInterceptor或者自定义了Feign配置类就可能把Seata原本的拦截器覆盖掉。常见写法有Bean public RequestInterceptor requestInterceptor() { return template - { template.header(X-User, userId); }; }这里头没有直接冲突但如果有人粗暴地在configureFeignBuilder里重新new Feign.Builder()就可能丢掉Seata的拦截器链。3.2 验证XID是否真的在Header里排查XID透传最笨也最有效的办法在调用方Feign Client对应接口前打一个Feign拦截器打印所有Header或者在B服务侧加一个Spring MVC拦截器把进来的Header全部打出来。调用方的Feign拦截器打印Bean public RequestInterceptor seataXidPrintInterceptor() { return template - { template.headers().forEach((k, v) - { log.info(Feign请求头: {}{}, k, v); }); }; }被调用方的Spring MVC拦截器打印public class XidCheckInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { EnumerationString headers request.getHeaderNames(); while (headers.hasMoreElements()) { String headerName headers.nextElement(); log.info(B服务收到Header: {}{}, headerName, request.getHeader(headerName)); } return true; } }实测时如果发现请求头里根本没有TX_XID那问题就锁死在调用方Feign侧。如果Header有TX_XID而B服务的RootContext.getXID()仍然为空那问题多在被调用方的Seata拦截器或Spring MVC配置上。3.3 被调用方没有自动解开XID的情况有一种情况经常被忽略B服务虽然接到了TX_XIDHeader但不会主动绑定到RootContext。Seata提供了一个Web MVC拦截器TransactionPropagationInterceptor专门负责从HttpServletRequest的Header中解析XID然后执行RootContext.bind(xid)。如果你的项目里自定义了WebMvcConfigurer并且重写了addInterceptors方法但没有调用super.addInterceptors(registry)或者没有把Seata的拦截器主动加进去就会出现“Header有XID但上下文为空”的情况。查看B服务是否注册了Seata MVC拦截器的方式Autowired private WebMvcConfigurer seataWebMvcConfigurer; PostConstruct public void printInterceptors() { // 实际项目中可以通过注入WebMvcConfigurer并打印注册记录 }更稳的做法如果你自己写了WebMvc配置类记得在addInterceptors里追加Seata的TransactionPropagationInterceptorOverride public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new TransactionPropagationInterceptor()); // 其他自定义拦截器 }基于最近项目里遇到的几例多数XID为空并不是因为代码复杂而是自定义MVC配置或自定义Feign配置把自动装配的东西给“顶掉”了。4. 深入定位为什么XID到了事务还是不生效4.1 确认被调用方的事务入口前面说了被调用方参与全局事务不一定要GlobalTransactional。但有一种业务场景需要区分清楚B服务接口方法上如果有GlobalTransactional那么哪怕B自己生成一个新的XID也会在全局事务协调器注册一个根事务。如果上游XID已存在Seata默认传播行为是直接加入上游事务而不是再起一个独立全局事务。所以这个注解加法本身不导致“两个全局事务”问题。B服务接口方法上如果只有Transactional在XID绑定成功的情况下走的是Seata代理数据源SQL会注册为全局事务分支在XID绑定失败的情况下退化为普通本地事务这也是很多人误以为“Transactional就够了”的原因但实际上它是否加入全局事务完全取决于XID在不在。所以排查时要先确认B服务的写库方法上到底写的什么注解。如果连Transactional都没加加不加全局事务就先不谈了那只是JDBC事务缺了个边界属于另一类问题。4.2 分支事务没有注册的真正原因XID绑定成功但分支没注册的情况最典型的是数据源代理失效。我见过一个项目装配了Seata依赖XID也通过Header传到了B服务控制台日志能看到RootContext.getXID()返回了正确值。但是执行SQL时没有任何分支事务注册记录。查到最后发现B服务用来连数据库的连接池是自己手写的封装没有走Spring Boot的自动数据源创建流程。Seata的自动数据源代理只处理DataSource类型的Bean如果这个Bean不是标准DataSource类型或者初始化时间早于Seata代理器那代理就绕过去了。做法有两种一是不要手动覆盖数据源直接依赖Spring Boot的自动配置spring: datasource: url: jdbc:mysql://... username: ... password: ...Seata的SeataAutoDataSourceProxyCreator会在Bean初始化后置增强阶段自动把数据源替换为DataSourceProxy。二是如果确实需要自定义数据源务必将它包装成Seata的代理Bean public DataSource dataSource() { DruidDataSource ds new DruidDataSource(); ... return new DataSourceProxy(ds); }这个DataSourceProxy是Seata AT模式的核心内部持有ConnectionProxy执行SQL时通过拦截Connection来注册分支事务。4.3 自调用、异步线程与XID丢失还有一种极其隐蔽的场景B服务的入口方法成功绑定了XID但它内部调用了另一个本地方法这个方法标了Transactional。由于Spring AOP代理只在外部调用时生效自调用不会经过代理事务边界就断了。跟XID关系不大但会让团队误判为Seata没生效。更常见的是异步线程问题。如果B服务把一部分数据库操作交给了线程池执行线程池里的线程不会自动继承RootContext的XID。从Seata 1.5.x开始XID的存储从普通ThreadLocal升级为支持异步传播的TransmittableThreadLocal但前提是你开启了对应的采样配置并且线程池确实用了TTL。实际上很多人用的是JDK原生ThreadPoolExecutor子线程就是个黑盒。解决办法// 异步任务前手动传递XID String xid RootContext.getXID(); executor.submit(() - { RootContext.bind(xid); try { // 业务代码 } finally { RootContext.unbind(); } });这个手动绑定是治标不治本但对局部场景很有效。大招是直接用Seata的异步链路解决方案或者尽量别让分支事务在子线程里执行。4.4 事务传播行为带来的“中断”再提一个容易忽视的点B服务内部在某些方法上用了Transactional(propagation Propagation.REQUIRES_NEW)。如果在全局事务分支里强行开启一个不受当前事务控制的新事务而且这个新事务的数据源是同一个逻辑数据源由于Seata代理会为每个连接绑定XIDREQUIRES_NEW并不完全等同于脱离全局事务。它更多影响的是本地事务的挂起恢复。如果真的想脱离全局事务需要在Seata层面单独控制连接比如使用非代理数据源执行特定SQL或者通过命名区分数据源。这类需求不常见但一旦出现排查很容易被“事务不开启”的帽子带偏方向。遇到这种场景请先回归最朴素的检查这个SQL执行的连接是不是代理连接当前线程的XID是否还存在。5. 标准排查步骤与日志速查5.1 五段式全链路排查清单把这几年遇到的各种原因汇总成一张速查表方便按序检查检查项方法预期结果异常原因被调方Seata依赖检查pom.xml中seata相关依赖存在且版本与全局一致依赖缺失或版本不一致事务分组一致性对比各服务yml中tx-service-group保持一致分组名称不一致导致找不到TC注册中心与配置中心查看启动日志中的配置加载信息成功从配置中心拉取配置registry/config配置错误Feign请求Header调用侧打印Header包含TX_XID字段自定义Feign配置覆盖拦截器MVC入口XID绑定被调侧打印RootContext.getXID()非空自定义WebMvcConfigurer覆盖数据源代理打印DataSource类型包含DataSourceProxy手动数据源未包装代理分支事务注册查看B侧SQL执行日志看到Branch Register记录连接未代理或XID绑定失败异步线程上下文检查线程池代码子线程能获取XID未手动绑定XID或未使用TTL我一般会按这个顺序直接跑下来最快一次20分钟定位最慢一次卡在异步线程上浪费了快一天。5.2 关键日志与关键字速查熟悉日志关键字能极大加快排查速度。打印全局事务开启的日志通常在服务入口Begin new global transaction: ip:port:transactionIdTM注册全局事务成功Registering GlobalTransaction被调用方成功绑定XID后第一次执行SQL时会有分支注册调用发生。Seata在debug级别下能打出Branch transaction register: globalIdxxx branchIdxxx如果全局事务正常提交会在TM侧看到Global transaction commit: xxx异常回滚时Global transaction rollback: xxx当排查XID为空时重点用debug级别日志开启以下包的日志输出logging: level: io.seata: debug com.alibaba.feign: debug org.springframework.cloud.openfeign: debug建议线上别直接开debug但在测试环境必须开。Seata在debug级别下会打印很多有价值的上下文切换细节特别是RootContext绑定与解绑的调用栈。5.3 快速验证接口与临时手段线上快速验证时我习惯给服务临时加两个小接口调用方接口RestController public class DebugController { GetMapping(/debug/xid) public String debugXid() { return String.valueOf(RootContext.getXID()); } }被调用方接口RestController public class DebugController { GetMapping(/debug/xid) public String debugXid(HttpServletRequest request) { return Header TX_XID request.getHeader(TX_XID) , RootContext XID RootContext.getXID(); } }这两个接口配合使用一个看调用前上下文一个看调用后接收结果。顺带还能验证如果直接curl带TX_XID请求头访问B服务B服务是否能正确绑定能进一步把问题隔离到A侧还是B侧。如果B服务能在带上TX_XID头的情况下识别并绑定说明B侧没问题如果还是绑不上那就要单独看B服务自己的拦截器注册链路。6. 常见原因汇总与个人经验6.1 Top榜故障原因从多个项目排查经验看Feign Seata XID为空的前五名原因大致是排序原因典型特征1自定义Feign配置覆盖了Seata拦截器调用方请求头没有TX_XID2被调方自定义WebMvcConfigurer覆盖了拦截器有TX_XID但RootContext为空3事务分组不一致各服务tx-service-group对不上4数据源被手动覆盖且未包代理XID非空但分支未注册5异步线程丢失上下文主线程有XID子线程为空其中前三项占到了绝大多数。有意思的是这些问题在单测环境完全看不出来因为单测根本不走Feign和HTTP链路。6.2 我踩过的一个真实深坑有个项目Feign调用方和被调用方都配置了SeataHeader里也打印出TX_XID了B服务RootContext却是空。反复排查后发现B服务虽然引入了spring-cloud-starter-alibaba-seata但项目里还同时引入了spring-cloud-starter-openfeign的旧版本两个starter共同作用的类加载顺序出现了问题。B服务里Seata自己的MVC拦截器没有被实例化。最后解决办法更简单也更让人无奈把B服务里那些不需要的starter依赖从pom里清掉保留干净的环境后Seata自动配置就正常加载了。所以遇到问题先别急着改代码对照依赖清单过一遍排除重复引包和版本覆盖。另外有一回被调用方用的不是Spring MVC而是WebFluxSeata传统的MVC拦截器完全没生效。这个场景就需要单独适配不引入WebFlux的Seata集成方案前全局事务是接不进去的。如果项目里混用了MVC和WebFlux排查思路完全不一样千万别用同一套结论套。6.3 避坑建议与收尾心得最后分享几个踩过坑之后总结下来的经验。第一Feign相关的配置只通过配置项控制不要轻易自定义Feign.Builder。一旦自己new了BuilderSeata的自动装配拦截器可能被跨过。如果确实需要自定义请务必在Builder上手动添加Seata的RequestInterceptor。第二所有参与分布式事务的服务的Seata版本务必统一最好用一个BOM统一管理。版本不一致造成的静默失效比配置错误更难发现因为代码完全一样表现却可能时好时坏。第三无论什么时候都要建立“验证XID透传”的运维习惯。你可以在网关或调用链工具里增加TX_XID头的透传记录也可以在关键服务的入口日志中打印XID。每次发版前挑一个最核心的Feign调用做一次全链路回滚演练只要有一次回滚成功整条链路的Seata配置大概率是好的。分布式事务这种东西不验证就别上线。Seata的XID传播看着是件小事但它是整个全局事务基座。基座歪了业务代码写得再好也白搭。希望这篇文章能帮你少花几天时间快速定位到是Header丢了、拦截器被覆盖了还是数据源代理失效了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询