ElIauk

Spring Boot 事务 @Transactional 入门与失效

先看一个"没回滚"的例子 下单接口的逻辑很常见:先插订单,再扣库存。测试时故意让扣库存抛异常,结果订单表里多了一条脏数据,库存却没扣。开发第一反应是"@Transactional 没生效",于是开始在方法上反复加注解,加 rollbackFor、加 propagation,折腾半天还是没用。 事务不

评论
Spring Boot 事务 @Transactional 入门与失效

先看一个"没回滚"的例子

下单接口的逻辑很常见:先插订单,再扣库存。测试时故意让扣库存抛异常,结果订单表里多了一条脏数据,库存却没扣。开发第一反应是"@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、是否自调用、异常是否抛出"三项检查,基本能定位。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者