数据没回滚也不报错:@Transactional 自调用失效的 3 种复现与修复

发布时间:2026/10/5 9:23:28
数据没回滚也不报错:@Transactional 自调用失效的 3 种复现与修复 本文摘要同类内方法互调绕过代理事务注解静默失效异常抛出后数据已提交不回滚。修复是拆出独立类让调用经过代理或改用编程式事务重建边界。该结论只在代理被绕过时成立多线程与非公开方法的事务边界需另行配置。一、问题现象转账接口抛出RuntimeException(Insufficient balance)控制台只有异常栈没有事务警告查库发现alice的balance已是-100bob一行未动——扣款写入了加款没执行。这不是建了事务但回滚规则不对而是Transactional从未被解析事务根本没创建。三类表现同根同类内this.deduct()互调注解被绕过同类内调用Transactional(propagation Propagation.REQUIRES_NEW)独立事务没开出来Async或新线程里再调同类方法代理绕过与 ThreadLocal 上下文缺失叠加。排查方向因此不是调rollbackFor而是确认调用点是否经过代理。二、排查与定位按代价从低到高先排除事务语义层异常是否被catch吞掉或是否是 checked 异常而未配rollbackFor。这一层事务是存在的。在出问题的方法入口打印TransactionSynchronizationManager.isActualTransactionActive()返回false即事务没建立问题在代理层。看调用点写法同一类里直接写deduct(...)或this.deduct(...)就是自调用。查库确认数据已在表里说明各写操作独立提交与部分提交吻合。一条隐性线索SimpleJpaRepository.save()自带Transactional。外层没有事务时save()仍会独立建事务并立即提交异常抛出时该行早已落库。三、关键原理Spring AOP 基于代理。Spring Framework 6.x 文档原话是 “Self-invocation does not lead to an advice getting a chance to run.”——同类内this.method()直接落到 target 对象不进拦截器链事务 advice 没机会执行。静默性是同一策略文档写明Transactional标在 protected、private 或 package-private 方法上 “no error is raised”。自调用失效行为一致——方法照常执行只是没有事务语义。传播属性失效是同一根因的另一面。属性由代理建事务时解析代理没走到就不被读取REQUIRES_NEW预期挂起外层事务、独立提交实际与业务共用上下文业务回滚时审计记录一起消失NESTED预期按 savepoint 子事务语义执行实际退化为外层事务内的普通写入回滚范围扩大到整体。多线程是另一层事务上下文绑定在TransactionSynchronizationManager的 ThreadLocal新线程不继承。Async里即使修好自调用也要在新线程自己的调用链上建事务。CGLIB 与 JDK 动态代理两种模式下自调用问题都存在Spring Framework 5.xSpring Boot 2.x与 6.xSpring Boot 3.x行为一致。四、复现与修复环境Java 17、Spring Boot 3.x依赖spring-boot-starter-data-jpa测试用 H2spring-boot-starter-test与com.h2database:h2均为 test scope生产 MySQL InnoDB。Account实体含id、username、balanceAccountRepository extends JpaRepositoryAccount, Long。实体src/main/java/com/example/entity/Account.javapackagecom.example.entity;importjakarta.persistence.*;importjava.math.BigDecimal;EntityTable(namet_account)publicclassAccount{IdGeneratedValue(strategyGenerationType.IDENTITY)privateLongid;Column(nullablefalse,uniquetrue)privateStringusername;Column(nullablefalse)privateBigDecimalbalance;publicAccount(){}publicAccount(Stringusername,BigDecimalbalance){this.usernameusername;this.balancebalance;}publicLonggetId(){returnid;}publicBigDecimalgetBalance(){returnbalance;}publicvoidsetBalance(BigDecimalbalance){this.balancebalance;}}Bug 版服务src/main/java/com/example/service/TransferService.javapackagecom.example.service;importcom.example.entity.Account;importcom.example.repository.AccountRepository;importorg.springframework.stereotype.Service;importorg.springframework.transaction.annotation.Transactional;importjava.math.BigDecimal;ServicepublicclassTransferService{privatefinalAccountRepositoryrepo;publicTransferService(AccountRepositoryrepo){this.reporepo;}publicvoidtransfer(LongfromId,LongtoId,BigDecimalamount){deduct(fromId,amount);// 自调用不经过代理add(toId,amount);// 自调用不经过代理}Transactionalpublicvoiddeduct(Longid,BigDecimalamount){Accountaccrepo.findById(id).orElseThrow();acc.setBalance(acc.getBalance().subtract(amount));repo.save(acc);if(acc.getBalance().signum()0){thrownewRuntimeException(Insufficient balance);}}Transactionalpublicvoidadd(Longid,BigDecimalamount){Accountaccrepo.findById(id).orElseThrow();acc.setBalance(acc.getBalance().add(amount));repo.save(acc);}}复现测试src/test/java/com/example/SelfInvocationTest.javapackagecom.example;importcom.example.entity.Account;importcom.example.repository.AccountRepository;importcom.example.service.TransferService;importorg.junit.jupiter.api.Test;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.boot.test.context.SpringBootTest;importjava.math.BigDecimal;importstaticorg.junit.jupiter.api.Assertions.*;SpringBootTestclassSelfInvocationTest{AutowiredprivateTransferServicetransferService;AutowiredprivateAccountRepositoryrepo;TestvoidselfInvocation_rollbackNotApplied(){repo.deleteAll();LongaliceIdrepo.save(newAccount(alice,newBigDecimal(100))).getId();LongbobIdrepo.save(newAccount(bob,newBigDecimal(50))).getId();assertThrows(RuntimeException.class,()-transferService.transfer(aliceId,bobId,newBigDecimal(200)));Accountalicerepo.findById(aliceId).orElseThrow();System.out.println( Alice balance after failed transfer: alice.getBalance());assertEquals(-100,alice.getBalance().intValue());}}执行mvn -DtestSelfInvocationTest#selfInvocation_rollbackNotApplied test。预期输出 Alice balance after failed transfer: -100实际输出断言通过即证明数据已提交、未回滚再执行SELECT username, balance FROM t_account WHERE username alice;复查balance仍为-100撰写环境未重新执行数值按用例断言给出。失败处理测试类或测试方法上加了Transactional用例结束自动回滚Bug 被测试框架掩盖断言永远是100。去掉测试上的Transactional让它真实读写数据库。修复版把deduct、add挪进独立 BeanAccountOpssrc/main/java/com/example/service/AccountOps.java调用回到代理链packagecom.example.service;importcom.example.entity.Account;importcom.example.repository.AccountRepository;importorg.springframework.stereotype.Service;importorg.springframework.transaction.annotation.Transactional;importjava.math.BigDecimal;ServicepublicclassAccountOps{privatefinalAccountRepositoryrepo;publicAccountOps(AccountRepositoryrepo){this.reporepo;}Transactionalpublicvoiddeduct(Longid,BigDecimalamount){Accountaccrepo.findById(id).orElseThrow();acc.setBalance(acc.getBalance().subtract(amount));repo.save(acc);if(acc.getBalance().signum()0){thrownewRuntimeException(Insufficient balance);}}}add方法原样搬入同样标注TransactionalTransferService改为注入AccountOps后调用accountOps.deduct(...)、accountOps.add(...)。同一用例的assertEquals改为100即通过。五、替代方案与取舍方案选择条件代价边界拆分到独立 Service Bean大多数业务场景职责本可拆分多一个类调用链变长不适合把原子流程硬拆成多个类、接口数暴涨TransactionTemplate编程式事务事务内有分支判断、需要细粒度边界边界从注解变代码块风格不统一团队统一声明式事务、想一眼看清单方法语义时Lazy自注入self只想改一行暂不动结构self.xxx()与this.xxx()并存易误读调用点很多时容易继续踩坑不宜长期使用AopContext.currentProxy()需要精确拿到当前代理要配exposeProxy true业务代码耦合 AOP 内部 API不希望业务层依赖 Spring AOP 内部 API 时AspectJ 编译期或加载期织入无法拆类、必须同类调用、需织入非public方法构建链路与团队学习成本高中小项目只为修一处自调用时不值得上倾向优先拆 Bean其次TransactionTemplate确实改不动类结构才考虑 AspectJ。六、验证结果与边界在 MySQL 上用SET GLOBAL general_log ON;复核事务语义场景修复前自调用修复后经代理普通Transactional只见save()的独立COMMIT异常后数据仍在业务异常触发ROLLBACK数据回到100REQUIRES_NEW审计审计与业务同事务一起消失预期输出独立COMMIT审计行保留NESTED子流程无 savepoint回滚扩大到整体预期输出SAVEPOINT与ROLLBACK TO SAVEPOINT上表日志形态按 Spring 事务语义给出具体语句以 general query log 为准未逐字核对。H2 与 MySQL 在 savepoint 支持上一致但并发隔离级别表现不同涉及NESTED时建议在 MySQL 上复验。适用边界结论只在代理被绕过时成立。经代理调用的public方法、rollbackFor配置正确的场景不适用。非public方法即使经代理调用也不生效且不报错拆 Bean 救不了只能调整可见性或改用 AspectJ。多线程场景即使修好自调用也必须在新线程内自己建事务。单条写入且不需要原子性、或本来就是只读查询不必引入事务边界。参考资料Spring Framework Reference — AOP ProxyingSpring Framework Reference — Transactional Annotation