极客时间已完结课程限时免费阅读

06 | 20%的业务代码的Spring声明式事务,可能都没处理正确

06 | 20%的业务代码的Spring声明式事务,可能都没处理正确-极客时间

06 | 20%的业务代码的Spring声明式事务,可能都没处理正确

讲述:王少泽

时长20:44大小18.98M

你好,我是朱晔。今天,我来和你聊聊业务代码中与数据库事务相关的坑。
Spring 针对 Java Transaction API (JTA)、JDBC、Hibernate 和 Java Persistence API (JPA) 等事务 API,实现了一致的编程模型,而 Spring 的声明式事务功能更是提供了极其方便的事务配置方式,配合 Spring Boot 的自动配置,大多数 Spring Boot 项目只需要在方法上标记 @Transactional 注解,即可一键开启方法的事务性配置。
据我观察,大多数业务开发同学都有事务的概念,也知道如果整体考虑多个数据库操作要么成功要么失败时,需要通过数据库事务来实现多个操作的一致性和原子性。但,在使用上大多仅限于为方法标记 @Transactional,不会去关注事务是否有效、出错后事务是否正确回滚,也不会考虑复杂的业务代码中涉及多个子业务逻辑时,怎么正确处理事务。
事务没有被正确处理,一般来说不会过于影响正常流程,也不容易在测试阶段被发现。但当系统越来越复杂、压力越来越大之后,就会带来大量的数据不一致问题,随后就是大量的人工介入查看和修复数据。
所以说,一个成熟的业务系统和一个基本可用能完成功能的业务系统,在事务处理细节上的差异非常大。要确保事务的配置符合业务功能的需求,往往不仅仅是技术问题,还涉及产品流程和架构设计的问题。今天这一讲的标题“20% 的业务代码的 Spring 声明式事务,可能都没处理正确”中,20% 这个数字在我看来还是比较保守的。
我今天要分享的内容,就是帮助你在技术问题上理清思路,避免因为事务处理不当让业务逻辑的实现产生大量偶发 Bug。

小心 Spring 的事务可能没有生效

