记一次UnexpectedRollbackException解决过程
项目内有一个api方法(称之为 ServiceA.methodA() )需要调用一个共通service里的方法(称之为 CommonService.methodB() ),然后由于业务需求需要catch住 CommonService.methodB() 的异常并返回response。
由于项目在config里通过切面配置了对于所有的 @Service 都设置了默认事务为REQUIRED,并且设置了对所有异常都回滚(通过 TransactionInterceptor 和 NameMatchTransactionAttributeSource ),导致如果直接使用 CommonService.methodB() 的话就会因为spring的机制导致抛出UnexpectedRollbackException。
这个机制具体来说,抛出异常时spring会检测到并将当前事务内部的 rollbackOnly 这样一个flag设为true。但是又因为catch住了异常,导致最后结束方法提交事务时spring发现这个flag为true却仍然要提交,于是兜底性的回滚了并抛出UnexpectedRollbackException。
于是我想到了新建事务执行,而项目内正好有一个空壳方法 Helper.execute(Suppiler) 上面加了REQUIRES_NEW,只需代入方法即可。
1 | try { |
但测试后发现仍然还是会抛出UnexpectedRollbackException。这就要具体分析整个流程了:
- Api (
ServiceA) 调用Helper.execute(),此时环境是 Tx1,对应拦截器(指TransactionInterceptor)是拦截器1(用的是NameMatchTransactionAttributeSource)。 Helper上有@Service,拦截器2介入(用的是NameMatchTransactionAttributeSource):- 它看到
ServiceA有Tx1, - 它配置默认使用 REQUIRED。
- 所以它让
Helper加入了 Tx1。
- 它看到
Helper.execute上有@Transactional(propagation = Propagation.REQUIRES_NEW),拦截器3介入(用的是AnnotationTransactionAttributeSource):- 它看到 REQUIRES_NEW。
- 它调用
tm.suspend(Tx1),挂起 Tx1。 - 它调用
tm.begin(),开启 Tx2。
- 执行业务:
CommonService报错,抛出BusinessRuntimeException。
- 拦截器3捕获异常:
- 它持有 Tx2。
- 它执行
tm.rollback(Tx2),Tx2干净地回滚了。 - 它执行
tm.resume(Tx1),Tx1恢复了。 - 关键动作:它必须把异常继续往外抛(因为 AOP 默认行为就是处理完事务抛出异常)。
- 抛出
MyBusinessRuntimeException。
- 拦截器2捕获异常:
- 它接到了注解拦截器抛出来的异常。
- 它持有 Tx1 (因为它之前加入了 Tx1)。
- 它检查回滚规则:RuntimeException -> 需要回滚。
- 它执行
tm.rollback(Tx1)这里的逻辑。 - 但是因为它只是一个“参与者”(Propgation.REQUIRED),它没有资格直接物理回滚 Tx1,它只能通知上级(也就是拦截器1),通过
status.setRollbackOnly()。 - 结果:Tx1 被打上了必须回滚的标签。
- 它继续把异常抛给 Api。
- 回到 Api (ServiceA):
- Catch 住了异常。
- Api 觉得“没问题了”,方法正常结束。
- Spring 试图
tm.commit(Tx1)。 - 发现 Tx1 身上有全局拦截器贴的
RollbackOnly标签。 - 抛出异常
UnexpectedRollbackException。
关于参与者,是因为Spring 事务管理遵循一个基本原则:“谁开启,谁负责(物理)提交/回滚”。只有最外层开启事务的方法,才持有真正的数据库连接(Connection),才有资格调用 JDBC 的
con.rollback()或con.commit()。具体在spring源码中可以看到:
1 | // org.springframework.transaction.support.AbstractPlatformTransactionManager |
那如果把catch放进supplier里呢?
1 | result = Helper.execute(() -> { |
遗憾的是还是一样的结果。再分析下过程:
- Api (
ServiceA) 调用Helper.execute(),此时环境是 Tx1,对应拦截器是拦截器1(用的是NameMatchTransactionAttributeSource)。 Helper上有@Service,拦截器2介入(用的是NameMatchTransactionAttributeSource):- 它默认使用 REQUIRED。
- 它看到
ServiceA有Tx1。 - 所以它让
Helper加入了 Tx1。
Helper.execute上有@Transactional(propagation = Propagation.REQUIRES_NEW),拦截器3介入(用的是AnnotationTransactionAttributeSource):- 它看到 REQUIRES_NEW。
- 它调用
tm.suspend(Tx1),挂起 Tx1。 - 它调用
tm.begin(),开启 Tx2。
CommonService也有@Service,拦截器4介入(用的是NameMatchTransactionAttributeSource)- 它默认使用 REQUIRED。
- 它看到上一层有个Tx2。
- 所以它让
CommonService加入了Tx2。
CommonService报错,抛出BusinessRuntimeException。- 拦截器4捕获到异常。
- 拦截器4发现自己是“参与者”(Joined Tx2)。
- 所以它执行
setRollbackOnly()。 - Tx2 虽然还活着,但被打上了“必须回滚”标签。
- lambda代码里catch住异常,返回结果。
- 回到
Helper.execute所在的拦截器3:- 它看到 Lambda 执行结束了。
- 它看到没有抛出异常(被lambda吞了)。
- 它判断:“嗯,业务执行成功,没有异常。”
- 于是它决定执行 提交(Commit)Tx2。
- 事务管理器:
- 收到 Commit 指令。
- 检查 Tx2 状态。
- 发现:
isRollbackOnly == true(步骤 5 里被打的标签)。 - 判定:“你让我提交一个明明标记了回滚的事务?”
- 结果:抛出
UnexpectedRollbackException。
如果要验证也很简单,在外层再套一个try catch然后打印堆栈即可发现Helper.execute()会抛出个UnexpectedRollbackException。
解决办法其实很简单:
1 | result = Helper.execute(() -> { |
这个方法设的rollback-only并非和事务管理器自动设的rollback-only是同一个,前者在源码里称为localRollbackOnly,后者是globalRollbackOnly。而源码中会先判断如果localRollbackOnly为true的话就直接回滚,不会抛UnexpectedRollbackException。
这个代码在 AbstractPlatformTransactionManager.commit() 里:
1 | public final void commit(TransactionStatus status) throws TransactionException { |
当然另外一种解决办法就是使用编程式事务:
1 | private final TransactionTemplate transactionTemplate; // 构造器注入,此处略 |
但是要注意的是,不能把catch写在 transactionTemplate.execute 里面,否则内部的事务仍然会和前面一样把rollbackOnly设成true然后正常结束试图commit,结果导致UnexpectedRollbackException。
为什么这个catch写在 transactionTemplate.execute 外面了却没有之前那样抛UnexpectedRollbackException呢?主要还是因为它没有前面那么多层拦截器切面干涉:
- Api (
ServiceA) 调用Helper.execute(),此时环境是 Tx1,对应拦截器是拦截器1(用的是NameMatchTransactionAttributeSource)。 Helper.execute上有@Service,拦截器2介入(用的是NameMatchTransactionAttributeSource),同时加入 Tx1。(此时它在监控 Tx1)- 进入目标方法。
- 执行
transactionTemplate.execute:- 代码内部 挂起 Tx1,开启 Tx2。
- 异常抛出。
transactionTemplate内部 回滚 Tx2。transactionTemplate内部 恢复 Tx1。transactionTemplate重新抛出异常。
- try-catch :
- 在异常碰到拦截器 2 之前,就把它吞了!
- 转换成了一个正常的返回值对象。
- 方法返回。
- 拦截器 2:
- 看到方法正常返回了对象,没有抛出异常。
- 它就不会去执行
setRollbackOnly。 - Tx1 安全存活。
也就是说编程式事务能够正常将Tx1挂起(suspend)再恢复(resume),这使得在Tx1醒过来之前我们就已经把异常处理掉了,Tx1醒来过后就看不到异常了。