Spring 针对 Java Transaction API (JTA)、JDBC、Hibernate 和 Java Persistence API (JPA) 等事务 API,实现了一致的编程模型,而 Spring 的声明式事务功能更是提供了极其方便的事务配置方式,配合 Spring Boot 的自动配置,大多数 Spring Boot 项目只需要在方法上标记 @Transactional 注解,即可一键开启方法的事务性配置。
大多数业务开发同学都有事务的概念,也知道如果整体考虑多个数据库操作要么成功要么失败时,需要通过数据库事务来实现多个操作的一致性和原子性。但在使用上大多仅限于为方法标记 @Transactional,不会去关注事务是否有效、出错后事务是否正确回滚,也不会考虑复杂的业务代码中涉及多个子业务逻辑时,怎么正确处理事务。
我今天要分享的内容,就是帮助你在技术问题上理清思路,避免因为事务处理不当让业务逻 辑的实现产生大量偶发 Bug。
1. 小心 Spring 的事务可能没有生效 在使用 @Transactional 注解开启声明式事务时, 第一个最容易忽略的问题是,很可能事务并没有生效。实现下面的 Demo 需要一些基础类,首先定义一个具有 ID 和姓名属性的 UserEntity,也就是一个包含两个字段的用户表:
1 2 3 4 5 6 7 8 9 10 11 12 @Entity @Data public class UserEntity { @Id @GeneratedValue (strategy = AUTO) private Long id; private String name; public UserEntity () { } public UserEntity (String name) { this .name = name; } }
为了方便理解,我使用 Spring JPA 做数据库访问,实现这样一个 Repository,新增一个根据用户名查询所有数据的方法:
1 2 3 4 @Repository public interface UserRepository extends JpaRepository <UserEntity , Long > { List<UserEntity> findByName (String name) ; }
定义一个 UserService 类,负责业务逻辑处理。如果不清楚 @Transactional 的实现方式,只考虑代码逻辑的话,这段代码看起来没有问题。
定义一个入口方法 createUserWrong1 来调用另一个私有方法 createUserPrivate,私有方法上标记了 @Transactional 注解。当传入的用户名包含 test 关键字时判断为用户名不合法,抛出异常,让用户创建操作失败,期望事务可以回滚。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Service @Slf 4jpublic class UserService { @Autowired private UserRepository userRepository; public int createUserWrong1 (String name) { try { this .createUserPrivate(new UserEntity(name)); } catch (Exception ex) { log.error("create user failed because {}" , ex.getMessage()); } return userRepository.findByName(name).size(); } @Transactional private void createUserPrivate (UserEntity entity) { userRepository.save(entity); if (entity.getName().contains("test" )) throw new RuntimeException("invalid username!" ); } }
下面是 Controller 的实现,只是调用一下刚才定义的 UserService 中的入口方法 createUserWrong1。
1 2 3 4 5 6 7 8 9 10 11 12 @RestController @RequestMapping ("transactional" )public class UserController { @Autowired private UserService userService; @GetMapping ("wrong1" ) public int wrong1 (@RequestParam("name" ) String name) { return userService.createUserWrong1(name); } }
调用接口后发现,即便用户名不合法,用户也能创建成功。刷新浏览器,多次发现有十几个的非法用户注册。
这里给出@Transactional 生效原则 1,除非特殊配置(比如使用 AspectJ 静态织入实现 AOP),否则只有定义在 public 方法上的 @Transactional 才能生效。原因是,Spring 默认通过动态代理的方式实现 AOP,对目标方法进行增强,private 方法无法代理到, Spring 自然也无法动态增强事务处理逻辑。
你可能会说,修复方式很简单,把标记了事务注解的 createUserPrivate 方法改为 public 即可。在 UserService 中再建一个入口方法 createUserWrong2,来调用这个 public 方法再次尝试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public int createUserWrong2 (String name) { try { this .createUserPublic(new UserEntity(name)); } catch (Exception ex) { log.error("create user failed because {}" , ex.getMessage()); } return userRepository.findByName(name).size(); } @Transactional public void createUserPublic (UserEntity entity) { userRepository.save(entity); if (entity.getName().contains("test" )) throw new RuntimeException("invalid username!" ); }
测试发现,调用新的 createUserWrong2 方法事务同样不生效。这里,我给出 @Transactional 生效原则 2,必须通过代理过的类从外部调用目标方法才能生效。
Spring 通过 AOP 技术对方法进行增强,要调用增强过的方法必然是调用代理后的对象。 我们新增一个专门负责事务操作的 TxUserService 类,并实现 createUserPublic 方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 @Service public class TxUserService { @Autowired private UserRepository userRepository; @Transactional public void createUserPublic (UserEntity entity) { userRepository.save(entity); if (entity.getName().contains("test" )) throw new RuntimeException("invalid username!" ); } }
UserService 中创建 createUserRight 方法,并调用 TxUserService 的 createUserPublic 方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Service @Slf 4jpublic class UserService { @Autowired private UserRepository userRepository; @Autowired private TxUserService txUserService; public int createUserRight (String name) { try { txUserService.createUserPublic(new UserEntity(name)); } catch (Exception ex) { log.error("create user failed because {}" , ex.getMessage()); } return userRepository.findByName(name).size(); } }
设置断点可以看到,txUserService 是由 Spring 通 过 CGLIB 方式增强过的类:
CGLIB 通过继承方式实现代理类,private 方法在子类不可见,自然也就无法进行事务增 强; this 指针代表对象自己,Spring 不可能注入 this,所以通过 this 访问方法必然不是代理。
你可能还会考虑一个问题,这种实现在 Controller 里处理了异常显得有点繁琐,还不如直 接把 createUserRight 方法加上 @Transactional 注解,然后在 Controller 中直接调用这个方法。这样一来,既能从外部(Controller 中)调用 UserService 中的方法,方法又是 public 的能够被动态代理 AOP 增强。这种方法虽然可行,但是可能因为 createUserRight 这个本身很大,我们可能没有必要使用 @Transactional 注解加在比较大的方法上。
2. 事务即便生效也不一定能回滚 通过 AOP 实现事务处理可以理解为,使用 try…catch…来包裹标记了 @Transactional 注解的方法,当方法出现了异常并且满足一定条件的时候,在 catch 里面我们可以设置事务回滚,没有异常则直接提交事务。
这里的“一定条件”,主要包括两点。
第一,只有异常传播出了标记了 @Transactional 注解的方法,事务才能回滚 。在 Spring 的 TransactionAspectSupport 里有个 invokeWithinTransaction 方法,里面就是处理事务的逻辑。可以看到,只有捕获到异常才能进行后续事务处理:
1 2 3 4 5 6 7 8 9 10 11 try { retVal = invocation.proceedWithInvocation(); } catch (Throwable ex) { completeTransactionAfterThrowing(txInfo, ex); throw ex; } finally { cleanupTransactionInfo(txInfo); }
第二,默认情况下,出现 RuntimeException(非受检异常)或 Error 的时候,Spring 才会回滚事务。
打开 Spring 的 DefaultTransactionAttribute 类能看到如下代码块,可以发现相关证据,受检异常一般是业务异常,或者说是类似另一种方法的返回值,出现这样的异常可能业务还能完成,所以不会主动回滚;而 Error 或 RuntimeException 代表了非预期的结果,应该回滚:
1 2 3 4 @Override public boolean rollbackOn (Throwable ex) { return (ex instanceof RuntimeException || ex instanceof Error); }
接下来,我和你分享 2 个反例。重新实现一下 UserService 中的注册用户操作:
在 createUserWrong1 方法中会抛出一个 RuntimeException,但由于方法内 catch 了 所有异常,异常无法从方法传播出去,事务自然无法回滚。 在 createUserWrong2 方法中,注册用户的同时会有一次 otherTask 文件读取操作, 如果文件读取失败,我们希望用户注册的数据库操作回滚。虽然这里没有捕获异常,但因为 otherTask 方法抛出的是受检异常,createUserWrong2 传播出去的也是受检异常,事务同样不会回滚。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Service @Slf 4jpublic class UserService { @Autowired private UserRepository userRepository; @Transactional public void createUserWrong1 (String name) { try { userRepository.save(new UserEntity(name)); throw new RuntimeException("error" ); } catch (Exception ex) { log.error("create user failed" , ex); } } @Transactional public void createUserWrong2 (String name) throws IOException { userRepository.save(new UserEntity(name)); otherTask(); } private void otherTask () throws IOException { Files.readAllLines(Paths.get("file-that-not-exist" )); } }
Controller 中的实现,仅仅是调用 UserService 的 createUserWrong1 和 createUserWrong2 方法,这里就不贴出实现了。这 2 个方法的实现和调用,虽然完全避开了事务不生效的坑,但因为异常处理不当,导致程序没有如我们期望的文件操作出现异常时回滚事务。
现在,我们来看下修复方式,以及如何通过日志来验证是否修复成功。针对这 2 种情况, 对应的修复方法如下。
第一,如果你希望自己捕获异常进行处理的话,也没关系,可以手动设置让当前事务处于回滚状态:
1 2 3 4 5 6 7 8 9 10 @Transactional public void createUserRight1 (String name) { try { userRepository.save(new UserEntity(name)); throw new RuntimeException("error" ); } catch (Exception ex) { log.error("create user failed" , ex); TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); } }
第二,在注解中声明,期望遇到所有的 Exception 都回滚事务(来突破默认不回滚受检异常的限制):
1 2 3 4 5 @Transactional (rollbackFor = Exception.class)public void createUserRight2 (String name) throws IOException { userRepository.save(new UserEntity(name)); otherTask(); }
3. 请确认事务传播配置是否符合自己的业务逻辑 有这么一个场景:一个用户注册的操作,会插入一个主用户到用户表,还会注册一个关联的子用户。我们希望将子用户注册的数据库操作作为一个独立事务来处理,即使失败也不会影响主流程,即不影响主用户的注册。
接下来,我们模拟一个实现类似业务逻辑的 UserService:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Autowired private UserRepository userRepository;@Autowired private SubUserService subUserService;@Transactional public void createUserWrong (UserEntity entity) { createMainUser(entity); subUserService.createSubUserWithExceptionWrong(entity); } private void createMainUser (UserEntity entity) { userRepository.save(entity); log.info("createMainUser finish" ); }
SubUserService 的 createSubUserWithExceptionWrong 实现正如其名,因为最后我们抛出了一个运行时异常,错误原因是用户状态无效,所以子用户的注册肯定是失败的。我们期望子用户的注册作为一个事务单独回滚,不影响主用户的注册,这样的逻辑可以实现吗?
1 2 3 4 5 6 7 8 9 10 11 12 13 @Service @Slf 4jpublic class SubUserService { @Autowired private UserRepository userRepository; @Transactional public void createSubUserWithExceptionWrong (UserEntity entity) { log.info("createSubUserWithExceptionWrong start" ); userRepository.save(entity); throw new RuntimeException("invalid status" ); } }
我们在 Controller 里实现一段测试代码,调用 UserService:
1 2 3 4 5 6 7 8 9 @GetMapping ("wrong" )public int wrong (@RequestParam("name" ) String name) { try { userService.createUserWrong(new UserEntity(name)); } catch (Exception ex) { log.error("createUserWrong failed, reason:{}" , ex.getMessage()); } return userService.getUserCount(name); }
调用后可以在日志中发现如下信息,很明显事务回滚了。如果我们希望主方法不回滚,应该把子方法抛出的异常捕获了。
也就是这么改,把 subUserService.createSubUserWithExceptionWrong 包裹上 catch,这样外层主方法就不会出现异常了:
1 2 3 4 5 6 7 8 9 10 @Transactional public void createUserWrong2 (UserEntity entity) { createMainUser(entity); try { subUserService.createSubUserWithExceptionWrong(entity); } catch (Exception ex) { log.error("create sub user error:{}" , ex.getMessage()); } }
运行可以发现 createUserWrong2 方法还是会回滚,这是因为 createUserWrong2 方法事务提交后发现子方法已经把当前事务设置为了回滚,所以无法完成提交。这挺反直觉的。我们之前说,出了异常事务不一定回滚,这里说的却是不出异常,事务也不一定可以提交。原因是,主方法注册主用户的逻辑和子方法注册子用户的逻辑是同一个事务,子逻辑标记了事务需要回滚,主逻辑自然也不能提交了。
看到这里,修复方式就很明确了,想办法让子逻辑在独立事务中运行,也就是改一下 SubUserService 注册子用户的方法,为注解加上 propagation = Propagation.REQUIRES_NEW 来设置 REQUIRES_NEW 方式的事务传播策略,也就是执行到这个方法时需要开启新的事务,并挂起当前事务:
1 2 3 4 5 6 @Transactional (propagation = Propagation.REQUIRES_NEW)public void createSubUserWithExceptionRight (UserEntity entity) { log.info("createSubUserWithExceptionRight start" ); userRepository.save(entity); throw new RuntimeException("invalid status" ); }
主方法没什么变化,同样需要捕获异常,防止异常漏出去导致主事务回滚。