在使用 @Transactional 注解开启声明式事务时, 第一个最容易忽略的问题是,很可能事务并没有生效。
实现下面的 Demo 需要一些基础类,首先定义一个具有 ID 和姓名属性的 UserEntity,也就是一个包含两个字段的用户表:
@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,新增一个根据用户名查询所有数据的方法:
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
List<UserEntity> findByName(String name);
}
定义一个 UserService 类,负责业务逻辑处理。如果不清楚 @Transactional 的实现方式,只考虑代码逻辑的话,这段代码看起来没有问题。
定义一个入口方法 createUserWrong1 来调用另一个私有方法 createUserPrivate,私有方法上标记了 @Transactional 注解。当传入的用户名包含 test 关键字时判断为用户名不合法,抛出异常,让用户创建操作失败,期望事务可以回滚:
@Service
@Slf4j
public class UserService {
@Autowired
private UserRepository userRepository;
//一个公共方法供Controller调用,内部调用事务性的私有方法
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方法
@Transactional
private void createUserPrivate(UserEntity entity) {
userRepository.save(entity);
if (entity.getName().contains("test"))
throw new RuntimeException("invalid username!");
}
//根据用户名查询用户数
public int getUserCount(String name) {
return userRepository.findByName(name).size();
}
}
下面是 Controller 的实现,只是调用一下刚才定义的 UserService 中的入口方法 createUserWrong1。
@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 方法再次尝试:
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方法
@Transactional
public void createUserPublic(UserEntity entity) {
userRepository.save(entity);
if (entity.getName().contains("test"))
throw new RuntimeException("invalid username!");
}
测试发现,调用新的 createUserWrong2 方法事务同样不生效。这里,我给出 @Transactional 生效原则 2,必须通过代理过的类从外部调用目标方法才能生效
Spring 通过 AOP 技术对方法进行增强,要调用增强过的方法必然是调用代理后的对象。我们尝试修改下 UserService 的代码,注入一个 self,然后再通过 self 实例调用标记有 @Transactional 注解的 createUserPublic 方法。设置断点可以看到,self 是由 Spring 通过 CGLIB 方式增强过的类:
CGLIB 通过继承方式实现代理类,private 方法在子类不可见,自然也就无法进行事务增强;
this 指针代表对象自己,Spring 不可能注入 this,所以通过 this 访问方法必然不是代理。
把 this 改为 self 后测试发现,在 Controller 中调用 createUserRight 方法可以验证事务是生效的,非法的用户注册操作可以回滚。
虽然在 UserService 内部注入自己调用自己的 createUserPublic 可以正确实现事务,但更合理的实现方式是,让 Controller 直接调用之前定义的 UserService 的 createUserPublic 方法,因为注入自己调用自己很奇怪,也不符合分层实现的规范:
@GetMapping("right2")
public int right2(@RequestParam("name") String name) {
try {
userService.createUserPublic(new UserEntity(name));
} catch (Exception ex) {
log.error("create user failed because {}", ex.getMessage());
}
return userService.getUserCount(name);
}
我们再通过一张图来回顾下 this 自调用、通过 self 调用,以及在 Controller 中调用 UserService 三种实现的区别:
通过 this 自调用,没有机会走到 Spring 的代理类;后两种改进方案调用的是 Spring 注入的 UserService,通过代理调用才有机会对 createUserPublic 方法进行动态增强。
这里,我还有一个小技巧,强烈建议你在开发时打开相关的 Debug 日志,以方便了解 Spring 事务实现的细节,并及时判断事务的执行情况
我们的 Demo 代码使用 JPA 进行数据库访问,可以这么开启 Debug 日志:
logging.level.org.springframework.orm.jpa=DEBUG
开启日志后,我们再比较下在 UserService 中通过 this 调用和在 Controller 中通过注入的 UserService Bean 调用 createUserPublic 区别。很明显,this 调用因为没有走代理,事务没有在 createUserPublic 方法上生效,只在 Repository 的 save 方法层面生效:
//在UserService中通过this调用public的createUserPublic
[10:10:19.913] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
//在Controller中通过注入的UserService Bean调用createUserPublic
[10:10:47.750] [http-nio-45678-exec-6] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo1.UserService.createUserPublic]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
你可能还会考虑一个问题,这种实现在 Controller 里处理了异常显得有点繁琐,还不如直接把 createUserWrong2 方法加上 @Transactional 注解,然后在 Controller 中直接调用这个方法。这样一来,既能从外部(Controller 中)调用 UserService 中的方法,方法又是 public 的能够被动态代理 AOP 增强。
你可以试一下这种方法,但很容易就会踩第二个坑,即因为没有正确处理异常,导致事务即便生效也不一定能回滚。

事务即便生效也不一定能回滚

