为什么你的@Transactional事务没有回滚?从内部调用失效到掌握事务之道

48 阅读8分钟

作为一名Java开发者,你是否遇到过这样的场景:明明在方法上加了@Transactional注解,期待异常时数据回滚,却发现数据库变化依旧生效?最近我自己就踩了这个坑:在事务方法A中调用了另一个事务方法B,结果异常抛出后,B的操作竟没回滚。查阅资料后发现,这是经典的“内部调用”问题,导致AOP代理失效。这让我反思:事务管理看似简单,却藏着诸多陷阱。本文从开发者视角出发,针对Spring事务,带你从“小白”阶段的困惑,到理解失效场景、底层原理,再到最佳实践,最终实现使用事务“举重若轻”。如果你是初学者,别担心,我们一步步拆解;如果你有经验,或许能从中获得新启发。

事务为什么会失效?

作为事务小白,我最初以为加个@Transactional就万事大吉:方法执行异常,数据自动回滚。但现实往往残酷。失效的核心在于Spring的事务实现依赖AOP(Aspect-Oriented Programming)代理机制。注解不是直接作用于方法,而是通过代理对象在方法调用前后织入事务逻辑(如开启事务、提交或回滚)。

常见失效起点:内部调用(self-invocation)。比如在Service类中,方法A(标注@Transactional)调用方法B(也标注@Transactional),但由于是this.B()的直接调用,绕过了代理对象,B的事务不会触发。这就像你从房间内部推门,门锁(代理)不起作用。只有外部通过代理调用(如注入Service后service.A()),事务才生效。

为什么小白容易踩坑?因为默认配置下,一切“看起来正常”:代码运行无报错,但回滚失效,导致生产环境数据不一致。引发思考:事务不是“贴标签”那么简单,它要求我们理解代理的边界。作为开发者,从这里开始,你需要问自己:我的调用链是否绕过了AOP?

失效场景详解:常见陷阱与诊断技巧

事务失效不止内部调用一种。以下是从实际项目中总结的场景,按频率排序,帮助你快速定位。每个场景都附带诊断方法,让小白也能上手。

  1. 代理绕过场景

常见的有:非public方法、内部调用或静态/final方法,无法被代理。 比如:Service中public void A() { B(); },B标注@Transactional。这暴露了AOP的局限—JDK代理需接口,CGLIB代理类但仍需public。

  • 排查:启用日志(logging.level.org.springframework.transaction=DEBUG),观察是否进入TransactionInterceptor。若无,确认调用路径。
  1. 异常规则不匹配

默认仅回滚RuntimeException子类Checked Exception(如IOException)不会触发回滚。如果方法抛出SQLException,事务提交成功,但是不会回滚。如果捕获异常未抛出(如try-catch吞异常),事务视作正常结束。异常分类是Java设计哲学,但事务中需自定义规则以覆盖业务异常。

  • 排查:检查栈追踪,确认异常类型。添加rollbackFor=Exception.class测试。
  1. 传播行为冲突

嵌套事务下,Propagation.REQUIRES_NEW独立提交,外层失败不影响内层。举个例子,外层REQUIRED,内层REQUIRES_NEW;外异常仅回滚外层。传播如事务“边界协议”,不当设置导致部分一致性—在分布式系统中,这可能是灾难源头。

  • 排查:模拟嵌套,检查数据库变更。默认REQUIRED适合大多数,但多服务时易冲突。
  1. 环境与配置问题: 数据库不支持(如MySQL MyISAM引擎无事务),或多线程/@Async丢失ThreadLocal绑定。比如异步任务中事务失效,因连接不共享,所以事务不止代码,还依赖基础设施。
    • 排查:确认数据源(InnoDB支持),用JDBC日志追踪连接。

这些场景从简单代理问题渐入复杂传播,帮助开发者构建排查思维。可以用单元测试(如@SpringBootTest + @Rollback)模拟每个场景,之后你就可以和别人说:事务这一块*/,嘿嘿~你懂的。

事务底层原理:从表象到核心机制

Spring事务不是黑盒,它建立在AOP、TransactionManager和JDBC之上。Spring 的事务管理之所以“看起来简单、用起来坑多”,是因为它的每一步都建立在几个环环相扣的机制之上。只要有一环断掉,回滚就失效就不可避免。

1. 一切的根基:AOP 代理

当 Spring 容器启动时,只要发现类或方法上加了 @Transactional,它就会为这个 Bean 创建一个代理对象(接口用 JDK 动态代理,类用 CGLIB 生成子类)。真正的业务方法不再直接被调用,而是先经过 TransactionInterceptor 这个切面。
这个拦截器会在方法执行前决定“要不要开启事务”,执行后决定“提交还是回滚”。
因此,一旦调用绕过了代理(最典型的内部 this.xxx() 调用、非 public 方法、final 类等),拦截器根本不会被触发,事务逻辑自然不会执行——这就是“内部调用失效”的根本原因。

2. 事务传播与隔离:决定“用哪个连接、哪种规则”

