0

    EventBus异常处理引发的异常信息混乱问题

    2023.07.06 | admin | 194次围观

    背景问题描述

    最近发现在前端页面操作失败时,展示的错误信息是

    {
        "errorCode": "A0001",
        "errorMessage": "运行时异常:Transaction rolled back because it has been marked as rollback-only",
        "data": null
    }

    而实际处理报错的地方是抛出的业务Exception,异常信息是明确指定的业务码,类似如下:

    throw new BaseException(ClientResultCodes.USER_ALREADY_EXIST);

    这样的话,正常情况下,前端展示的应该是代码中指定的错误码,而不应该是上述事务回滚的异常信息。

    代码结构抽象

    我们的代码结构抽象来看是这样的

    public class TestController {
    	// 入口
    	public Response create(CreateAParam param) {
          return serviceA.createA(param);
      }
    }
    @Transactional
    public class ServiceA {
    	public Response createA(CreateAParam param) {
          // A模块相关操作
          // 数据库操作1
          // 数据库操作2
          // ...
          // 其他模块操作
          eventBus.post(eventMessageB)
          eventBus.post(eventMessageC)
          eventBus.post(eventMessageD)
        	return Response.success();
      }
    }
    @Transactional
    public class ServiceB {
    	public Response createB(CreateBParam param) {
      	...
      	if (null != xxx) {
            throw new BaseException(ClientResultCodes.USER_ALREADY_EXIST);
        }
      	...
    	}
    }
    @Transactional
    public class ServiceC {
    	public Response createC(CreateCParam param) {
      	...
      	...
    	}
    }
    @Transactional
    public class ServiceD {
    	public Response createD(CreateDParam param) {
      	...
      	...
    	}
    }

    大致流程是这样,入口controller调用了serviceA,serviceA中完成本模块的一些数据库操作;但是业务上要求在完成A的操作的时候要同时完成BCD操作,而BCD是其他模块;为了避免直接在serviceA中依赖serviceB、serviceC、serviceD,代码中使用了Guava中的EventBus,在serviceA中完成本模块的操作后,通过EventBus发布事件,通过订阅模式,在每个Subscriber中处理B、C、D相关的操作,这样从代码层面解耦。

    我们的本质目的是代码解耦,因此使用的EventBus是同步模式,也就是使用同一个线程处理serviceA、subscriber以及serviceB、C、D中的代码,

    从现象来看,是ServiceB中的if判断处抛出了业务异常,但前端页面显式的确实Spring事务处理相关的信息

    分析现象事实

    当前先总结出以下事实:

    底层抛出异常的地方是ServiceB中的if判断EventBus使用的是同步模式事务最终回滚了网页找不到了错误信息连接失败,即serviceA模块本身的数据库操作没有更新到数据库猜测及验证猜想一

    基于上述现象,做了简单猜想:

    ServiceB中的if判断处抛出异常ServiceA的createA方法从eventBus.post(eventMessageB)处中断执行ServiceA的createA方法上的事务处理捕获到异常,回滚事务

    这样能解释的通为什么事务最终会回滚,但解释不同为什么前端错误信息显示的不是业务信息

    验证

    代码debug看了下,发现上述猜想第2点并不正确,代码并没有在eventBus.post(eventMessageB)处中断,而是继续执行,一直执行到了最后的return部分。

    猜想二

    既然代码没有在eventBus.post(eventMessageB)处中断,那就说明ServiceB中的if判断处抛出的异常在此处被捕获了,没有继续往上抛出,因此做了如下猜想:

    EventBus捕获了异常且没有往上抛,导致代码继续执行ServiceA中的createA方法执行成功

    既然createA方法执行成功了,那createA方法上的事务应该会提交,但最终结果显示事务又是回滚了的,这样就相互矛盾了

    源码分析

    基于以上事实,仅凭简单猜想似乎无法解释的通网页找不到了错误信息连接失败,因此详细debug分析下EventBus及Spring源码

    EventBus源码分析

    代码中使用的EventBus是guava 29.0-jre版本中的,代码概要分析如下:

    由于使用的是同步模式,这里的dispatcher的类型是ImmediateDispatcher,进入ImmediateDispatcher的dispatch方法

    最终进入dispatchEvent方法

    这里的1处,executor实际是DirectExecutor类型,它也实现了jdk中的Executor接口,代码如下:

    从这里可以验证,EventBus是同步模式,验证了现象事实中的第二点。继续回到上图第二处,这里最终会触发Subscriber订阅代码的执行,即会执行ServiceB中的createB方法调用,抛出BaseException后,被上述3处代码捕获,进而进入到Eventbus的异常处理部分,继续进入bus.handleSubscriberException部分:

    这里先是调用exceptionHandler.handleException方法,而后又catch了Throwable,先看一下1处的exceptionHandler.handleException方法:

    默认的SubscriberExceptionHandler实现只是打印了一下日志;而2处的catch Throwable部分后续解决方案部分会分析。

    到这里EventBus概要流程就分析完了,可以验证猜想二中的两点是正确的,重新梳理一下客观事实,如下:

    ServiceB中的if判断处抛出异常EventBus捕获了异常且没有往上抛,导致代码继续执行ServiceA中的createA方法执行成功事务最终回滚了,即serviceA模块本身的数据库操作没有更新到数据库

    从新的客观事实可以发现,第3点和第4点是矛盾的,而这个矛盾指向了Spring的事务处理,因此重点分析下Spring的事务处理部分源码

    Spring事务源码分析

    代码中使用的是SpringBoot版本是2.5.5,关联的Spring版本是5.3.10.事务相关代码入口从TransactionInterceptor#invoke方法开始,如下:

    其调用了invokeWithinTransaction方法,实际执行的核心代码如下:

    1处代码最终会执行目标代码,2处代码是目标代码执行抛出异常后的处理逻辑,3处代码是目标代码执行成功后提交事务的逻辑;代码的简易模型抽象如下:

    因为ServiceA和ServiceB的方法上都加了@Transactional注解,因此会两次进入TransactionInterceptor#invoke方法,调用ServiceA的方法前会开启新事务,而调用ServiceB的方法前,根据@Transactional的默认配置,会加入之前的事务,因此,整个调用链使用的是同一个事务,但在上图1、3处对事务处理结果是不一样的,1处由于目标ServiceB中抛出了BaseException,因此进入事务异常处理逻辑;而由于EventBus中将异常捕获了,导致3处没有检查到异常信息,因此进入到事务成功提交逻辑。接下来详细看下事务异常处理和成功提交的逻辑;

    事务异常处理

    由于ServiceB是加入的原有事务,因此,实际执行的是doSetRollbackOnly方法,如下:

    最终只是设置了一个标记

    事务成功提交

    接着回到ServiceA执行成功后,提交事务部分逻辑,代码如下:

    执行并没有进入processCommit,而是执行了processRollback,看下判断条件

    真相

    因为EventBus是同步模式,整个调用链执行使用的是同一个线程,并且ServiceB的事务模式是加入原有的事务,使用的是同一个数据库连接,而之前执行ServiceB的时候设置了rollbackOnly标记为true,所以会进入到processRollback逻辑,也就是尽管ServiceA执行没有抛出异常,但ServiceA上的事务执行的并不是提交,而是回滚。并且这里调用processRollback传递的参数unexpected值为true,在processRollback方法最底部代码如下:

    这里抛出了UnexpectedRollbackException,异常信息

    "Transaction rolled back because it has been marked as rollback-only"

    正是我们在前端看到的错误信息

    解决

    原因已经找到了,接下来是如何解决

    方式一

    由于EventBus处捕获了异常没有继续往上抛,导致ServiceA处的事务执行了提交逻辑,那自然就想到能否让EventBus捕获的异常继续网上抛,很遗憾,行不通,EventBus异常处理逻辑如下:

    这个在前面也提到,EventBus捕获了exceptionHandle抛出的异常,只记录了日志,并没有继续往上抛,而且handleSubscriberException方法签名没有任何修饰符,导致想通过继承EventBus重写该方法也行不通

    方式二

    不想去搞复杂的黑科技来重写EventBus的异常处理逻辑了,直接更换了Spring自带的ApplicationEventPublisher来实现事件的通知,替换了EventBus

    总结

    EventBus的异常处理还是有坑啊,或许不应该这么用吧

    版权声明

    本文仅代表作者观点。
    本文系作者授权发表,未经许可,不得转载。

    发表评论