Spring @Transactional自调用失效:原理剖析与五种解决方案 1. 问题先导一个常见的诡异场景先看一段代码。这是在业务系统里非常典型的写法我敢说绝大部分Java开发都写过类似的Service public class UserService { Transactional(rollbackFor Exception.class) public void createUserAndLog(String name) { userDao.insert(name); saveOperationLog(name); // 自调用同类内部直接调用 } Transactional(propagation Propagation.REQUIRES_NEW, rollbackFor Exception.class) public void saveOperationLog(String name) { logDao.insert(create user: name); throw new RuntimeException(记录日志失败); } }你满心以为createUserAndLog开启事务插入用户成功后再开一个新事务记录日志日志失败不影响用户插入用户数据应该正常提交。结果一跑用户数据也回滚了。还有一个更典型、更让新人懵的场景Service public class UserService { public void doBiz() { updateUser(); } Transactional public void updateUser() { userDao.update(...); throw new RuntimeException(触发回滚); } }doBiz方法从外部被调用内部走updateUser()updateUser明明标了Transactional可异常抛出后数据库照样回滚了吗答案没回滚。数据稳稳地写进去了。这就是社区里被反复讨论的自调用self-invocation导致 Transactional 失效问题。我在面试候选人的时候也经常拿这道题来摸底能答到这是AOP代理导致的的人大概占三成能进一步讲清楚为什么自调用绕过了代理对象的人十个人里面不一定有一个。这篇文章就把这件事彻底掰开揉碎讲清楚从现象到原理再到解决方案最后附上我在实际项目里排查事务失效问题的一些经验。2. 现象复现自调用到底是怎么失灵的2.1 最小复现 Demo先把复现环境交代清楚。我用的是 Spring Boot 2.7.x MyBatis-Plus MySQL数据库表很简单一张user表一张operation_log表。为了项目直观我把事务场景写进同一个 Service 里Service public class DemoService { Resource private UserMapper userMapper; Resource private OperationLogMapper logMapper; /** * 外部调用入口 */ public void outerMethod() { System.out.println( outerMethod 开始); innerTxMethod(); System.out.println( outerMethod 结束); } /** * 标注了事务但被同类内部方法直接调用 */ Transactional(rollbackFor Exception.class) public void innerTxMethod() { userMapper.insert(new User(zhangsan)); logMapper.insert(new OperationLog(insert user zhangsan)); throw new RuntimeException(模拟业务异常); } }测试入口很简单SpringBootTest class DemoServiceTest { Resource private DemoService demoService; Test void testSelfInvocation() { try { demoService.outerMethod(); } catch (Exception e) { System.out.println(捕获异常 e.getMessage()); } // 查一下数据库看user表有没有数据 } }执行结果outerMethod调用innerTxMethodinnerTxMethod里抛了异常事务注解按道理应该生效并回滚——但你去查user表zhangsan这条数据已经老老实实躺在里面了。换句话说Transactional在这个调用链上完全没起作用。2.2 把代理三个字摆到台面上为什么失效网上的标准答案是因为自调用没有经过Spring的代理对象。这句话本身没错但太笼统了很多新手听完依然不知道问题出在哪。我换个角度解释Spring 的Transactional不是靠改Java代码实现的它是靠在你不知道的地方把你的 Bean 换成了一个代理对象实现的。当 Spring 容器扫描到DemoService这个类里有Transactional注解时容器实际放进来的并不是DemoService这个类的直接实例而是一个经过包装后的代理对象。你从外部注入并调用demoService.outerMethod()时调用的是这个代理对象的方法。代理对象在执行方法前后插入了一堆事务逻辑开启事务、执行方法、异常回滚、正常提交。但问题来了outerMethod()方法内部写的this.innerTxMethod()这个this指的是谁2.3 this 和 proxy 的根本区别this指向的是当前这个对象实例。在代理模式下this指向的恰恰是那个没有被包装过的原始对象而不是代理对象。打个比方。你请了个保安代理对象在小区门口站岗所有访客外部调用都必须经过保安登记事务拦截才能进楼。可是你这栋楼有内部通道你自己从屋里直接走到隔壁办公室自调用保安根本看不见你——内部通道上可没设岗。所以innerTxMethod上面的Transactional注解虽然写着但执行它的路径根本没经过保安事务拦截器压根儿没上班自然就谈不上什么开启事务、异常回滚。注意这里我把事务拦截器比作保安是为了方便理解。实际上 Spring 的事务逻辑藏在TransactionInterceptor里后面第3部分会详细拆解这个拦截器的执行链路。理解了this是原始对象、外部调用是代理对象之后自调用失效的谜底就揭开一大半了。剩下要搞清楚的是 Spring 究竟怎么把 Bean 变成代理对象的、以及事务拦截器到底在哪一步插入逻辑。3. 底层原理Transactional 的代理机制与执行链路3.1 Transactional 的本质是 AOP 通知Spring 的事务管理底层依赖的是 AOP面向切面编程。Spring AOP 的核心概念可以简化成三件事切面Aspect横切关注点的模块化比如事务管理就是一个切面。通知Advice切面在特定时机执行的逻辑比如开启事务提交事务回滚事务。切点Pointcut判断哪些方法需要被切入。Transactional注解本身并不干活它只是一个标记。真正干活的是一个叫TransactionInterceptor的类它实现了事务通知的逻辑。Spring 启动时会扫描所有 Bean 的方法一旦发现某个方法或类上标了Transactional就把这个 Bean 包装成代理对象并让TransactionInterceptor插入到方法调用链上。可以理解为代理对象在外层包了一个壳壳里拦截到方法调用后先执行事务逻辑再执行真正的方法代码。外部调用 ↓ 代理对象.method() ← 这里先被 TransactionInterceptor 拦住了 ↓ 事务逻辑开启事务 ↓ 真实对象.method() ← 你的业务代码在这里执行 ↓ 事务逻辑提交或回滚注意看如果你从外部调demoService.outerMethod()会完整走上面这条链路。但如果outerMethod()里写的是this.innerTxMethod()innerTxMethod方法的调用起点是真实对象根本没机会回到代理对象那里链路就变成外部调用 ↓ 代理对象.outerMethod() ↓ 事务逻辑outerMethod 有事务则开启 ↓ 真实对象.outerMethod() ↓ 真实对象.innerTxMethod() ← 直接从真实对象身上调用不再经过代理所以innerTxMethod上的Transactional形同虚设。3.2 Spring 如何决定创建哪种代理对象这里引出一个很多人面试时经常被追问的点JDK 动态代理和 CGLIB 代理的区别。Spring AOP 默认支持两种代理方式第一种JDK 动态代理要求目标类必须实现至少一个接口。基于java.lang.reflect.Proxy生成一个接口的代理实现。代理类和目标类是平级关系都实现了同一个接口。优点不需要额外引入依赖基于 JDK 原生反射。缺点只能代理接口中定义的方法如果某个方法不在接口里代理不了。第二种CGLIB 代理不要求目标类实现接口。基于字节码生成技术直接生成目标类的一个子类。通过继承父类的方式重写父类方法来实现拦截。优点可以代理普通类不需要接口。缺点final类、final方法无法被 CGLIB 代理JDK 9 需要额外的--add-opens参数较新版本越来越省心但老项目偶尔能碰到。在 Spring Boot 2.x 的默认配置下spring.aop.proxy-target-classtrue也就是说默认走 CGLIB 代理。哪怕你的类实现了接口Spring Boot 也默认用 CGLIB只有在显式配置为false时才退回 JDK 动态代理。那实际开发中怎么确认自己的 Bean 到底是 JDK 代理还是 CGLIB 代理最土的办法是在启动后打印一下对象类型System.out.println(demoService.getClass());如果是 JDK 动态代理打印结果里会有jdk.proxy或者com.sun.proxy字样如果是 CGLIB打印结果一般是com.example.service.DemoService$$EnhancerBySpringCGLIB$$xxxxx。注意这个细节在排查事务失效问题时非常有用。有些场景下你以为自己在调代理对象实际上因为类型不对Spring 注入的是别的对象打印类的toString一眼就能看出问题。3.3 TransactionInterceptor 的事务拦截逻辑TransactionInterceptor是 Spring 事务通知的核心实现它的工作流程可以用下面几步概括确定事务属性解析方法或类上的Transactional注解拿到传播行为propagation、回滚规则rollbackFor、隔离级别isolation等配置。判断是否已有事务根据传播行为决定是新建事务、加入已有事务还是挂起已有事务如REQUIRES_NEW就表示挂起当前事务、新开一个。执行业务方法通过反射调用目标方法。方法正常返回如果业务方法没有抛异常提交事务。方法抛出异常根据回滚规则判断该不该回滚。默认情况下只有RuntimeException和Error才会触发回滚Checked Exception编译期异常不会回滚。这也是一个高频踩坑点。伪代码大致是这样// 这是 TransactionInterceptor 内部逻辑的简化示意 public Object invoke(MethodInvocation invocation) throws Throwable { TransactionInfo txInfo createTransactionIfNecessary(...); Object result; try { result invocation.proceed(); // 调用真正的业务方法 } catch (Throwable ex) { completeTransactionAfterThrowing(txInfo, ex); // 根据规则回滚或提交 throw ex; } finally { cleanupTransactionInfo(txInfo); } commitTransactionAfterReturning(txInfo); // 正常返回时提交 return result; }理解了这段逻辑你就能解释很多诡异现象为什么Transactional方法在同类里被this.xxx()调用会失效——因为走了原始对象TransactionInterceptor根本没被触发。为什么在事务方法里 catch 掉异常就不会回滚——因为TransactionInterceptor根本没看到业务方法抛出的异常它只看到方法正常返回。为什么Checked Exception默认不回滚——因为默认回滚规则只匹配RuntimeException和Error要想让编译期异常也回滚必须显式写rollbackFor Exception.class。3.4 代理创建的底层入口BeanPostProcessor可能有人要问Spring 到底在什么时机、哪个环节把普通 Bean 替换成代理对象的答案是在 Bean 的生命周期里有一个叫BeanPostProcessor的后置处理器扩展点。Spring 在 Bean 初始化完成之后会调用所有的BeanPostProcessor其中有一个专门负责处理 AOP 的后置处理器叫做AbstractAutoProxyCreator。它的工作流程大概是扫描容器里所有的Advisor通知器包含了切点和通知。判断当前这个 Bean 是否匹配某个Advisor的切点。如果匹配就用ProxyFactory创建代理对象。把代理对象返回给容器后续注入到其他 Bean 里的就是这个代理对象。对于Transactional来说Spring 内置了一个BeanFactoryTransactionAttributeSourceAdvisor这个 Advisor 的切点会去解析方法上有没有Transactional注解通知就是TransactionInterceptor。所以整体链路就是Transactional 注解 ↓ BeanFactoryTransactionAttributeSourceAdvisor 识别 ↓ AbstractAutoProxyCreator 创建代理 ↓ 代理对象内部织入 TransactionInterceptor ↓ 事务逻辑在方法调用前后生效这个链路不需要背源码但理解它之后你再回头去看为什么自调用会失效就不是死记结论而是能从机制上推导出来。4. 五种解决自调用失效的方案4.1 方案一把事务方法拆到独立的 Bean既然自调用失效的本质是绕过代理对象那最直接、最干净的办法就是……不让它自调用。把需要事务的方法放到另一个 Service/Component 里从外部调用Service public class DemoService { Resource private InnerTxService innerTxService; public void outerMethod() { System.out.println( outerMethod 开始); innerTxService.innerTxMethod(); System.out.println( outerMethod 结束); } } Service public class InnerTxService { Transactional(rollbackFor Exception.class) public void innerTxMethod() { userMapper.insert(new User(zhangsan)); logMapper.insert(new OperationLog(insert user zhangsan)); throw new RuntimeException(模拟业务异常); } }这时候DemoService.outerMethod()内部调用的是InnerTxService的代理对象事务自然生效。为什么我把这个方案排在最前面因为它在团队协作中最不容易出错可读性好事务边界被单独拎出来看代码的人一眼就知道哪个方法有事务。职责清晰不同的事务逻辑放到不同的类符合单一职责。不依赖魔法配置不像AopContext.currentProxy()那样需要额外开启配置也不存在Resource注入自己是否循环依赖的顾虑。容易测试可以单独 mockInnerTxService也可以对DemoService做单元测试。缺点也很明显类数量会变多小项目可能觉得为了一个方法单独建类太啰嗦。我的建议是在一个团队里定个约定凡是事务方法尽量放到独立类中或者至少不要写成同类内互相调用事务方法的形态。这个约定能帮你砍掉一大部分隐性 bug。4.2 方案二注入一个自己的代理这个思路也很直白既然this指向原始对象那我就注入一个代理对象进来通过代理对象调用事务方法。Service public class DemoService { // 注入自己 Resource private DemoService self; public void outerMethod() { System.out.println( outerMethod 开始); self.innerTxMethod(); System.out.println( outerMethod 结束); } Transactional(rollbackFor Exception.class) public void innerTxMethod() { userMapper.insert(new User(zhangsan)); logMapper.insert(new OperationLog(insert user zhangsan)); throw new RuntimeException(模拟业务异常); } }注意self注入的不是原始对象而是经过 Spring 包装的代理对象所以self.innerTxMethod()能正常触发事务。使用上有几个细节要提醒优先用Resource或者Autowired字段注入。如果用构造器注入在 Spring 2.6 的循环依赖默认不允许的配置下可能会出现启动报错因为DemoService的构造器里要注入DemoService自己。字段注入一般能绕开这个坑因为字段注入是 Bean 创建完成后再注入的不存在构造器循环依赖。加Lazy是更稳的做法Resource Lazy private DemoService self;。延迟注入可以进一步避免循环依赖问题。注意方法访问权限如果innerTxMethod是private哪怕通过代理对象调也调不到Spring 代理无法拦截私有方法一定要保证它是public的。优点是不用额外建类改动量小。缺点是注入自己这个写法对没接触过的人来说有点绕而且如果团队里有人不理解原理可能会当成死循环注入给改掉。4.3 方案三利用 AopContext.currentProxy()Spring 提供了一个工具类AopContext可以拿到当前对象的代理对象。用法Service public class DemoService { public void outerMethod() { System.out.println( outerMethod 开始); // 从 AopContext 中取当前代理对象 DemoService proxy (DemoService) AopContext.currentProxy(); proxy.innerTxMethod(); System.out.println( outerMethod 结束); } Transactional(rollbackFor Exception.class) public void innerTxMethod() { userMapper.insert(new User(zhangsan)); logMapper.insert(new OperationLog(insert user zhangsan)); throw new RuntimeException(模拟业务异常); } }有一个前置条件必须做在配置类上显式开启暴露代理对象Configuration EnableAspectJAutoProxy(proxyTargetClass true, exposeProxy true) public class AppConfig { }如果你的项目用了 Spring Boot默认配置下需要自己声明一下EnableAspectJAutoProxy(exposeProxy true)否则运行时会抛IllegalStateException: Cannot find current proxy。这个方案的好处是改动范围集中在方法内部不用新增类。坑也比较明显必须开exposeProxy全局开启暴露代理会有一点点性能损耗其实微乎其微但洁癖型架构师会介意。强转类型容易踩雷如果开启了proxyTargetClasstruecurrentProxy()返回的是 CGLIB 代理强转成DemoService没问题但如果 JVM 因为某些原因回退到了 JDK 动态代理强转就会ClassCastException。代码可读性一般团队新人看到AopContext.currentProxy()第一反应通常是这是什么黑魔法。所以说能用方案一就别用方案三。这个方案适合那种代码结构实在不好动、又急着修 bug的场景。4.4 方案四从容器里手动取代理对象思路和方案二类似只是拿代理对象的方式从注入改成了主动获取Service public class DemoService implements ApplicationContextAware { private ApplicationContext applicationContext; public void outerMethod() { System.out.println( outerMethod 开始); DemoService proxy applicationContext.getBean(DemoService.class); proxy.innerTxMethod(); System.out.println( outerMethod 结束); } Transactional(rollbackFor Exception.class) public void innerTxMethod() { userMapper.insert(new User(zhangsan)); logMapper.insert(new OperationLog(insert user zhangsan)); throw new RuntimeException(模拟业务异常); } Override public void setApplicationContext(ApplicationContext applicationContext) { this.applicationContext applicationContext; } }applicationContext.getBean(DemoService.class)拿到的必然是 Spring 容器里的代理对象如果这个 Bean 被代理了的话所以innerTxMethod的事务能生效。这个方案相当于手动模拟了 Spring 的依赖注入能用但不优雅。它比较适合那些注册表模式策略模式的场景——方法所属的 Bean 是在运行时才决定用什么类型的。实际项目里我很少推荐这个因为你完全可以在一开始就把self注入进来没必要每次调用都去容器查一次。4.5 方案五绕开注解用编程式事务有时候与其纠结注解失效的问题不如换个思路直接用编程式事务把事务边界显式写进代码里。Spring 提供了TransactionTemplate用它可以在任意地方开启事务Service public class DemoService { Resource private TransactionTemplate transactionTemplate; public void outerMethod() { System.out.println( outerMethod 开始); transactionTemplate.execute(status - { try { userMapper.insert(new User(zhangsan)); logMapper.insert(new OperationLog(insert user zhangsan)); throw new RuntimeException(模拟业务异常); } catch (RuntimeException e) { // 可在这里设置回滚状态 status.setRollbackOnly(); throw e; } }); System.out.println( outerMethod 结束); } }也可以用PlatformTransactionManager手动控制Service public class DemoService { Resource private PlatformTransactionManager transactionManager; public void outerMethod() { DefaultTransactionDefinition def new DefaultTransactionDefinition(); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); def.setTimeout(30); TransactionStatus status transactionManager.getTransaction(def); try { userMapper.insert(new User(zhangsan)); logMapper.insert(new OperationLog(insert user zhangsan)); transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); throw e; } } }编程式事务最大的优点是精确可控没有代理、没有 AOP、没有自调用失效的问题事务边界一目了然。缺点也很明显代码侵入性比注解式事务强得多业务代码里塞了事务开启/提交/回滚逻辑可读性受一点影响。TransactionTemplate的回调写法需要小心setRollbackOnly和抛出异常的配合不然容易出现虽然设置了回滚但事务管理器没收到通知的问题。我个人一般这么选常规业务用Transactional 拆 Bean复杂的、需要动态控制事务边界的场景比如循环内处理多条数据、重试后决定是否回滚、跨方法组合事务宁可上TransactionTemplate也别硬凑注解。4.6 五种方案对比方案改动量可读性适用范围踩坑点拆独立 Bean中等好通用推荐优先类数量变多注入自己小中快速修复构造器循环依赖private 方法无效AopContext.currentProxy()小较差结构不好动时救急必须开启 exposeProxy从容器取代理小中动态获取场景与 Spring 容器耦合编程式事务中中复杂事务边界代码侵入性高5. 事务失效排查经验别光盯着自调用5.1 先确认代理到底存不存在遇到事务不生效第一步不要急着查数据库先打印一下你调用的对象到底是什么类型System.out.println(this.demoService.getClass());我帮同事排查问题时经常看到有人判断 Transactional 失效结果打印出来发现class com.example.service.DemoService连$$EnhancerBySpringCGLIB都没有——说明这个 Bean 压根没有被代理根本不是自调用的问题而是这个类根本不在 Spring 容器里比如用new出来的或者代理创建被某些配置关闭了。所以我的排查顺序是打印对象getClass()确认是代理类。如果连代理类都不是检查类上是否漏了Service/Component或者是不是用new直接创建了。如果是代理类但事务还是失效检查是不是自调用、是不是private方法、是不是在catch里吞了异常、是不是抛了Checked Exception但没配rollbackFor。看日志确认有没有经过事务拦截器见 5.3。5.2 事务失效常见原因速查表这里总结一份我多年踩坑整理出来的速查表基本覆盖日常开发里能见到的绝大部分情况失效原因示例排查方式同类自调用this.innerTxMethod()看调用方式打印代理类方法非 publicprivate void txMethod()Spring 代理无法拦截 private类被 final 修饰final class DemoServiceCGLIB 无法继承 final 类异常被吞掉try-catch后没抛出事务拦截器认为方法正常返回Checked Exception 无 rollbackFor抛IOException默认只回滚RuntimeException和Error类未被 Spring 管理手动new DemoService()检查 Bean 注解绕过代理直接调用target.method()不是注入对象检查调用路径多线程子线程里调事务方法事务绑定在当前线程的数据库连接上事务传播配置理解错误REQUIRES_NEW参与方以为是独立事务核对日志确认事务 ID5.3 用日志确认事务边界Spring 的事务框架本身带有非常详细的日志输出可以在application.yml里打开logging: level: org.springframework.transaction: TRACE org.springframework.jdbc.datasource: DEBUG跑一次之后你会看到类似下面的日志Getting transaction for [com.example.service.DemoService.innerTxMethod] Participating in existing transaction Completing transaction for [com.example.service.DemoService.innerTxMethod] Releasing JDBC Connection after transaction如果看到Getting transaction for ...说明事务拦截器确实介入了。如果方法里抛了异常日志里还会出现Initiating transaction rollback Rolling back JDBC transaction on Connection ...要是你的方法全程连Getting transaction for都没出现那就是事务拦截器压根没执行到这时候要回到代理和调用链路上查。5.4 设计事务方法的三条经验这些经验来自项目实践不一定写在文档里但很实用经验一事务方法要短、平、快。不要在事务里做远程调用、RPC、消息推送、大文件读写。事务的本质是锁住数据库资源事务时间越长连接占用越久锁冲突概率越高。有些线上事故就是事务里调了超时的外部接口导致连接池被占满。经验二事务边界要尽量和业务边界对齐。一个事务只做一件事。不要把多个独立操作硬塞进一个大事务里回头又用REQUIRES_NEW切来切去这样排查问题时心态会崩。经验三培养一看代码就知道事务会不会失效的判断力。看到this.xxx()脑子里立刻弹警报警告看到private方法上有Transactional赶紧把它改public看到try-catch里吞异常想一下事务回滚的条件。这种条件反射比背一百个面试题都顶用。6. 写在最后的几点补充这个话题在 Spring 社区里属于老生常谈但常在河边走哪有不湿鞋我见过不少工作四五年的开发在代码 review 时依然会给同一个类里的方法互相调用加上Transactional然后理直气壮地告诉我说这样应该没问题吧。每次遇到这种情况我都建议对方先去把当前对象的getClass()打出来看一眼。你亲眼见过$$EnhancerBySpringCGLIB$$这个后缀再理解代理对象和真实对象不是同一个东西自调用失效这件事就能在脑子里扎下根。另外多说一句这类问题在团队中最好沉淀成一份简单的规范比如Transactional只允许写在 public 方法上事务方法尽量不要在同类里被其他方法调用一定要自调用时优先考虑拆独立 Bean事务方法内部不要 catch 掉异常后不重新抛出需要编译期异常回滚的显式配置rollbackFor Exception.class。这些约定不复杂但能省下大量排查事务诡异行为的时间。顺便说一句Spring 的代理原理不只是Transactional的根基它也是理解Async、Cacheable、Retryable这类注解的通用钥匙。你把这一套代理机制吃透了再遇到为什么我的 Async 不生效为什么 Cacheable 缓存没走这类问题大概率能举一反三不用网上搜半天。我在实际开发里最常用的方案还是拆 Bean虽然多一个类但换来的是明确的事务边界和更顺畅的 review 体验。如果你也在项目里碰到了类似的问题不妨先打开代码跑一把getClass()再对照这篇文里的排查表走一遍——我赌十有八九能直接定位到原因。