“分支事务不回滚” 问题的复盘

发现问题

完成上述步骤后,依次启动 Nacos ServerSeata Server 及微服务实例,进行分布式事务处理的测试。然而验证的结果让笔者非常吃惊:分布式事务的处理并未成功。问题的具体表现为:在出现异常后,订单数据未生成,但是商品库存已扣减、购物车中的数据已删除。本来以为是自己看错了,但是经过笔者的多次测试,得到的结果都是如此,分支事务并未被正常处理。

笔者在工作笔记里查到了出现这个问题的确切时间,也想到了当天的情况。发现这个问题的时候,是 2022 年 4 月 29 日下午 4 点左右。

好的,发现问题后该怎么办呢?

尝试解决问题

在刚开始发现这个问题时,笔者并没有觉得这是一个大问题,认为自己可以轻轻松松地解决。当时并没有意识到严重性,也可以说是 “轻敌” 了。

当时主要觉得问题可能出现在配置和整合步骤上,于是做了如下 4 件事情。

  1. 检查整合步骤,看是不是漏掉了哪个环节,或者少了哪些配置,抑或哪个配置项因为粗心没配置好。

  2. 数据库中的表是否有问题。

  3. 项目重启(遇事不决,重启试试)。

  4. 查看日志,确认与 Seata 是否连接通信,是否正常注册 TMRM 等。

因为觉得这是小问题,所以从 2022 年 4 月 29 日下午 4 点左右到 6 点,笔者一直在做上述的 4 件事情。结果就是,没发现步骤有问题,也没发现配置有问题,与 Seata Server 也正常通信,但是 “分支事务不回滚” 的问题依然在。笔者在这个时候其实已经隐隐觉得这可能不是一个小问题了,有点慌乱,但是检查了一段时间没头绪,脑子也乱了,索性就下班回家了。

本来想着第二天上班再处理这个问题,但是被这个问题搞得实在睡不着,那天晚上 11 点半打开电脑继续处理。处理的过程和下午一样,还是觉得可能是哪里不小心漏掉了步骤或配置不对,扩大了检查范围,除检查代码中的配置、数据库外,还检查了 Nacos Server 的配置、Seata Server 的配置,结果发现配置都没问题,也没有遗漏什么。

为什么笔者在发现这个问题时会觉得它是一个小问题,之后一直都在做检查配置项之类的事情?其原因就是之前已经整合过、配置过,而且验证通过,源代码也没问题,分布式事务的处理结果是正确的,就觉得只要整合步骤没有遗漏、配置项正确,分布式事务肯定会被正常处理。同样的代码、同样的配置、同样的测试环境,一个正常,另一个不正常,这的确有些出乎意料。然而,笔者尝试查询了几次问题后,就意识到问题的严重性了。确切地说,当时脑袋里一片空白,前一份整合代码中对分布式事务的处理完全正常,另一份整合代码则完全没反应,笔者有些慌乱了。

分析问题产生的原因

凌晨了,别犟了,换个思路吧!

冷静下来后,笔者觉得不能再骗自己了,这份代码肯定是有问题的,不然分支事务怎么会不回滚?但是确实不知道问题出在哪里,为什么同样的配置和步骤整合到 newbee-mall-cloud 实战项目里就不能用了呢?

然而,再去检查配置、对比代码已经没意义了。既然确定代码有问题,就根据 Seata 运行流程查一下哪里出了问题吧!具体做法是根据微服务实例的运行日志和 Seata Server 的运行日志来定位问题,最终的验证结果如下。

  1. 微服务实例与 Seata Server 通信正常。

  2. 3 个微服务实例都正常注册 TMRM 等。

  3. 全局事务能够正常开启。

  4. 两个分支事务开启的日志一行都没出现。

不管是在微服务实例的运行日志中,还是在 Seata Server 的运行日志中,都没有看到两个分支事务的开启和处理。是的,没有任何信息和踪迹。再去数据库中确认,undo_log 表中也没有数据。虽然不知道哪里出了问题,但是至少有方向了。