通过 AOP 实现事务处理可以理解为,使用 try…catch…来包裹标记了 @Transactional 注解的方法,当方法出现了异常并且满足一定条件的时候,在 catch 里面我们可以设置事务回滚,没有异常则直接提交事务。
这里的“一定条件”,主要包括两点。
第一,只有异常传播出了标记了 @Transactional 注解的方法,事务才能回滚。在 Spring 的 TransactionAspectSupport 里有个 invokeWithinTransaction 方法,里面就是处理事务的逻辑。可以看到,只有捕获到异常才能进行后续事务处理:
try {
// This is an around advice: Invoke the next interceptor in the chain.
// This will normally result in a target object being invoked.
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
第二,默认情况下,出现 RuntimeException(非受检异常)或 Error 的时候,Spring 才会回滚事务
打开 Spring 的 DefaultTransactionAttribute 类能看到如下代码块,可以发现相关证据,通过注释也能看到 Spring 这么做的原因,大概的意思是受检异常一般是业务异常,或者说是类似另一种方法的返回值,出现这样的异常可能业务还能完成,所以不会主动回滚;而 Error 或 RuntimeException 代表了非预期的结果,应该回滚:
/**
* The default behavior is as with EJB: rollback on unchecked exception
* ({@link RuntimeException}), assuming an unexpected outcome outside of any
* business rules. Additionally, we also attempt to rollback on {@link Error} which
* is clearly an unexpected outcome as well. By contrast, a checked exception is
* considered a business exception and therefore a regular expected outcome of the
* transactional business method, i.e. a kind of alternative return value which
* still allows for regular completion of resource operations.
* <p>This is largely consistent with TransactionTemplate's default behavior,
* except that TransactionTemplate also rolls back on undeclared checked exceptions
* (a corner case). For declarative transactions, we expect checked exceptions to be
* intentionally declared as business exceptions, leading to a commit by default.
* @see org.springframework.transaction.support.TransactionTemplate#execute
*/
@Override
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
接下来,我和你分享 2 个反例。
重新实现一下 UserService 中的注册用户操作:
在 createUserWrong1 方法中会抛出一个 RuntimeException,但由于方法内 catch 了所有异常,异常无法从方法传播出去,事务自然无法回滚。
在 createUserWrong2 方法中,注册用户的同时会有一次 otherTask 文件读取操作,如果文件读取失败,我们希望用户注册的数据库操作回滚。虽然这里没有捕获异常,但因为 otherTask 方法抛出的是受检异常,createUserWrong2 传播出去的也是受检异常,事务同样不会回滚。
@Service
@Slf4j
public 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();
}
//因为文件不存在,一定会抛出一个IOException
private void otherTask() throws IOException {
Files.readAllLines(Paths.get("file-that-not-exist"));
}
}
Controller 中的实现,仅仅是调用 UserService 的 createUserWrong1 和 createUserWrong2 方法,这里就贴出实现了。这 2 个方法的实现和调用,虽然完全避开了事务不生效的坑,但因为异常处理不当,导致程序没有如我们期望的文件操作出现异常时回滚事务。
现在,我们来看下修复方式,以及如何通过日志来验证是否修复成功。针对这 2 种情况,对应的修复方法如下。
第一,如果你希望自己捕获异常进行处理的话,也没关系,可以手动设置让当前事务处于回滚状态:
@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();
}
}
运行后可以在日志中看到 Rolling back 字样,确认事务回滚了。同时,我们还注意到“Transactional code has requested rollback”的提示,表明手动请求回滚:
[22:14:49.352] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :698 ] - Transactional code has requested rollback
[22:14:49.353] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :834 ] - Initiating transaction rollback
[22:14:49.353] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(1906719643<open>)]
第二,在注解中声明,期望遇到所有的 Exception 都回滚事务(来突破默认不回滚受检异常的限制):
@Transactional(rollbackFor = Exception.class)
public void createUserRight2(String name) throws IOException {
userRepository.save(new UserEntity(name));
otherTask();
}
运行后,同样可以在日志中看到回滚的提示:
[22:10:47.980] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :834 ] - Initiating transaction rollback
[22:10:47.981] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(1419329213<open>)]
在这个例子中,我们展现的是一个复杂的业务逻辑,其中有数据库操作、IO 操作,在 IO 操作出现问题时希望让数据库事务也回滚,以确保逻辑的一致性。在有些业务逻辑中,可能会包含多次数据库操作,我们不一定希望将两次操作作为一个事务来处理,这时候就需要仔细考虑事务传播的配置了,否则也可能踩坑。

请确认事务传播配置是否符合自己的业务逻辑

