@Transactional 写上去了,日志里也确实走了业务逻辑,偏偏回滚没发生;或者相反,一个无关的异常把整批数据全带走了。这类问题九成不是"事务配置少了参数",而是代理压根没拦到那次调用。
Spring 事务不是数据库特性,是一层 AOP 包装:TransactionInterceptor 在方法前后做 begin / commit / rollback,真正的提交交给 PlatformTransactionManager。理解这条链路,八种失效场景就都是同一个问句的不同答案——这次调用经过代理了吗?事务管理器管到这个连接了吗?
以下按 Boot 3.3.x / Framework 6.1.x 核对。
1. 三个角色
| 角色 | 实现 | 失效时对应的问题 |
|---|---|---|
| 代理 | CGLIB(Boot 默认)或 JDK 动态代理 | 自调用、非 public、注解位置错 |
| 拦截器 | TransactionInterceptor |
异常的传播与回滚规则 |
| 事务管理器 | DataSourceTransactionManager 等 |
多数据源选错、跨线程 |
事务状态挂在 TransactionSynchronizationManager 的 ThreadLocal 上:resources(连接持有者)、synchronizations、readOnly、actualTransactionActive。这个事实直接决定了第 7 种失效。
2. 八种失效一览
| # | 场景 | 现象 | 根因 | 改法 |
|---|---|---|---|---|
| 1 | 同类内部方法调用 | 内层方法抛异常,外层照样提交 | 调用走 this,不经代理 |
拆到另一个 Bean;或注入自身代理 |
| 2 | 方法非 public | 注解完全不生效,无任何日志 | 代理无法拦截(private/final/static 必失效) | 改 public,或把事务边界上移 |
| 3 | 异常被 catch 吞掉 |
抛了异常但数据已写入 | 拦截器看不到异常 → 正常提交 | 重新抛出,或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
| 4 | 抛受检异常 | IOException 抛出仍提交 |
默认只回滚 RuntimeException/Error |
@Transactional(rollbackFor = Exception.class) |
| 5 | 注解只写在接口上 | 换代理方式后时灵时不灵 | 属性查找按"具体方法 → 声明类 → 接口"回退,重载或类级注解会读到另一份 | 注解统一写实现类 |
| 6 | 多数据源用了错的管理器 | 操作 A 库却回滚了 B 库 | 默认取唯一的 PlatformTransactionManager,有多个时取 @Primary |
@Transactional(transactionManager = "orderTxManager") |
| 7 | 事务方法里开新线程 | 子线程的写操作不回滚 | 事务绑定在 ThreadLocal,新线程是新事务 | 子线程内用 TransactionTemplate,或改为同步/消息补偿 |
| 8 | readOnly=true 却写 / 嵌套传播配错 |
写入被驱动拒绝,或内层失败把外层一起回滚 | 只读是提示不是强制;REQUIRED 下内层异常标记外层回滚 |
去掉 readOnly;内层改 REQUIRES_NEW |
3. 复现与改正(前三种最常见)
自调用,最小复现:
@Service
public class OrderService {
public void create(Order o) {
this.save(o); // 绕过代理,事务不生效
}
@Transactional
public void save(Order o) { orderMapper.insert(o); }
}
改成拆 Bean 或注入自身代理(@Lazy 打断循环):
@Service
public class OrderService {
@Lazy @Autowired private OrderService self;
public void create(Order o) { self.save(o); } // 走代理
}
catch 吞异常与受检异常:
@Transactional(rollbackFor = Exception.class)
public void importRows(List<Row> rows) {
for (Row r : rows) {
try { repo.insert(r); }
catch (Exception e) {
log.warn("skip row {}", r.id(), e);
// 吞掉 = 继续提交;要整体失败就 rethrow
}
}
}
部分失败要保留已成功的行,就不该用一个大事务包住循环——这是设计问题,不是注解问题。要整体失败,直接把异常抛出去,别在 catch 里"记录后继续"。
第 5 种的写法收敛:
// 注解写在实现类,类级 + 方法级各一份,避免接口/实现类元数据打架
@Service
@Transactional(rollbackFor = Exception.class)
public class PaymentServiceImpl implements PaymentService {
@Override
public void pay(String id) { /* ... */ }
}
多数据源与线程:
@Transactional(transactionManager = "orderTxManager")
public void createOrder(Order o) { orderMapper.insert(o); }
// 子线程必须自己开事务,父事务不会传播过去
public void asyncWrite(Order o) {
transactionTemplate.executeWithoutResult(s -> orderMapper.insert(o));
}
4. 传播行为七种取值
| 取值 | 外层有事务时 | 外层无事务时 | 适用面 |
|---|---|---|---|
REQUIRED(默认) |
加入 | 新建 | 绝大多数写操作 |
REQUIRES_NEW |
挂起外层、新建 | 新建 | 日志落库、独立补偿,要求"外层回滚我不回滚" |
NESTED |
建保存点 | 新建 | 局部可回退、但随外层一起回滚(需 JDBC 保存点,JPA 不支持) |
SUPPORTS |
加入 | 非事务执行 | 只读查询,有事务也行没事务也行 |
NOT_SUPPORTED |
挂起外层 | 非事务执行 | 事务里不该做的慢操作 |
MANDATORY |
加入 | 抛 IllegalTransactionStateException |
强制要求调用方已开事务 |
NEVER |
抛异常 | 非事务执行 | 明确禁止在事务中调用 |
三者的关键差异在"回滚的传播方向":REQUIRED 下内层抛异常会把整个事务标记为 rollback-only,外层即使 catch 了也提交不了,常见日志是 Transaction rolled back because it has been marked as rollback-only;NESTED 回滚到保存点,外层可以继续;REQUIRES_NEW 是两个独立事务,外层回滚不影响内层已提交的数据。
5. @Transactional 与 synchronized 叠加
@Transactional
public synchronized void deduct(Long id, int qty) { // 锁的范围 < 事务的范围
stockMapper.decrease(id, qty);
}
锁在方法体执行完就释放,而提交发生在代理返回之后的拦截器里。于是出现窗口:线程 A 已经释放锁但事务未提交,线程 B 拿到锁读到旧值。并发扣减在这种写法下照样超卖。
要锁就锁在事务外层(分布式锁或数据库行锁),或者在事务内用 SELECT ... FOR UPDATE。synchronized 与事务的作用域不一致,这不是事务失效,而是锁放错了地方。
6. 超时与只读提示
| 配置 | 实际作用 | 别指望它 |
|---|---|---|
timeout = 5(秒) |
拦截器记 deadline,每次语句执行前检查;超时抛 TransactionTimedOutException |
不会中断正在执行的 SQL,长查询仍会跑完 |
readOnly = true |
提示驱动 setReadOnly、Hibernate 置 FlushMode.MANUAL、跳过脏检查 |
不是数据库层强制,写操作是否报错取决于驱动与连接池 |
readOnly 在部分驱动上会被翻译成 SET SESSION TRANSACTION READ ONLY,此时写入直接抛 SQLException;换一个驱动或连接池,同样的代码又能悄悄写进去。所以它只能算优化提示,不能当权限控制。给只读查询配上 readOnly=true 的实际收益是省掉脏检查开销,典型量级是查询路径上的一次对象比对,别当成数量级优化。
7. 怎么确认"事务到底有没有生效"
最直接的一句,放在方法体第一行:
log.info("tx active = {}", TransactionSynchronizationManager.isActualTransactionActive());
在自调用场景里它打出 false,在正常代理调用里打出 true。另一种办法是先看代理类型:
log.info("proxy = {}", AopUtils.isAopProxy(this) ? this.getClass() : "none");
this.getClass() 打印出 OrderService$$SpringCGLIB$$0 之类的名字说明代理存在;但代理存在不代表当前这次调用经过它——这两个判断要一起看。
8. 常见坑
| 现象 | 根因 | 处理 |
|---|---|---|
| "加了注解就该有事务" | 自调用、非 public、Bean 未被 Spring 管理 | 用第 7 节的方法先验证 |
| 内层失败把外层也回滚了 | REQUIRED 下内层标记 rollback-only |
内层改 REQUIRES_NEW,外层 catch 才有意义 |
| 事务里做远程调用 | 持锁/持连接时间被拉长,连接池被占满 | 远程调用挪到事务外,先落库再异步发 |
| 回滚了但缓存/消息已发出 | 事务只管数据库,不管 MQ 与 Redis | 事务提交后发(TransactionSynchronization#afterCommit) |
@Transactional 在 @Async 方法上 |
异步线程不继承事务上下文 | 异步方法内部自己开事务 |
| 单元测试里事务不生效 | 手工 new 了对象,没走容器 |
用 @SpringBootTest 或直接测 TransactionTemplate |
第三条在真实项目里代价最大:一个事务方法里调用第三方 HTTP 接口,连接被占用到接口超时为止,连接池在几十毫秒到几秒的区间内被耗尽,表现却是"偶发获取连接超时"。
9. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 跨服务一致性 | Saga / 本地消息表 / TCC | 本地事务管不到远程 |
| 大批量导入 | 分批提交 + 幂等键 | 单事务过大,undo 与锁开销都不可控 |
| 需要精确控制提交点 | TransactionTemplate |
编程式比注解更清楚 |
| 读多写少的报表查询 | 只读连接或从库 | 事务本身解决不了查询成本 |
| 需要在提交后做事 | TransactionSynchronization |
与事务生命周期对齐 |
事务的边界应该落在"一次业务不变量变更"上,而不是落在"一个 Controller 方法"或"一个 Service 类的所有 public 方法"上。边界画错时,注解写得再全也只是把错误包得更整齐。
10. 自测
- 自调用为什么会让
@Transactional失效,除了拆 Bean 还有什么办法? - 抛
IOException默认会回滚吗,怎么改? REQUIRED下内层抛异常被外层catch,为什么仍然提交不了?NESTED与REQUIRES_NEW在回滚范围上差在哪?synchronized与事务叠加时,锁为什么保护不了并发?- 怎么用一行日志判断当前方法真的在事务里?