先看一个"没回滚"的例子
下单接口的逻辑很常见:先插订单,再扣库存。测试时故意让扣库存抛异常,结果订单表里多了一条脏数据,库存却没扣。开发第一反应是"@Transactional 没生效",于是开始在方法上反复加注解,加 rollbackFor、加 propagation,折腾半天还是没用。
事务不生效的原因就那么几类:代理没走到、异常没抛出去、回滚规则不匹配、事务管理器不对。这篇文章按"原理—写法—排查"的顺序,把常见的坑过一遍。
注解背后发生了什么
Spring 的声明式事务本质是 AOP:容器给 Bean 生成代理,方法调用前开启事务,正常返回则提交,抛异常则回滚。所以它和 @Async 一样,有一个前提——调用必须经过代理。
事务管理器由 Spring Boot 自动配置,单数据源时就是一个 DataSourceTransactionManager。真正决定"抛异常回不回滚"的是回滚规则:
| 异常类型 | 默认是否回滚 |
|---|---|
RuntimeException 及其子类 |
是 |
Error 及其子类 |
是 |
受检异常(Exception 中非运行时) |
否 |
这也是很多人踩坑的地方:自定义业务异常如果继承了 Exception,不配 rollbackFor 就不会回滚。
常用属性可以记成一张表:
| 属性 | 作用 | 常用值 |
|---|---|---|
rollbackFor |
指定哪些异常回滚 | Exception.class |
propagation |
事务传播行为 | REQUIRED(默认) |
readOnly |
只读提示 | 查询方法设 true |
timeout |
超时秒数 | 按业务设置,避免长事务 |
isolation |
隔离级别 | 一般用数据库默认 |
写对事务的几个习惯
第一,统一加 rollbackFor,不要依赖默认规则:
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCmd cmd) {
orderMapper.insert(buildOrder(cmd));
inventoryService.deduct(cmd.getSkuId(), cmd.getCount());
}
}
第二,异常要往外抛,不能自己吞掉:
try {
inventoryService.deduct(skuId, count);
} catch (Exception e) {
log.error("扣库存失败", e); // 异常被吞,事务感知不到,不会回滚
}
如果确实要捕获,就在 catch 里重新抛出业务异常,或者手动调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
第三,远程调用、发消息、发邮件不要放在事务里。事务持有数据库连接和锁,远程接口慢几秒,连接就被占用几秒;更麻烦的是一致性问题——本地事务回滚了,远程调用却已经成功。
第四,事务提交后再做副作用。可以用 TransactionSynchronizationManager.registerSynchronization,或者更简单的 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)。
第五,控制事务粒度。循环里一条条插入几万行,事务会变得非常长,undo log 膨胀、锁等待增加。改成批量插入、分批提交,每批几百到几千条,失败时只重试当前批次。
常见坑
1. 同类内部调用
public void outer() {
this.inner(); // inner 上的 @Transactional 不生效
}
@Transactional(rollbackFor = Exception.class)
public void inner() { ... }
this 不是代理对象,注解不会被拦截。把方法拆到另一个 Bean,或者从外部调用。
2. 方法不是 public
Spring 5.3(Spring Boot 2.7)下,@Transactional 只对 public 方法生效。写在 private、protected 方法上不会报错,但也不会开启事务。Spring 6 对类代理放宽了限制,但没必要依赖这个差异,统一用 public 最稳。
3. 异常被 catch 后没再抛
这是"注解加了、日志也有、就是没回滚"的第一大原因。日志只能证明代码执行过,不能证明异常传到了代理层。
4. 抛的是受检异常
throw new Exception("...") 默认不回滚。要么改抛 RuntimeException 子类,要么显式写 rollbackFor = Exception.class。
5. 多数据源没指定事务管理器
项目里配了两个 DataSource,@Transactional 默认只认主数据源的事务管理器。操作从库时,要么用 @Transactional(transactionManager = "slaveTransactionManager"),要么用 @DS 之类的切换方案,并确认事务管理器跟着切换。
6. 事务里套异步
@Async 方法运行在另一个线程,不共享调用方的事务。异步线程里查不到未提交的数据,回滚也管不到它。需要最终一致,就用消息或任务表。
7. 大事务拖垮数据库
一个事务里更新几十万行、调用多个远程接口、跑复杂计算,提交时间被拉长,锁持有时间变长,其他请求开始排队。事务里只放必要的数据库操作,其他逻辑挪到事务外。
8. 只读事务并非"性能开关"
readOnly = true 主要是给驱动和框架一个提示,某些数据库会据此拒绝写操作。它不会自动加缓存,也不会让查询变快,别指望靠它优化性能。
排查步骤
事务是否真的开启,日志比猜更可靠。在 application.yml 里打开:
logging:
level:
org.springframework.transaction: DEBUG
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
调用接口时观察日志:出现 Creating new transaction 说明事务开启了;出现 Initiating transaction rollback 和 Rolling back 说明回滚生效了;如果只有 SQL 日志、没有事务日志,说明代理没走到或者方法没被拦截。
还可以在代码里临时打印:
log.info("事务激活={}", TransactionSynchronizationManager.isActualTransactionActive());
返回 false 就不要再怀疑数据库了,问题一定在 Spring 这一层。再结合"方法是否 public、是否自调用、异常是否抛出"三项检查,基本能定位。
评论