有这么一个场景:一个用户注册的操作,会插入一个主用户到用户表,还会注册一个关联的子用户。我们希望将子用户注册的数据库操作作为一个独立事务来处理,即使失败也不会影响主流程,即不影响主用户的注册。
接下来,我们模拟一个实现类似业务逻辑的 UserService:
@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 实现正如其名,因为最后我们抛出了一个运行时异常,错误原因是用户状态无效,所以子用户的注册肯定是失败的。我们期望子用户的注册作为一个事务单独回滚,不影响主用户的注册,这样的逻辑可以实现吗?
@Service
@Slf4j
public 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:
@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);
}
调用后可以在日志中发现如下信息,很明显事务回滚了,最后 Controller 打出了创建子用户抛出的运行时异常:
[22:50:42.866] [http-nio-45678-exec-8] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(103972212<open>)]
[22:50:42.869] [http-nio-45678-exec-8] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :620 ] - Closing JPA EntityManager [SessionImpl(103972212<open>)] after transaction
[22:50:42.869] [http-nio-45678-exec-8] [ERROR] [t.d.TransactionPropagationController:23 ] - createUserWrong failed, reason:invalid status
你马上就会意识到,不对呀,因为运行时异常逃出了 @Transactional 注解标记的 createUserWrong 方法,Spring 当然会回滚事务了。如果我们希望主方法不回滚,应该把子方法抛出的异常捕获了。
也就是这么改,把 subUserService.createSubUserWithExceptionWrong 包裹上 catch,这样外层主方法就不会出现异常了:
@Transactional
public void createUserWrong2(UserEntity entity) {
createMainUser(entity);
try{
subUserService.createSubUserWithExceptionWrong(entity);
} catch (Exception ex) {
// 虽然捕获了异常,但是因为没有开启新事务,而当前事务因为异常已经被标记为rollback了,所以最终还是会回滚。
log.error("create sub user error:{}", ex.getMessage());
}
}
运行程序后可以看到如下日志:
[22:57:21.722] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.UserService.createUserWrong2]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[22:57:21.739] [http-nio-45678-exec-3] [INFO ] [t.c.transaction.demo3.SubUserService:19 ] - createSubUserWithExceptionWrong start
[22:57:21.739] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :356 ] - Found thread-bound EntityManager [SessionImpl(1794007607<open>)] for JPA transaction
[22:57:21.739] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :471 ] - Participating in existing transaction
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :843 ] - Participating transaction failed - marking existing transaction as rollback-only
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :580 ] - Setting JPA transaction on EntityManager [SessionImpl(1794007607<open>)] rollback-only
[22:57:21.740] [http-nio-45678-exec-3] [ERROR] [.g.t.c.transaction.demo3.UserService:37 ] - create sub user error:invalid status
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :741 ] - Initiating transaction commit
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :529 ] - Committing JPA transaction on EntityManager [SessionImpl(1794007607<open>)]
[22:57:21.743] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :620 ] - Closing JPA EntityManager [SessionImpl(1794007607<open>)] after transaction
[22:57:21.743] [http-nio-45678-exec-3] [ERROR] [t.d.TransactionPropagationController:33 ] - createUserWrong2 failed, reason:Transaction silently rolled back because it has been marked as rollback-only
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
...
需要注意以下几点:
如第 1 行所示,对 createUserWrong2 方法开启了异常处理;
如第 5 行所示,子方法因为出现了运行时异常,标记当前事务为回滚;
如第 7 行所示,主方法的确捕获了异常打印出了 create sub user error 字样;
如第 9 行所示,主方法提交了事务;
奇怪的是,如第 11 行和 12 行所示,Controller 里出现了一个 UnexpectedRollbackException,异常描述提示最终这个事务回滚了,而且是静默回滚的。之所以说是静默,是因为 createUserWrong2 方法本身并没有出异常,只不过提交后发现子方法已经把当前事务设置为了回滚,无法完成提交。
这挺反直觉的。我们之前说,出了异常事务不一定回滚,这里说的却是不出异常,事务也不一定可以提交。原因是,主方法注册主用户的逻辑和子方法注册子用户的逻辑是同一个事务,子逻辑标记了事务需要回滚,主逻辑自然也不能提交了。
看到这里,修复方式就很明确了,想办法让子逻辑在独立事务中运行,也就是改一下 SubUserService 注册子用户的方法,为注解加上 propagation = Propagation.REQUIRES_NEW 来设置 REQUIRES_NEW 方式的事务传播策略,也就是执行到这个方法时需要开启新的事务,并挂起当前事务:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createSubUserWithExceptionRight(UserEntity entity) {
log.info("createSubUserWithExceptionRight start");
userRepository.save(entity);
throw new RuntimeException("invalid status");
}
主方法没什么变化,同样需要捕获异常,防止异常漏出去导致主事务回滚,重新命名为 createUserRight:
@Transactional
public void createUserRight(UserEntity entity) {
createMainUser(entity);
try{
subUserService.createSubUserWithExceptionRight(entity);
} catch (Exception ex) {
// 捕获异常,防止主方法回滚
log.error("create sub user error:{}", ex.getMessage());
}
}
改造后,重新运行程序可以看到如下的关键日志:
第 1 行日志提示我们针对 createUserRight 方法开启了主方法的事务;
第 2 行日志提示创建主用户完成;
第 3 行日志可以看到主事务挂起了,开启了一个新的事务,针对 createSubUserWithExceptionRight 方案,也就是我们的创建子用户的逻辑;
第 4 行日志提示子方法事务回滚;
第 5 行日志提示子方法事务完成,继续主方法之前挂起的事务;
第 6 行日志提示主方法捕获到了子方法的异常;
第 8 行日志提示主方法的事务提交了,随后我们在 Controller 里没看到静默回滚的异常。
[23:17:20.935] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.UserService.createUserRight]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[23:17:21.079] [http-nio-45678-exec-1] [INFO ] [.g.t.c.transaction.demo3.UserService:55 ] - createMainUser finish
[23:17:21.082] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :420 ] - Suspending current transaction, creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.SubUserService.createSubUserWithExceptionRight]
[23:17:21.153] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :834 ] - Initiating transaction rollback
[23:17:21.160] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :1009] - Resuming suspended transaction after completion of inner transaction
[23:17:21.161] [http-nio-45678-exec-1] [ERROR] [.g.t.c.transaction.demo3.UserService:49 ] - create sub user error:invalid status
[23:17:21.161] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :741 ] - Initiating transaction commit
[23:17:21.161] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :529 ] - Committing JPA transaction on EntityManager [SessionImpl(396441411<open>)]
运行测试程序看到如下结果,getUserCount 得到的用户数量为 1,代表只有一个用户也就是主用户注册完成了,符合预期:

