摘要
很多人都遇到过这种情况:代码明明加了 @Transactional,但数据还是提交了,或者异常发生后并没有按预期回滚。本文结合真实开发中最常见的几个场景,梳理 Spring 事务失效的典型原因,包括同类方法内部调用、异常被吞掉、非运行时异常不回滚、方法访问权限问题以及异步场景下事务边界变化等,并给出对应的正确写法和排查思路。
前言
在日常开发里,Spring 事务几乎是最常用的能力之一。
很多业务代码都会这么写:
@Transactional(rollbackFor = Exception.class)
public void doBusiness() {
// 业务处理
}
看起来很简单,好像只要加上这个注解,事务问题就解决了。
但真实开发里,事务相关的问题其实非常多,最常见的一类就是:
代码明明加了 @Transactional,但事务并没有按预期生效。
这种问题很容易让人误判,因为表面上看代码没问题,日志里也未必直接报错,但结果就是:
- 该回滚的时候没回滚
- 该整体成功的时候只成功了一部分
- 事务边界和自己想的不一样
这篇文章我就结合几个很常见的场景,整理一下 Spring 事务为什么会失效,以及实际开发中该怎么避免这些坑。
一、为什么会感觉事务“加了但没生效”
很多人第一次遇到事务问题时,都会有一种感觉:
我都已经加注解了,为什么还是不对?
原因就在于,Spring 事务并不是“看到注解就一定生效”,它本质上依赖的是 AOP 代理机制。
也就是说,事务能否生效,和下面这些因素都有关系:
- 方法是不是通过 Spring 代理对象调用的
- 异常有没有真正抛出去
- 抛出的异常类型是不是默认支持回滚
- 方法访问权限是否符合代理要求
- 当前执行线程是不是还在原来的事务上下文里
所以事务问题很多时候并不是“注解没写”,而是:
事务的触发条件和你的实际调用方式不一致。
二、同类方法内部调用,事务为什么会失效
这是最经典、也最容易踩的坑。
错误写法
@Service
public class OrderService {
public void createOrder() {
saveMainOrder();
}
@Transactional(rollbackFor = Exception.class)
public void saveMainOrder() {
// 保存主单
// 保存明细
throw new RuntimeException("模拟异常");
}
}
很多人会觉得,saveMainOrder() 已经加了事务,执行异常应该回滚。
但如果 createOrder() 是在当前类内部直接调用 saveMainOrder(),事务很可能不会生效。
为什么会失效
因为 Spring 事务依赖代理对象。
而同类内部调用,本质上是 this.saveMainOrder(),没有经过 Spring 代理,自然也就绕过了事务增强。
正确写法一:把事务方法拆到另一个 Bean
@Service
public class OrderService {
@Resource
private OrderTxService orderTxService;
public void createOrder() {
orderTxService.saveMainOrder();
}
}
@Service
public class OrderTxService {
@Transactional(rollbackFor = Exception.class)
public void saveMainOrder() {
// 保存主单
// 保存明细
throw new RuntimeException("模拟异常");
}
}
正确写法二:通过代理对象调用
这种方式能用,但不如拆 Bean 清晰,日常开发里我更推荐第一种。
一句话总结
同类内部直接调用,事务不会经过 Spring 代理,注解等于白加。
三、异常被捕获但没有继续抛出,为什么不会回滚
这个场景也特别常见。
错误写法
@Transactional(rollbackFor = Exception.class)
public void updateData() {
try {
// 更新表A
// 更新表B
int i = 1 / 0;
} catch (Exception e) {
log.error("执行异常", e);
}
}
很多人会以为这里出异常了,事务就会自动回滚。
其实不一定。
为什么会失效
Spring 事务默认是通过“方法抛出异常”来感知是否需要回滚的。
如果你把异常 catch 住了,又没有继续往外抛,那对 Spring 来说,这个方法就是“正常结束”的,它自然会提交事务。
正确写法一:捕获后重新抛出
@Transactional(rollbackFor = Exception.class)
public void updateData() {
try {
// 更新表A
// 更新表B
int i = 1 / 0;
} catch (Exception e) {
log.error("执行异常", e);
throw new RuntimeException(e);
}
}
正确写法二:手动标记回滚
@Transactional(rollbackFor = Exception.class)
public void updateData() {
try {
// 更新表A
// 更新表B
int i = 1 / 0;
} catch (Exception e) {
log.error("执行异常", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
一句话总结
异常被吞掉,Spring 感知不到失败,事务通常不会回滚。
四、抛出的不是运行时异常,默认为什么不回滚
这个坑很多人也是在踩过之后才知道。
错误写法
@Transactional
public void handleData() throws Exception {
// 业务处理
throw new Exception("模拟受检异常");
}
很多人会觉得这里抛异常了,事务应该回滚。
但默认情况下,不一定。
为什么会失效
Spring 默认只会对:
- RuntimeException
- Error
进行回滚。
像 Exception 这种受检异常,如果你没有显式指定,默认是不回滚的。
正确写法
@Transactional(rollbackFor = Exception.class)
public void handleData() throws Exception {
// 业务处理
throw new Exception("模拟受检异常");
}
一句话总结
默认回滚的是运行时异常,不是所有异常。
五、方法不是 public,事务为什么可能不生效
这个坑属于不太起眼,但也确实会遇到(在 IntelliJ IDEA 这类的工具中,会有明显的编译错误)。
错误写法
@Transactional(rollbackFor = Exception.class)
private void doUpdate() {
// 业务处理
}
或者:
@Transactional(rollbackFor = Exception.class)
protected void doUpdate() {
// 业务处理
}
为什么会失效
在常见的 Spring 代理模式下,事务增强一般是针对 public 方法生效的。
如果方法不是 public,很多场景下事务代理根本不会生效。
正确写法
@Transactional(rollbackFor = Exception.class)
public void doUpdate() {
// 业务处理
}
一句话总结
事务方法尽量用 public,不要把关键事务逻辑放在 private 方法上。
六、异步或多线程场景下,事务边界为什么会变
这个坑在复杂业务里特别容易被忽略。
典型场景
@Transactional(rollbackFor = Exception.class)
public void process() {
// 主线程更新数据
CompletableFuture.runAsync(() -> {
// 子线程更新数据
});
throw new RuntimeException("模拟异常");
}
很多人会以为,外层方法有事务,里面异步线程的操作也应该跟着一起回滚。
实际上通常不是这样。
为什么会失效
Spring 事务是和当前线程绑定的。
你在主线程里开启的事务,并不会自动传播到新的异步线程里。
也就是说:
- 主线程有主线程的事务上下文
- 子线程是新的执行线程
- 子线程里的数据库操作,默认不在主线程事务里
所以你主线程抛异常回滚,并不代表子线程里的操作也会跟着回滚。
正确思路
异步场景下不要想当然地把它当成“还是同一个事务”。
通常应该:
- 明确主线程和子线程的事务边界
- 子线程里如果有独立数据库操作,需要自己定义事务策略
- 对一致性要求高的场景,不要轻易混用事务和异步
一句话总结
Spring 事务默认是线程级别的,跨线程后事务上下文就变了。
七、真实开发里,我一般怎么排查事务问题
事务问题有时候比普通 bug 更绕,因为代码表面上看不一定有错。
我一般会按下面几个方向排查。
1. 先看调用方式有没有经过 Spring 代理
先确认是不是:
- 同类内部调用
- this.xxx() 调用
- 非 Spring 管理对象调用
这类问题出现概率非常高。
2. 再看异常有没有真正抛出去
重点确认:
- 是不是 try-catch 后吞掉了
- 有没有只是打日志没抛异常
- 有没有手动 return 掉
3. 看异常类型是否支持默认回滚
如果抛的是受检异常,要确认有没有写:
@Transactional(rollbackFor = Exception.class)
4. 看执行线程有没有变化
如果中间有这些场景,要特别小心:
- @Async
- CompletableFuture
- 线程池
- 消息异步消费
很多事务问题本质上不是“事务失效”,而是“你已经不在原来的事务线程里了”。
5. 最后看日志和数据库结果
事务问题不能只看代码推断,最好结合:
- SQL 执行日志
- 异常日志
- 数据库最终结果
因为事务最终有没有生效,还是要看数据结果。
八、我自己总结的几个经验
1. 不要把事务想得太“自动化”
很多人会下意识觉得,只要加了 @Transactional,Spring 就会自动处理好一切。
其实不是。
事务生效有前提,回滚也有条件。
2. 事务代码要尽量清晰,不要写得太绕
事务方法里如果:
- 又有内部调用
- 又有异步
- 又有异常吞掉
- 又有多层封装
那后期出问题会很难查。
3. 对一致性要求高的代码,最好显式设计事务边界
不要把“事务应该会生效吧”当成理所当然。
重要链路最好一开始就把事务边界想清楚。
4. 真正判断事务有没有生效,最终还是看数据
代码只是“理论”,数据库结果才是“事实”。
总结
Spring 事务本身并不复杂,真正复杂的是:
业务代码的调用方式、异常处理方式以及执行线程,往往会让事务边界和想象中不一致。
这篇文章总结了几个最常见的事务失效场景:
- 同类方法内部调用
- 异常被捕获但没有继续抛出
- 抛出的不是运行时异常
- 方法不是 public
- 异步或多线程场景下事务边界变化
如果你也遇到过“明明加了事务但还是不回滚”的问题,可以先按这几个方向排查,通常能比较快缩小范围。
很多时候,事务不是没加,而是:
加了,但没有在正确的调用链路里生效。