rollback-only异常令我对事务有了新的认识

rollback-only异常令我对事务有了新的认识
最新回答
残存的回音

2023-03-09 05:38:50

本文首发于个人网站。

相关环境配置:

在两个使用 Transaction 注解的 ServiceA 和 ServiceB 中,ServiceA 引入了 ServiceB 的方法用于更新数据。当 ServiceA 中捕捉到 ServiceB 中有异常时,回滚动作正常执行,但当返回时则出现 org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only 异常。

代码示例:

ServiceA

ServiceB

知识回顾:

Spring Boot 默认集成事务,所以无须手动开启,使用 @EnableTransactionManagement 注解进行事务管理。

@Transactional 的作用范围和常用配置参数详细介绍请参考相关文章。

@Transactional 事务注解原理:

@Transactional 的工作机制是基于 AOP 实现的,通过动态代理(JDK 或 CGLIB)在目标方法之前开启事务,在方法执行过程中遇到异常时回滚事务,方法调用完成之后提交事务。

Spring AOP 自调用问题:

若同一类中的其他没有@Transactional 注解的方法内部调用有 @Transactional 注解的方法,会导致有@Transactional 注解的方法的事务失效。

这是由于 Spring AOP 代理的原因造成的。只有当 @Transactional 注解的方法在类以外被调用时,Spring 事务管理才会生效。

关于 AOP 自调用的问题,文章结尾会介绍相关解决方法。

@Transactional 的使用注意事项:

总结 Spring 的 @Transactional 注解控制事务的不生效场景及案例分析。

构建项目步骤:

1、创建 Maven 项目,选择相应的依赖。

2、配置 application.yml。

3、在 MySQL 数据库中创建两个表。

4、创建实体类并添加 JPA 注解。

5、创建对应的 Repository 实现 JpaRepository 接口,生成 CRUD 操作。

6、创建 UserService 及其实现类。

7、UserController

最终,通过特定知识的学习演示整个项目结构和文件分布。

事务回滚:

通过构建必要的代码进行测试,发现报错结果与预期不同。关键信息变为了 Transaction silently rolled back because it has been marked as rollback-only,这里我们暂不讨论错误提示信息为何发生了改变,集中讨论报错原因。

根据基础知识,当我们在 Service 文件类上添加 @Transactional 时,该注解对该类中所有的 public 方法都生效,且传播机制默认为 PROPAGATION_REQUIRED。

在这种情况下,外层事务(UserApplication)和内层事务(UserServiceImpl)就是一个事务,任何一个出现异常,都会在 findAll()执行完毕后回滚。如果内层事务抛出异常 IllegalArgumentException(没有 catch,继续向外层抛出),在内层事务结束时,Spring 会把内层事务标记为“rollback-only”;这时外层事务发现了异常 IllegalArgumentException,如果外层事务 catch 了异常并处理掉,那么外层事务 A 的方法会继续执行代码,直到外层事务也结束时,这时外层事务想 commit,因为正常结束没有向外抛异常,但是内外层事务是同一个事务,事务已经被内层方法标记为“rollback-only”,需要回滚,无法 commit,这时 Spring 就会抛出 org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only。

分析了报错原因后,我们来深入代码分析为何自建简易代码复现时,错误提示发生了变化。

通过日志打印的结果,rollback-only 异常发生于 org.springframework.transaction.support.AbstractPlatformTransactionManager 文件中。

结合代码,通过断点调试,可以梳理出如下逻辑:

1、当内层事务(UserServiceImpl)中的 query 抛出异常后,开始进行回滚,即进入 rollback()方法,接着进入 processRollback()方法,此时第二个入参的值为 false;

2、进入 processRollback()方法后,首先判断事物是否拥有 savepoint(回滚点),如果有,就回滚到设置的 savepoint;接着判断当前事务是否是新事务,因为这里是内外层事务,其实是同一个事务,所以判断结果为 false;但 hasTransaction()判断为 true,接着进入 if 方法体,isLocalRollbackOnly()为 false,isGlobalRollbackOnParticipationFailure()为 true,那么只能执行 doSetRollbackOnly()方法,此处只是补充打印一下日志;紧接着调用 isFailEarlyOnGlobalRollbackOnly()方法,这里主要是获取 failEarlyOnGlobalRollbackOnly 字段的值,默认情况下 failEarlyOnGlobalRollbackOnly 开关是关闭的,这个开关的作用是如果开启了程序则会尽早抛出异常。最终 unexpectedRollback 字段仍为 false,所以没有抛出 Transaction rolled back because it has been marked as rollback-only 异常。

3、内层事务方法调用结束后,回到外层方法,在事务提交时,即执行 commit()方法,实际上执行的是 processCommit()方法。该方法中的逻辑和 processRollback()方法有些重叠,此时判断当前事务是新事务,所以 unexpectedRollback 就被赋值为 true,最终抛出 Transaction silently rolled back because it has been marked as rollback-only 异常。

通过分析,我们了解到自定义代码时为何只能得到 Transaction silently rolled back because it has been marked as rollback-only 异常,而在项目代码中确实遇到了 Transaction rolled back because it has been marked as rollback-only 异常。

rollback-only 异常产生的原因需要同时满足以下前提:

1. 事务方法嵌套,位于同一个事务中,方法位于不同的文件;

2. 子方法抛出异常,被上层方法捕获和消化。

解决方法:

1、捕获异常时,手动设置上层事务状态为 rollback 状态。

2、修改事务传播机制,如将内层事务的传播方式指定为 @Transactional(propagation= Propagation.NESTED),外层事务的提交和回滚能够控制嵌套的内层事务回滚;内层事务报错时,只回滚内层事务,外层事务可以继续提交。

但尝试 Propagation.NESTED 与 Hibernate JPA 一起使用将导致 Spring 异常。

3、如果这个异常发生时,内层需要事务回滚的代码还没有执行,则可以 @Transactional(noRollbackFor = {内层抛出的异常}.class),指定内层也不为这个异常回滚。

4、内层方法取消 @Transactional 注解,这样就不会发生回滚操作。

事务失效:

接下来,我们分析事务是否生效的问题。虽然大家对于同类自调用会导致事务失效这一知识点朗朗上口,但你真的了解吗?具体来说就是类 A 的方法 a() 调用方法 b(),方法 b() 配置了事务,那么该事务在调用时不会生效。

案例分析:

在 UserServiceImpl 中的两个方法以及 UserRepository 定义的查询方法,通过代码分析和日志输出,我们发现事务配置没有生效。进一步探究,query()方法中抛出异常时,数据是否会回滚?答案是,没有事务就不会回滚。

接着,我们分析如果类 A 的方法 a() 调用方法 b(),方法 a()、b() 都配置了事务,又会是什么结果?通过在 findByAddress()方法加上 @Transactional 注解并重新执行代码,我们可以发现 findByAddress()方法的事务生效了,但 query()方法的事务没有生效,因为它们共享同一个事务。

在测试上述场景的过程中,我们发现一个有趣的情况,即关于 save()方法的调用。通过控制台输出,我们了解到明明没有添加 @Transactional 注解,为何却输出了事务相关内容?深入源码分析,我们了解到 JPA 自带的 save 方法是如何实现的。如果在 add 方法中调用配置了事务的 query()方法,日志输出会表明事务没有生效。不过,事务生效的范围仅在 save()方法上,而非 add()方法。如果此时 query()方法中抛出异常,add()方法是不会回滚的。对此感兴趣的朋友可以进行测试。

在 add()方法上添加 @Transactional 注解并执行代码后,控制台输出表明 save()方法加入了 add()方法的事务中。如果此时 query()方法中抛出异常,不管 query()方法是否添加了 @Transactional 注解,add()方法都会回滚。

事务失效原因分析:

事务不生效的原因在于,Spring 基于 AOP 机制实现事务的管理,无论是通过 @Autowired 来注入 UserService,还是其他方式,调用 UserService 的方法时,实际上通过 UserService 的代理类调用 UserService 的方法,代理类在执行目标方法前后加上了事务管理的代码。

因此,只有通过注入的 UserService 调用事务方法,才会走代理类,才会执行事务管理;如果在同类直接调用,未走代理类,事务就无效。 注意:除了 @Transactional,@Async 同样需要代理类调用,异步才会生效。

关于事务失效的三种方法,我们在网上查阅资料时发现,这里简单给大家介绍一下。

解决事务失效的三种方法如下:

方式 1:避免循环依赖,使用 @Autowired 而不是 @RequiredArgsConstructor。

方式 2:通过 ApplicationContext 获取当前代理类。

方式 3:在应用启动类中加入注解 EnableJpaAuditing 或定义 config 类,同时在需要的字段上加上 @CreatedDate、@CreatedBy、@LastModifiedDate、@LastModifiedBy 等注解。

扩展数据持久化自动生成新增时间:

在 Spring JPA 中,支持在字段或方法上注解 @CreatedDate、@CreatedBy、@LastModifiedDate、@LastModifiedBy,分别用于标记创建时间、创建者、最后修改时间和修改者。要使用上述注解并启用它们,首先申明实体类并添加注解 @EntityListeners(AuditingEntityListener.class),在 application 启动类中加入注解 EnableJpaAuditing 或在 config 类中定义。在 JPA 的 save 方法被调用时,时间字段会自动设置并插入数据库,但 CreatedBy 和 LastModifiedBy 的值并未赋值,因为需要实现 AuditorAware 接口来返回需要插入的值。

问题记录:

方法抛出 'java.lang.StackOverflowError' 异常,无法评估 com.msdn.hresh.domain.User.toString()。

问题出现的原因是 debug 模式下,由于 User 类和 Job 类相互引用,且都加了 lombok 的 @Data 注解,@Data 注解会生成 toString()方法,而这两个类在使用 toString()方法时,会不断的互相循环调用引用对象的方法,导致栈溢出。

解决办法:

1、删除 @Data 注解,使用 @Getter 和 @Setter 替代。

2、重写 toString()方法,覆盖 @Data 注解实现的 toString()方法,注意不要再互相循环调用方法。

推荐使用第一种方法。

总结:

使用 Spring 框架进行开发提供了便利,隐藏了事务控制的细节和底层繁琐的逻辑,极大减少了开发的复杂度。然而,如果对底层源码有更多了解,对于开发和问题排查将大有帮助。不过,学习源码本身就是一件枯燥的事情,需要在特定情况下进行研究,动力更强一些,效率更高一些。

参考文献:

Spring 事务源码(7) —— 事务的 completeTransactionAfterThrowing 回滚、commitTransactionAfterReturning 提交以及事务源码总结

Spring 事务方法嵌套引发的异常与问题分析

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