“分支事务不回滚” 问题的复盘
发现问题
完成上述步骤后,依次启动 Nacos Server
、Seata Server
及微服务实例,进行分布式事务处理的测试。然而验证的结果让笔者非常吃惊:分布式事务的处理并未成功。问题的具体表现为:在出现异常后,订单数据未生成,但是商品库存已扣减、购物车中的数据已删除。本来以为是自己看错了,但是经过笔者的多次测试,得到的结果都是如此,分支事务并未被正常处理。
笔者在工作笔记里查到了出现这个问题的确切时间,也想到了当天的情况。发现这个问题的时候,是 2022 年 4 月 29 日下午 4 点左右。
好的,发现问题后该怎么办呢?
尝试解决问题
在刚开始发现这个问题时,笔者并没有觉得这是一个大问题,认为自己可以轻轻松松地解决。当时并没有意识到严重性,也可以说是 “轻敌” 了。
当时主要觉得问题可能出现在配置和整合步骤上,于是做了如下 4 件事情。
-
检查整合步骤,看是不是漏掉了哪个环节,或者少了哪些配置,抑或哪个配置项因为粗心没配置好。
-
数据库中的表是否有问题。
-
项目重启(遇事不决,重启试试)。
-
查看日志,确认与
Seata
是否连接通信,是否正常注册TM
、RM
等。
因为觉得这是小问题,所以从 2022 年 4 月 29 日下午 4 点左右到 6 点,笔者一直在做上述的 4 件事情。结果就是,没发现步骤有问题,也没发现配置有问题,与 Seata Server
也正常通信,但是 “分支事务不回滚” 的问题依然在。笔者在这个时候其实已经隐隐觉得这可能不是一个小问题了,有点慌乱,但是检查了一段时间没头绪,脑子也乱了,索性就下班回家了。
本来想着第二天上班再处理这个问题,但是被这个问题搞得实在睡不着,那天晚上 11 点半打开电脑继续处理。处理的过程和下午一样,还是觉得可能是哪里不小心漏掉了步骤或配置不对,扩大了检查范围,除检查代码中的配置、数据库外,还检查了 Nacos Server
的配置、Seata Server
的配置,结果发现配置都没问题,也没有遗漏什么。
为什么笔者在发现这个问题时会觉得它是一个小问题,之后一直都在做检查配置项之类的事情?其原因就是之前已经整合过、配置过,而且验证通过,源代码也没问题,分布式事务的处理结果是正确的,就觉得只要整合步骤没有遗漏、配置项正确,分布式事务肯定会被正常处理。同样的代码、同样的配置、同样的测试环境,一个正常,另一个不正常,这的确有些出乎意料。然而,笔者尝试查询了几次问题后,就意识到问题的严重性了。确切地说,当时脑袋里一片空白,前一份整合代码中对分布式事务的处理完全正常,另一份整合代码则完全没反应,笔者有些慌乱了。
分析问题产生的原因
凌晨了,别犟了,换个思路吧!
冷静下来后,笔者觉得不能再骗自己了,这份代码肯定是有问题的,不然分支事务怎么会不回滚?但是确实不知道问题出在哪里,为什么同样的配置和步骤整合到 newbee-mall-cloud
实战项目里就不能用了呢?
然而,再去检查配置、对比代码已经没意义了。既然确定代码有问题,就根据 Seata
运行流程查一下哪里出了问题吧!具体做法是根据微服务实例的运行日志和 Seata Server
的运行日志来定位问题,最终的验证结果如下。
-
微服务实例与
Seata Server
通信正常。 -
3 个微服务实例都正常注册
TM
、RM
等。 -
全局事务能够正常开启。
-
两个分支事务开启的日志一行都没出现。
不管是在微服务实例的运行日志中,还是在 Seata Server
的运行日志中,都没有看到两个分支事务的开启和处理。是的,没有任何信息和踪迹。再去数据库中确认,undo_log
表中也没有数据。虽然不知道哪里出了问题,但是至少有方向了。
全局事务能够正常开启和回滚,而两个分支事务不正常(与 Seata Server
正常通信,但是都没有生效)。到这里已经大致有了眉目,商品微服务和购物车微服务两个微服务实例和 Seata Server
的运行日志中都没有看到任何关于全局事务的信息,这也说明了两个分支事务可能根本就没有注册成功。全局事务正常开启和处理,而两个本应出现的分支事务没有出现,它们之间 “失联” 了。
查看源代码并确定问题所在
从代码层面来说,全局事务和分支事务的联系主要在一个变量上,这个变量就是全局事务的 ID——xid
。现在它们 “失联” 了,只能通过这个变量的产生、传递、接收、处理等几个步骤来确认问题在哪里了。
此时,需要检查的内容就确定了下来,整理如下。
-
全局事务是否正常开启?
xid
是否正确地生成了? -
xid
是否正确地传递给下游的调用实例? -
下游的调用实例是否正确地接收了
xid
? -
接收
xid
后是否正确处理并开启了分支事务?
“问题不清晰,看源代码分析。” 为了确认上述的几个检查内容,还是要用 debug
模式看一下 Seata
处理分布式事务过程中所涉及的源代码,由于涉及的源代码太多,因此笔者挑几个重要节点介绍一下。
对于全局事务是否正常开启、xid
是否正确地生成了,主要跟进了以下两个类的源代码:
-
io.seata.spring.annotation.GlobalTransactionalInterceptor.java.
-
io.seata.tm.api.TransactionalTemplate.java.
这两个类主要涉及全局事务的开启和处理,感兴趣的读者可以仔细地探索一下。当然,检查结果是这个步骤并没有问题,全局事务正常开启,xid
正确地生成。
难道是 xid
生成了却没有传递给下游?对于这个问题,笔者主要在 debug
模式下跟进了 com.alibaba.cloud.seata.feign.SeataFeignClient.java
这个类的源代码:
@Override
public Response execute(Request request, Request.Options options) throws
IOException {
Request modifiedRequest = getModifyRequest(request);
return this.delegate.execute(modifiedRequest, options);
}
private Request getModifyRequest(Request request) {
String xid = RootContext.getXID();
if (StringUtils.isEmpty(xid)) {
return request;
}
Map<String, Collection<String>> headers = new HashMap<>(MAP_SIZE);
headers.putAll(request.headers());
List<String> seataXid = new ArrayList<>();
seataXid.add(xid);
// 把xid放入请求头中
headers.put(RootContext.KEY_XID, seataXid);
return Request.create(request.httpMethod(), request.url(), headers,
request.body(),
request.charset(), null);
}
向下游微服务实例发送请求是由 SeataFeignClient
类来完成的,在这个类中会对 Request
对象进一步包装,把 xid
放进请求的 header
参数中并传递给下游方法,在 saverOrder()
方法中使用 OpenFeign
调用商品微服务和购物车微服务中的方法前,会对 Request
对象做进一步的包装,再发起请求。当然,检查结果是这个步骤并没有问题,xid
被放入 header
参数中并传递给下游。
在查找问题的过程中,笔者还在购物车微服务的 deleteByCartItemIds()
方法中添加了 request
参数,主要是为了查看该对象中是否有 xid
参数,代码如下:
@DeleteMapping("/shop-cart/deleteByCartItemIds")
@ApiOperation(value = "批量删除购物项", notes = "")
public Result<Boolean> deleteByCartItemIds(@RequestParam("cartItemIds") List<Long> cartItemIds, HttpServletRequest request) {
if (CollectionUtils.isEmpty(cartItemIds)) {
return ResultGenerator.genFailResult("error param");
}
return ResultGenerator.genSuccessResult(newBeeMallShoppingCartService.deleteCartItemsByCartIds(cartItemIds) > 0);
}
在 debug
模式下查看了 Request
对象中的内容,最终也确认了 header
参数中是有 xid
参数的,并进一步确认了上游微服务实例(订单微服务)正确地把 xid
传递给下游微服务,而且下游微服务实例也接收了 xid
参数,说明接收也没问题。
xid
的生产、传递、接收都没问题。到这里又卡住了,几个步骤好像都正常。笔者还是有些不敢相信这个结果,如果这些步骤都正常,全局事务和分支事务怎么会 “失联” 呢?
于是赶紧在代码中又加上了打印 RootContext.getXID()
的语句,代码如下:
@DeleteMapping("/shop-cart/deleteByCartItemIds")
@ApiOperation(value = "批量删除购物项", notes = "")
public Result<Boolean> deleteByCartItemIds(@RequestParam("cartItemIds") List<Long> cartItemIds, HttpServletRequest request) {
// 通过 RootContext 获取 xid 字段
System.out.println("RootContext.getXID()="+ RootContext.getXID());
if (CollectionUtils.isEmpty(cartItemIds)) {
return ResultGenerator.genFailResult("error param");
}
return ResultGenerator.genSuccessResult(newBeeMallShoppingCartService.deleteCartItemsByCartIds(cartItemIds) > 0);
}
如果正确接收到上游微服务实例传递的 xid
,那么这个变量肯定不会有问题。重新启动项目并请求 /saveOrder
验证整个分布式事务流程,打印 RootContext.getXID()
的结果是 null
,证明下游微服务实例确实没有正确地接收 xid
。
为什么会这样呢?
此时,答案已经呼之欲出了。全局事务 ID——xid
正常地生成并正确地传递给下游微服务实例,看似成功地被下游微服务实例接收了,但是只是接收,并没有接收到。上游微服务实例传递了变量过去,下游微服务实例接收变量,但是没接收到。“没接收到” 的意思就是到达下游微服务实例的请求中是有 xid
参数的,但是 xid
参数并没有被正常处理。xid
的传递在终点出现了问题,导致全局事务和分支事务 “失联” 了。
解决问题
下游微服务实例中 xid
参数接收和处理的类在哪里呢?在 com.alibaba.cloud.seata.web.SeataHandlerInterceptor
类中,源代码及注释如下:
public class SeataHandlerInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory
.getLogger(SeataHandlerInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) {
// 获取绑定后的 xid
String xid = RootContext.getXID();
// 获取请求头中的 xid
String rpcXid = request.getHeader(RootContext.KEY_XID);
if (log.isDebugEnabled()) {
log.debug("xid in RootContext{}xid in RpcContext{}", xid, rpcXid);
}
// 如果未绑定
if (StringUtils.isBlank(xid) && rpcXid != null) {
// 则绑定 xid
RootContext.bind(rpcXid);
if (log.isDebugEnabled()) {
log.debug("bind {} to RootContext", rpcXid);
}
}
return true;
}
// 省略部分代码
}
这个拦截器可以说是 xid
传递过程的终点,下游微服务实例会在这里接收请求头中的 xid
参数并进行绑定操作。如果这个拦截器中的方法正常运行,那么 xid
的传递就不会出问题,全局事务和分支事务也不会 “失联” 了。
在查找问题的过程中,笔者在这个拦截器的 preHandle()
方法中打了断点,发现在验证过程中根本没有进入这些断点,也就是说,这个拦截器根本没起作用。为什么这个拦截器没起作用呢?因为没有配置这个拦截器。newbee-mall-cloud-shop-cart-web
和 newbee-mall-cloud-goods-web
两个项目中分别定义 ShopCartServiceWebMvcConfigurer
和 GoodsServiceWebMvcConfigurer
两个类并继承了 WebMvcConfigurationSupport
,如果一个拦截器要生效,就需要在这里进行配置。
解决办法就是在这两个项目中配置 SeataHandlerInterceptor
拦截器并使其生效,代码如下:
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SeataHandlerInterceptor()).addPathPatterns("/**");
}
好的,到这里,“分支事务不回滚” 的问题就解决完了,一切都正常了。绕了那么一大圈,花费了那么多时间,分析了一堆源代码,结果仅仅是因为没有配置这个拦截器。
总体来说,这个问题围绕 Seata
分布式事务处理中 “全局事务的开启与处理”、“xid 的生成与传递” 两个知识点,如果不熟悉,建议读者看看这部分知识的文章和分析。
其实在真实的业务开发中,也有可能遇到这种情况。比如,写个简单的案例或小功能一切都正常,但是真正拿到企业开发的项目中,就出现了问题。毕竟写案例不会考虑太多,涉及的代码也少,能执行就行,而真实项目中有些是被忽略的或开发者不熟悉的配置,这都是需要注意的地方。
另外,扩展一下这个知识点。除 “未配置 SeataHandlerInterceptor
” 会导致 “分支事务不回滚” 的问题外,全局事务失败的原因还有如下几种情形。
-
代码中的配置错误或配置项有遗漏,导致报错。处理办法:检查配置,修改正确即可。
-
数据源未被
Seata
代理,即未正确配置io.seata.rm.datasource.DataSourceProxy
类。处理办法:修改代码,手动或自动配置DataSourceProxy
。 -
依赖版本升级导致全局事务失效,笔者之前遇到过,在从
seata-spring-boot-starter
1.3.0 版本升级到 1.4.2 版本时,Seata
数据源自动配置逻辑的调整导致全局事务失败。处理办法:手动配置一下数据源代理。
本章主要讲解微服务架构项目中整合 Seata
完成分布式事务处理的相关编码过程,对实战部分的讲解做补充和优化,还对处理分布式事务代码时实际遇到的一个 “坑” 做了复盘,详细地记录了遇到问题后笔者的处理过程和思考过程,希望对读者有一些启发。