重点回顾

今天,我针对业务代码中最常见的使用数据库事务的方式,即 Spring 声明式事务,与你总结了使用上可能遇到的三类坑,包括:
第一,因为配置不正确,导致方法上的事务没生效。我们务必确认调用 @Transactional 注解标记的方法是 public 的,并且是通过 Spring 注入的 Bean 进行调用的。
第二,因为异常处理不正确,导致事务虽然生效但出现异常时没回滚。Spring 默认只会对标记 @Transactional 注解的方法出现了 RuntimeException 和 Error 的时候回滚,如果我们的方法捕获了异常,那么需要通过手动编码处理事务回滚。如果希望 Spring 针对其他异常也可以回滚,那么可以相应配置 @Transactional 注解的 rollbackFor 和 noRollbackFor 属性来覆盖其默认设置。
第三,如果方法涉及多次数据库操作,并希望将它们作为独立的事务进行提交或回滚,那么我们需要考虑进一步细化配置事务传播方式,也就是 @Transactional 注解的 Propagation 属性。
可见,正确配置事务可以提高业务项目的健壮性。但,又因为健壮性问题往往体现在异常情况或一些细节处理上,很难在主流程的运行和测试中发现,导致业务代码的事务处理逻辑往往容易被忽略,因此我在代码审查环节一直很关注事务是否正确处理
如果你无法确认事务是否真正生效,是否按照预期的逻辑进行,可以尝试打开 Spring 的部分 Debug 日志,通过事务的运作细节来验证。也建议你在单元测试时尽量覆盖多的异常场景,这样在重构时,也能及时发现因为方法的调用方式、异常处理逻辑的调整,导致的事务失效问题。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。

思考与讨论