拦截器被触发后,会去 PlatformTransactionManager(通常是 DataSourceTransactionManager)申请事务。
关键决策在传播行为(Propagation):

  • REQUIRED(默认):先去 ThreadLocal 里看有没有正在进行的事务(TransactionSynchronizationManager.hasResource(),有就加入,没有就新开一个。
  • REQUIRES_NEW:先把当前事务挂起(suspend),重新拿一个新连接,事务彻底独立。
  • 其他行为同理(NESTED 则用 Savepoint 实现嵌套回滚)。

同时,隔离级别(isolation)会直接翻译成 connection.setTransactionIsolation(...),决定数据库用什么锁策略。
这就是为什么 REQUIRES_NEW 能“逃过”外层回滚——它压根儿就不是同一个物理事务。

3. 资源绑定:ThreadLocal + ConnectionHolder

一旦决定要开启事务,Spring 会把当前线程和数据库连接绑定起来:
TransactionSynchronizationManager 把一个 ConnectionHolder(包装了 Connection)放进 ThreadLocal,并把 connection.setAutoCommit(false)。
之后方法里所有的 JdbcTemplate、MyBatis、JPA 操作拿到的都是同一个物理连接,从而保证原子性。
如果你换了线程(@Async、CompletableFuture、线程池手动 submit),ThreadLocal 自然拿不到绑定,事务就丢了。

4. 提交/回滚的最终决策

方法正常结束 → TransactionInterceptor 调用 commit() → connection.commit()
方法抛异常 → 拦截器根据 rollbackFor / rollbackOn 规则决定:

  • 匹配 → connection.rollback()(NESTED 时 rollback 到 Savepoint)
  • 不匹配或异常被 catch 吞掉 → 当作正常结束 → commit()

整个链条就像一条精密的传动带:
代理 → 传播决策 → ThreadLocal 绑定连接 → 正常/异常路径 → commit/rollback
只要传动带上任何一个环节断了(代理被绕、ThreadLocal 丢失、异常规则不匹配、传播行为拆散连接),事务就失效。

最佳实践:让事务真正“举重若轻”的三层进阶指南

把复杂的事务用起来像呼吸一样自然,其实就靠这三层递进的习惯。按这个顺序落地,你的项目里基本不会再出现“事务没回滚”的生产事故。

第一层:基础避坑(绝大部分情况这个就够了)

  1. 永远让代理生效

    • 推荐做法:把@Transactional 写在接口或父类方法上,或者干脆拆成两个 Service(ServiceA 调用 ServiceB)。
    • 实在改不了结构:加下面两行配置,然后在类里这样调用
      @EnableAspectJAutoProxy(exposeProxy = true)  // 应用启动类或配置类上
      
      ((MyService)AopContext.currentProxy()).innerMethod();  // 内部调用时强行走代理
      
  2. 异常回滚规则写死一条

    @Transactional(rollbackFor = Exception.class)  // 一劳永逸,Checked 和 Unchecked 都回滚
    

    特殊不回滚的再单独加 @Transactional(noRollbackFor = BizException.class)

  3. 传播行为只用两种就够

    • 99% 的场景用默认 REQUIRED
    • 极少数必须物理隔离的场景(比如日志记录绝对不能回滚)才用 REQUIRES_NEW,而且要写注释说明理由

做到这三条,内部调用、异常吞掉、传播错乱这三大元凶基本被消灭。

第二层:监控与测试

  1. 本地测试必写

@SpringBootTest
   @Transactional  // 测试类上加
   class OrderServiceTest {
       @Test
       void shouldRollbackWhenException() {
           orderService.placeOrder();   // 里面故意抛异常
           // 断言数据库没变化
       }
   }

或者直接加 @Rollback(false) 看是否真的提交了。

  1. 生产监控两板斧

    • Spring Boot Actuator:打开 /actuator/metrics/spring.tx(能看到提交、回滚次数)
    • 日志里加一行:logging.level.org.springframework.transaction.interceptor=TRACE
      异常时立刻能看到 “Creating new transaction…” 和 “Committing transaction” 或 “Rolling back”
  2. 异常被吞时的救命稻草

    try {
        ...
    } catch (Exception e) {
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        throw e;
    }
    

第三层:让事物像火箭一样快像泰山一样稳

  1. 拆大事务 → 小事务
    错误示范:一个方法里又扣库存、又发消息、又写日志,全包在一个事务里。
    正确做法:核心业务走 REQUIRED,日志、发消息拆出去(另起事务或完全无事务)。

  2. 常用性能开关

    @Transactional(timeout = 30)           // 30秒超时,防止死锁卡住线程
    @Transactional(readOnly = true)         // 纯查询时加,数据库能走从库、少加锁
    
  3. 分布式时代两把终极武器

    • 需要强一致:Seata AT 或 XA(Atomikos)
    • 能接受最终一致:直接上 Saga(TCC / 可靠消息),彻底摆脱 @Transactional 的局限

结语:事务之道,举重若轻的精髓

看到这里,说明你已经知道怎么使用事务,并且能够用好事务了,这就是从“小白踩坑”到“举重若轻”的分水岭:把事务当成一条看得见的链条,而不是一个黑盒注解。掌握了链条,就掌握了所有失效场景的根源,也就真正用好@Transactional 了。