全局事务能够正常开启和回滚,而两个分支事务不正常(与 Seata Server 正常通信,但是都没有生效)。到这里已经大致有了眉目,商品微服务和购物车微服务两个微服务实例和 Seata Server 的运行日志中都没有看到任何关于全局事务的信息,这也说明了两个分支事务可能根本就没有注册成功。全局事务正常开启和处理,而两个本应出现的分支事务没有出现,它们之间 “失联” 了。

查看源代码并确定问题所在

从代码层面来说,全局事务和分支事务的联系主要在一个变量上,这个变量就是全局事务的 ID——xid。现在它们 “失联” 了,只能通过这个变量的产生、传递、接收、处理等几个步骤来确认问题在哪里了。

此时,需要检查的内容就确定了下来,整理如下。

  1. 全局事务是否正常开启?xid 是否正确地生成了?

  2. xid 是否正确地传递给下游的调用实例?

  3. 下游的调用实例是否正确地接收了 xid

  4. 接收 xid 后是否正确处理并开启了分支事务?

“问题不清晰,看源代码分析。” 为了确认上述的几个检查内容,还是要用 debug 模式看一下 Seata 处理分布式事务过程中所涉及的源代码,由于涉及的源代码太多,因此笔者挑几个重要节点介绍一下。

对于全局事务是否正常开启、xid 是否正确地生成了,主要跟进了以下两个类的源代码:

  1. io.seata.spring.annotation.GlobalTransactionalInterceptor.java.

  2. 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-webnewbee-mall-cloud-goods-web 两个项目中分别定义 ShopCartServiceWebMvcConfigurerGoodsServiceWebMvcConfigurer 两个类并继承了 WebMvcConfigurationSupport,如果一个拦截器要生效,就需要在这里进行配置。

解决办法就是在这两个项目中配置 SeataHandlerInterceptor 拦截器并使其生效,代码如下:

public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new SeataHandlerInterceptor()).addPathPatterns("/**");
}

好的,到这里,“分支事务不回滚” 的问题就解决完了,一切都正常了。绕了那么一大圈,花费了那么多时间,分析了一堆源代码,结果仅仅是因为没有配置这个拦截器。

总体来说,这个问题围绕 Seata 分布式事务处理中 “全局事务的开启与处理”、“xid 的生成与传递” 两个知识点,如果不熟悉,建议读者看看这部分知识的文章和分析。

其实在真实的业务开发中,也有可能遇到这种情况。比如,写个简单的案例或小功能一切都正常,但是真正拿到企业开发的项目中,就出现了问题。毕竟写案例不会考虑太多,涉及的代码也少,能执行就行,而真实项目中有些是被忽略的或开发者不熟悉的配置,这都是需要注意的地方。

另外,扩展一下这个知识点。除 “未配置 SeataHandlerInterceptor” 会导致 “分支事务不回滚” 的问题外,全局事务失败的原因还有如下几种情形。

  1. 代码中的配置错误或配置项有遗漏,导致报错。处理办法:检查配置,修改正确即可。

  2. 数据源未被 Seata 代理,即未正确配置 io.seata.rm.datasource.DataSourceProxy 类。处理办法:修改代码,手动或自动配置 DataSourceProxy

  3. 依赖版本升级导致全局事务失效,笔者之前遇到过,在从 seata-spring-boot-starter 1.3.0 版本升级到 1.4.2 版本时,Seata 数据源自动配置逻辑的调整导致全局事务失败。处理办法:手动配置一下数据源代理。

本章主要讲解微服务架构项目中整合 Seata 完成分布式事务处理的相关编码过程,对实战部分的讲解做补充和优化,还对处理分布式事务代码时实际遇到的一个 “坑” 做了复盘,详细地记录了遇到问题后笔者的处理过程和思考过程,希望对读者有一些启发。