考虑到 Demo 的简洁,文中所有数据访问使用的都是 Spring Data JPA。国内大多数互联网业务项目是使用 MyBatis 进行数据访问的,使用 MyBatis 配合 Spring 的声明式事务也同样需要注意文中提到的这些点。你可以尝试把今天的 Demo 改为 MyBatis 做数据访问实现,看看日志中是否可以体现出这些坑。
在第一节中我们提到,如果要针对 private 方法启用事务,动态代理方式的 AOP 不可行,需要使用静态织入方式的 AOP,也就是在编译期间织入事务增强代码,可以配置 Spring 框架使用 AspectJ 来实现 AOP。你能否参阅 Spring 的文档“Using @Transactional with AspectJ”试试呢?注意:AspectJ 配合 lombok 使用,还可能会踩一些坑。
有关数据库事务,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 50

提建议

上一篇
05 | HTTP调用:你考虑到超时、重试、并发了吗?
下一篇
答疑篇:代码篇思考题集锦(一)
unpreview
 写留言

精选留言(70)

  • Darren
    2020-03-23
    AspectJ与lombok,都是字节码层面进行增强,在一起使用时会有问题,根据AspectJ维护者Andy Clement的当前答案是由于ECJ(Eclipse Compiler for Java)软件包存在问题在AspectJ编译器基础结构中包含和重命名。 解决问题可以参考下面连接: http://aspectj.2085585.n4.nabble.com/AspectJ-with-Lombok-td4651540.html https://stackoverflow.com/questions/41910007/lombok-and-aspectj 分享一个使用lombok的坑: 之前为了set赋值方便,在VO或者DTO上使用了@Accessors(chain=true),这样就可以链式赋值,但是在动态通过内省获取set方法进行赋值时,是获取不到对应的set方法,因为默认的set方法返回值是void,但是加了@Accessors(chain=true)之后,set方法的返回值变成了this,这样通过内省就获取到对应的set方法了,通过去掉@Accessors(chain=true)即可实现,通过内省动态给属性赋值。
    展开

    作者回复: 👍🏻

    共 8 条评论
    58
  • hanazawakana
    2020-03-21
    否则只有定义在 public 方法上的 @Transactional 才能生效。这里一定要用public吗,用protected不行吗,protected在子类中应该也可见啊,是因为包不同吗

    作者回复: 这个问题很好,首先JDK动态代理肯定是不行的只能是public,理论上CGLIB方式的代理是可以代理protected方法的,不过如果支持,那么意味着事务可能会因为切换代理实现方式表现不同,大大增加出现Bug的可能性,我觉得为了一致性所以Spring考虑只支持public,这是最好的。

    共 4 条评论
    44
  • Seven.Lin澤耿
    2020-03-22
    我还遇到一个坑,就是子方法使用了REQUIRES_NEW,但是业务逻辑需要的数据是来源于父方法的,也就是父方法还没提交,子方法获取不到。当时的解决方法是把事务隔离级别改成RC,现在回想起来,不知道这种解决方法是否正确?

    作者回复: 你说的隔离级别应该是指READ_UNCOMMITTED。我不认为这是很好的解决方案,子方法内需要依赖的数据来自父方法,可以方法传值,而不是用这种隔离级别。

    39
  • 看不到de颜色
    2020-03-29
    Spring默认事务采用动态代理方式实现。因此只能对public进行增强(考虑到CGLib和JDKProxy兼容,protected也不支持)。在使用动态代理增强时,方法内调用也可以考虑采用AopContext.currentProxy()获取当前代理类。

    作者回复: 没错

    24
  • Seven.Lin澤耿
    2020-03-22
    老师,可以问一下为啥国内大多数公司使用MyBatis呢?是为了更加接近SQL吗?难倒国外业务不会遇到复杂的场景吗?

    作者回复: 1、容易上手简单 2、国内BAT大厂对于Mybatis的使用量大,影响力大 3、国内大部分项目还是面向表结构的编程,从下到上的思考方式而非OOP的思考方式

    共 8 条评论
    24
  • 九时四
    2020-03-21
    老师您好,有个数据库事务和spring事务的问题想请教下(我是一个入职半年的菜鸟)。 业务场景:为了实现同一个时间的多个请求,只有一个请求生效,在数据库字段上加了一个字段(signature_lock)标识锁状态。(没有使用redis锁之类的中间件,只讨论数据库事务和Spring的事务,以下的请求理解为同时请求) 1.在数据库层面,通过sql语句直接操作数据库,数据库事务隔离级别为可重复读: -- 请求1 show VARIABLES like 'tx_isolation'; START TRANSACTION; select * from subscribe_info where id = 29; -- update语句只有一个请求可以执行,另一个请求在等待 update trade_deal_subscribe_info set signature_lock =1 where id = 1 and signature_lock = 0; commit; -- 请求2 show VARIABLES like 'tx_isolation'; START TRANSACTION; select * from trade_deal_subscribe_info where id = 29; -- update语句只有一个请求可以执行,另一个请求在等待 update subscribe_info set signature_lock =1 where id = 1 and signature_lock = 0; commit; 两个请求中只有一个可以执行成功update语句,将signature_lock更新为1。 2.在代码层面按照在数据库层面的逻辑,service层的伪代码如下: public void test(ParamDto paramDto) { //取数据 Data data = getByParamDto(paramDto); // 尝试加锁,返回1表示加锁成功 Integer lockStatus = lockData(paramDto); // 加锁失败直接返回 if(!Objects.equals(1,lockStatus)){ return; } try{ // 处理业务代码,大概2到3秒 handle(); }catch(Exception e){ } finally{ // 释放锁 releaseLock(paramDto); } } 按照这样的方式,在方法上面不加注解的情况下,执行结果与在写sql的结果是一致的,两个请求只有一个可以执行成功;加上@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)之后,两个请求都可以拿到锁。 疑问是,Spring的事务和数据库的事务有什么关系,加上事务注解后,为什么和数据库的结果不一致。
    展开

    作者回复: 如果要通过数据库来实现锁,那么加锁解锁,需要是单独的事务,不能跟业务的sql事务混合在一起,加锁和业务在一个事务里了,锁就没用了,因为每个事务里,都认为自己拿到了锁。

    共 8 条评论
    19
  • 火很大先生
    2020-04-10
    @Transactional public int createUserRight(String name) throws IOException { try { userRepository.save(new UserEntity(name)); throw new RuntimeException("error"); } catch (Exception ex) { log.error("create user failed because {}", ex.getMessage()); TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); } return userRepository.findByName(name).size(); } 请教老师,我这种写法,控制台打出了Initiating transaction rollback 但是数据库还是存上了数据,没有回滚,是因为findByName 这个查询语句的默认commit给提交了吗
    展开

    作者回复: 需要明确几点: 1、我觉得这个事务最终是回滚的,你看到的这个查询有值,并不代表数据库有值 2、这个查询有值的原因是因为在一个事务内,此时事务并没有回滚,事务要到离开了这个createUserRight方法才会回滚(回想一下AOP原理) 3、在一个事务内肯定可以看到事务之前做的修改

    12
  • 阿里斯托芬
    2020-03-26
    我觉得这个文章里面应该顺带讲一下 AopContext.currentProxy()
    12
  • 雅然风懿
    2020-03-26
    学习了,我得回去检查我的事务代码了
    6
  • 王刚
    2020-03-26
    老师问个问题,您说得@Transactional事物回滚,只有是RuntimeException 或error时,才会回滚; 但是我在做测试时,发现@Transactional有一个rollbackFor属性,该属性可以指定什么异常回滚,如果@Transactional 不指定rollbackFor,默认得是RuntimeException?

    作者回复: 是啊,所以我们才需要设置 @Transactional(rollbackFor = Exception.class) 来不仅仅回滚RuntimeException

    共 2 条评论
    6
  • 汝林外史
    2020-03-24
    老师,创建主子用户那个业务,应该是子用户创建失败不影响主用户,但是主用户失败应该子用户也要回滚吧?如果是这样,那传播机制是不是应该用Propagation.NESTED

    作者回复: 理论上NESTED显然是比两个独立都事务好,NESTED因为JPA Hibernate不支持,所以这里没有采用这种方式(而且对于本例而言,主用户创建在先,如果先出异常的话后面也不会到子用户的逻辑,所以问题不大),抽空我再传一个例子上去: https://github.com/JosephZhu1983/java-common-mistakes/tree/master/src/main/java/org/geekbang/time/commonmistakes/transaction/nested

    共 4 条评论
    6
  • Yanni
    2020-04-10
    要注意,@Transactional 与 @Async注解不能同时在一个方法上使用, 这样会导致事物不生效。

    作者回复: 能否分析一下原因给大家分享一下?

    共 5 条评论
    5
  • magic
    2020-03-28
    老师能补充下对私有方法事务的代码示例吗?

    作者回复: 实现步骤这里帖不下,可以参考 https://www.cnblogs.com/lovecindywang/p/9749658.html 『使用AspectJ静态织入进行改造』一节,自己做一下测试

    5
  • 汝林外史
    2020-03-23
    很明显,this 调用因为没有走代理,事务没有在 createUserPublic 方法上生效,只在 Repository 的 save 方法层面生效。 createUserPublic这个方法不是本来就一个save操作吗,既然save层面生效了,那这个方法的事务难道不也就生效了吗?

    作者回复: 生效,但是出异常的并不是save本身,所以Spring无法回滚

    5
  • 梦倚栏杆
    2020-03-21
    很多注解貌似都是需要public才能生效比如:@cacheable @async
    共 3 条评论
    5
  • COLDLY
    2020-04-07
    请问如果仅是select语句,需要加事务吗

    作者回复: 1、一个select对于数据库来说也是事务,隐式事务也是事务,读也需要考虑事务隔离性 2、加事务你是指@Transactional?如果方法里面没有什么其他操作不建议加,就保持autocommit好了,加了可能还会autocommit=0、select、commit、autocommit=1,增加无谓的交互

    共 2 条评论
    4
  • 张珮磊想静静
    2020-03-26
    如果一个事务里面操作了不同的数据库,回滚操作是不是就得自己写补偿的重试了?

    作者回复: 可以搜索一下分布式事务的解决方案

    共 2 条评论
    4
  • 👽
    2020-03-23
    Spring的坑,看来还需要多读些源码啊。
    4
  • nimil
    2020-06-11
    前几天还真出现了个事务不生效的问题,于是对着文章仔细review了一下代码,发现也没文中说的那些毛病,最后排查到是事务管理器只配置了一个数据库,而我是在另一个数据库进行的数据操作,所以事务不生效了,最后添加另一个数据库的事务管理器事务就生效了

    作者回复: 的确,这也是一种坑

    共 2 条评论
    3
  • tongzi
    2020-05-21
    【问题】 1、员工导入(新手机号),第一次会导入失败,再导入才会成功 【具体场景】 导入excel,解析成员工数据4条,先去注册生成t_user表,生成基本信息 然后在调用,bandCorp,和公司绑定,生成t_corp_user表 但是在调用bandCorp时,需要先去校验这个手机号,是否在t_user表生成数据 【解决】 发现由于事务注解引起的bug,importUserList(导入员工方法)加了事务注解,registerUser没有加事务注解 bandCorp加了事务注解,bandCorp传播级别为Propagation.REQUIRES_NEW,新开了事务 由于线上数据库为可重复读,qa数据库为读已提交,导致数据的不可见,校验失败 老师您好,我这边有个问题,看见springboot的事务隔离级别是default,默认采用数据库的 隔离级别,如果要是springboot的事务隔离级别,和mysql采用不同的隔离级别,是否会导致什么问题?(比如springboot手动设置为 可重复读,而数据库为读已提交)
    展开

    作者回复: 不会有问题,不设置默认就是使用数据库设置的隔离级别,如果你设置了就使用你设置的隔离级别

    3