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调用innerTxMethod,innerTxMethod里抛了异常,事务注解按道理应该生效并回滚——但你去查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-class=true,也就是说默认走 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注入自己是否循环依赖的顾虑。 - 容易测试:可以单独 mock
InnerTxService,也可以对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,全局开启暴露代理会有一点点性能损耗(其实微乎其微,但洁癖型架构师会介意)。 - 强转类型容易踩雷:如果开启了
proxyTargetClass=true,currentProxy()返回的是 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() | 看调用方式,打印代理类 |
| 方法非 public | private void txMethod() | Spring 代理无法拦截 private |
| 类被 final 修饰 | final class DemoService | CGLIB 无法继承 final 类 |
| 异常被吞掉 | try-catch后没抛出 | 事务拦截器认为方法正常返回 |
| Checked Exception 无 rollbackFor | 抛IOException | 默认只回滚RuntimeException和Error |
| 类未被 Spring 管理 | 手动new DemoService() | 检查 Bean 注解 |
| 绕过代理直接调用 | target.method()不是注入对象 | 检查调用路径 |
| 多线程 | 子线程里调事务方法 | 事务绑定在当前线程的数据库连接上 |
| 事务传播配置理解错误 | REQUIRES_NEW参与方以为是独立事务 | 核对日志确认事务 ID |
5.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(),再对照这篇文里的排查表走一遍——我赌十有八九能直接定位到原因。