为什么明明在方法上写了 @Transactional,测试也故意抛了异常,数据库里的数据却还是写进去了、没有回滚?Spring 事务失效是后端开发里最常见也最隐蔽的”静默 bug”:代码不报错、编译能过、上线后才在真实故障里暴露。本文盘点 8 个让 @Transactional 不回滚的高频坑,每个都给出可复现的代码片段与修复方式,并附一张对照表和一个最小正确写法,帮你把事务真正”管住”。
一、先搞清楚:事务为什么能生效
Spring 的声明式事务本质是 AOP 代理:容器在启动时为标注了 @Transactional 的 Bean 生成一个代理对象,当外部调用该方法时,代理先开启事务、再执行目标方法、最后根据是否抛异常决定提交或回滚。关键点是——事务逻辑发生在”代理”这一层,而不是你的方法体内。一旦调用没经过代理(比如同类内部自调用、方法是 private),事务就直接”消失”。理解了这一点,下面 8 个坑就都能解释通。
二、8 个让事务失效的坑
1. 方法是 private / final / static
Spring 事务依赖代理(JDK 动态代理或 CGLIB)。JDK 代理只能代理接口方法,CGLIB 通过继承重写方法,而 private、final、static 方法无法被重写或代理,注解直接被忽略。把事务方法改成 public 且非 final 即可。
2. 同类内部自调用(最经典)
在 Service 内部,方法 A 直接调用本类的 this.save(),绕过代理,事务不生效:
@Service
public class OrderService {
public void createOrder(Order o) {
// 自调用:this 是原始对象,不是代理,@Transactional 不生效
this.saveAndNotify(o);
}
@Transactional
public void saveAndNotify(Order o) {
orderMapper.insert(o);
throw new RuntimeException("模拟失败");
}
}
修复方式有两种:把被调用方法抽到另一个 @Service Bean 里注入调用;或在 Spring 配置类加 @EnableAspectJAutoProxy(exposeProxy = true),再用 AopContext.currentProxy() 拿到代理再调:
@EnableAspectJAutoProxy(exposeProxy = true)
@Configuration
public class AopConfig {}
// 调用侧改为走代理
public void createOrder(Order o) {
((OrderService) AopContext.currentProxy()).saveAndNotify(o);
}
3. 异常被 catch 吞掉,没往外抛
代理只在”方法向外抛出异常”时才触发回滚。如果你在方法里 try-catch 吞掉了异常,代理感知不到失败,事务照样提交:
@Transactional
public void transfer() {
try {
accountMapper.debit(100);
accountMapper.credit(100);
} catch (Exception e) {
log.error("fail", e); // 异常被吞,事务提交!
}
}
需要回滚就别吞异常;若必须处理,手动 throw new RuntimeException(e) 或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
4. 抛出的异常类型不在回滚范围内
@Transactional 默认只对 RuntimeException 和 Error 回滚,受检异常 Exception(如 IOException、自定义业务异常继承 Exception)不会触发回滚。显式声明:
// 指定回滚的异常类型,或指定不回滚的异常类型
@Transactional(rollbackFor = Exception.class)
public void syncData() throws BusinessException {
repo.save();
throw new BusinessException("数据冲突"); // 现在会回滚
}
5. 异常抛在另一个线程里
事务绑定在当前线程的 ThreadLocal 上。如果在方法里 new Thread() 或扔进线程池执行写库并抛异常,主线程的事务完全感知不到,自然不会回滚:
@Transactional
public void batchImport() {
executor.submit(() -> {
orderMapper.insert(bigOrder); // 在子线程执行
throw new RuntimeException("子线程炸了"); // 主事务无感知,已提交
});
}
跨线程的写操作要么放到同一事务线程,要么改用消息表 / 本地事务表做最终一致,别指望一个 @Transactional 包住多线程。
6. 数据库引擎本身不支持事务
MySQL 的 MyISAM 引擎不支持事务,无论怎么加注解都不会回滚;PostgreSQL 默认 InnoDB 等效的堆表都支持。建表前确认引擎:SHOW TABLE STATUS LIKE 't_order'; 看到 Engine=MyISAM 就得 ALTER TABLE ... ENGINE=InnoDB。
7. 传播行为或只读配置踩错
把 propagation 设成 NOT_SUPPORTED(挂起事务以非事务执行)、REQUIRES_NEW(开新事务,外层回滚不影响它)、SUPPORTS(无事务则非事务执行),都会让”预期中的那个事务”失效。另外 readOnly = true 只是给优化器的提示,本身不阻断回滚,但部分连接池会因此走只读库而写入失败。传播行为该选哪个,可对照 Spring 事务传播机制深度解析 一文。
8. Bean 根本没被 Spring 管理
类上没有 @Service/@Component,或者你用 new OrderService() 自己 new 出来的对象,它不是容器里的 Bean,没有代理、没有事务。还有一种是包没被 @ComponentScan 扫到,注解写了也白写。确认被调用对象是注入进来的 Bean,而非手动实例。
三、8 个坑一表对照
| 场景 | 现象 | 根因 | 修复 |
|---|---|---|---|
| private/final/static | 注解无效 | 无法被代理重写 | 改为 public 非 final |
| 同类自调用 | 内部方法不回滚 | 绕过代理 | 拆 Bean 或 AopContext 代理 |
| 异常被吞 | 提交成功 | 代理未感知异常 | 抛出或 setRollbackOnly |
| 异常类型不对 | 受检异常不回滚 | 默认只回滚 RuntimeException | rollbackFor = Exception.class |
| 多线程抛异常 | 主事务提交 | 事务绑线程 | 同线程或最终一致方案 |
| MyISAM | 永不回滚 | 引擎不支持事务 | 改用 InnoDB |
| 传播配置错 | 事务被挂起 | NOT_SUPPORTED 等 | 按语义选传播行为 |
| 非 Spring Bean | 注解无效 | 无代理 | 确保被容器管理并注入 |
四、一个最小正确写法
@Service
public class AccountService {
private final AccountMapper accountMapper;
public AccountService(AccountMapper accountMapper) {
this.accountMapper = accountMapper;
}
@Transactional(rollbackFor = Exception.class,
isolation = Isolation.READ_COMMITTED,
timeout = 5)
public void transfer(Long from, Long to, BigDecimal amount) {
accountMapper.debit(from, amount);
accountMapper.credit(to, amount);
// 任何 RuntimeException / Exception 都会触发回滚
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("金额必须为正");
}
}
}
要点:方法 public、由外部 Bean 注入调用、rollbackFor 覆盖业务异常、不在内部吞异常。配合单元测试断言回滚最稳:在测试中抛异常后查询库,确认记录不存在。
五、怎么快速定位事务是否真的生效
三招自证:① 开 logging.level.org.springframework.transaction.interceptor=DEBUG,看日志里有没有 “Creating new transaction”;② 在方法里打断点,确认调用栈经过 TransactionInterceptor;③ 写一条故意抛异常的集成测试,断言数据未落库。定位到是”自调用”还是”异常被吞”后,对照上面表格改即可。如果业务已经跨多个服务、单库事务兜不住,就要上升到 Seata 分布式事务 这类方案。
六、边界:单机事务 vs 分布式事务
本文讲的都是单数据源、单 Spring 容器内的声明式事务。一旦涉及跨库、跨服务(比如下单同时扣库存和记账),@Transactional 管不到别的连接,必须引入 Seata、消息最终一致或 TCC。事务失效的 8 个坑属于”基本功没做对”,先把单机事务管牢,再谈分布式——否则连本地回滚都不可靠,上层再花哨也是空中楼阁。相关的安全与微服务上下文可参考 Spring Security 认证授权与 JWT 实战 与 Spring Cloud 微服务治理。
七、结语
Spring 事务失效几乎都源于”调用没走代理”或”异常没传到代理”。记住一句话:public 方法、外部调用、异常外抛、rollbackFor 覆盖、引擎支持事务,五点同时满足,事务才不会悄悄失效。把这篇文章的对照表存进团队 Code Review 清单,能挡掉绝大多数线上”脏数据”事故。




