一、一个让无数人栽跟头的经典场景
几乎每个使用 Spring 框架的开发者都遇到过这样的情况:在同一个类中,一个带有事务注解的方法调用了另一个同样带有事务注解的方法,结果事务没有生效,数据出现了不一致。
更诡异的是,单独调用那个方法时事务是正常的,但一旦通过内部调用,事务就像凭空消失了一样。
这个问题困扰了大量开发工程师,网上的解答往往只给结论:"不要在同一个类中调用事务方法"。但很少有人真正讲清楚背后的原理。本文将从 AOP 代理机制出发,彻底剖析事务失效的两大根源:自调用与代理丢失。
二、先搞清楚:事务到底是怎么生效的
在深入问题之前,必须先理解 Spring 事务的底层实现逻辑。
Spring 的事务管理并非通过修改业务代码实现,而是基于 AOP(面向切面编程)技术,在运行时通过动态代理来增强目标对象。当一个类或方法被标注了事务注解后,Spring 容器在初始化该 Bean 时,并不会直接返回原始对象,而是返回一个代理对象。
这个代理对象的工作流程是:当外部调用目标方法时,代理会先拦截这个调用,然后开启一个事务上下文,接着调用原始对象的方法,待方法执行完毕后,根据执行结果决定是提交还是回滚事务,最后关闭事务上下文。
关键点在于:只有通过代理对象调用方法时,事务拦截逻辑才会被触发。 如果调用绕过了代理,直接命中了原始对象,那么事务逻辑根本不会执行。
理解了这一点,接下来的两个问题就清晰了。
三、根源一:自调用——代理对象的盲区
自调用是指同一个类中的方法 A 调用了同一个类中的方法 B。
假设方法 A 和方法 B 都标注了事务注解。当外部通过代理对象调用方法 A 时,事务拦截器会正常工作,方法 A 内部的事务逻辑被正确执行。但当方法 A 内部又调用了方法 B 时,情况发生了变化。
此时,方法 A 内部持有的是原始对象的引用(即 this),而不是代理对象的引用。因此,当它调用方法 B 时,实际上是原始对象在调用自己的另一个方法,这个调用完全绕过了代理层。
结果就是:方法 B 的事务注解不会被任何拦截器感知,事务逻辑不会被触发,方法 B 就像一个普通方法一样执行了。如果方法 B 中有数据写入操作,这些操作不会被纳入方法 A 的事务上下文中,一旦方法 A 后续出现异常需要回滚,方法 B 的写入操作不会被回滚,数据不一致就此产生。
这就是自调用导致事务失效的根本原因:this 永远指向原始对象,永远无法触发代理逻辑。
需要特别说明的是,这个问题不仅存在于同一个类中。如果一个类通过内部持有的另一个 Bean 实例(而非从容器获取的代理实例)来调用方法,同样会触发代理丢失。本质上,任何绕过 Spring 容器获取代理对象的调用方式,都会导致事务失效。
四、根源二:代理丢失——比自调用更隐蔽的陷阱
如果说自调用是"明知故犯",那么代理丢失就是"暗箭难防"。它发生在一些看起来完全正常的场景中,却同样导致事务失效。
场景一:非 public 方法的事务注解被忽略
Spring 的默认 AOP 实现基于代理模式,而基于接口的 JDK 动态代理和基于继承的 CGLIB 代理,都有一个共同的前提:目标方法必须是 public 的。
如果你在一个 private 或 protected 方法上标注了事务注解,Spring 容器在创建代理时会直接忽略这些注解。因为代理机制无法拦截非 public 方法的调用,事务切面也就无从织入。
这种情况下,方法看上去有事务注解,实际上没有任何事务保护。更麻烦的是,这种问题在编译期不会报错,运行时也不会抛出异常,只有在数据出现不一致时才会被发现。
场景二:Bean 的生命周期导致代理不完整
在某些特殊的 Bean 初始化场景中,代理对象可能没有被正确创建。
例如,当一个类在构造函数中调用了自身的另一个方法时,此时 Spring 容器尚未完成 Bean 的初始化,代理对象可能还未生成。即便后续代理对象创建成功,构造函数中的调用已经发生,事务逻辑永远不会被补上。
另一种情况是,通过 new 关键字手动创建对象,而非从 Spring 容器中获取。手动创建的对象是原始对象,没有经过代理增强,所有 AOP 逻辑(包括事务)都不会生效。这种错误在单元测试中尤其常见。
场景三:异步调用中的事务上下文丢失
当一个事务方法内部发起异步调用时,新线程无法继承原线程的事务上下文。因为事务上下文是绑定在当前线程上的,异步线程是一个全新的执行上下文,它看不到也无法感知原线程的事务状态。
这导致异步方法中的数据库操作不会加入原事务,即使异步方法标注了事务注解也无济于事。这种场景下的事务失效,本质上也是一种"代理丢失"——异步线程中的调用绕过了原事务代理的管辖范围。
五、更深层的理解:为什么代理模式会有这个缺陷
要彻底理解这个问题,需要回到代理模式的设计本质。
代理模式的核心思想是:在不修改原始对象的前提下,为其增加额外的功能。实现方式是创建一个代理对象,代理对象持有原始对象的引用,并在调用前后插入增强逻辑。
但代理模式有一个天生的局限:它只能拦截通过代理对象发起的调用。 当原始对象内部调用自身方法时,this 引用指向的是原始对象本身,代理层根本不知情。
这不是 Spring 的 bug,而是代理模式的固有特性。任何基于代理的 AOP 实现(无论是 Spring AOP、AspectJ 还是其他框架),都无法解决自调用问题。
AspectJ 通过字节码增强可以在一定程度上绕过这个限制,因为它直接修改了类的字节码,在编译期就将切面逻辑织入了目标方法。但这已经不属于"运行时代理"的范畴了,而是另一种完全不同的技术路线。
六、解决方案:四种思路应对不同场景
理解了根因,解决方案就水到渠成了。针对不同场景,有以下四种应对思路。
思路一:重构代码,消除自调用
最干净的做法是将需要事务的方法抽取到另一个独立的类中,然后通过依赖注入调用。这样调用方持有的是代理对象的引用,事务逻辑可以正常触发。
这是最推荐的方案,不仅解决了事务问题,也让代码结构更清晰,职责更单一。
思路二:使用 AspectJ 代替 Spring AOP
如果业务逻辑确实无法避免自调用,可以考虑将事务切面从 Spring AOP 切换为 AspectJ。AspectJ 通过编译期或加载期字节码增强,可以在原始对象的方法中直接植入事务逻辑,不依赖代理,因此不受自调用影响。
但这种方案的侵入性更高,配置也更复杂,适合对事务一致性要求极高且无法重构代码的场景。
思路三:手动获取当前代理对象
在自调用的场景中,可以通过工具类从 Spring 容器中获取当前 Bean 的代理对象,然后通过代理对象调用目标方法。这种方式可以让事务逻辑生效,但代码会变得不够优雅,且增加了对 Spring 容器的依赖。
思路四:使用编程式事务管理
对于无法通过注解解决的场景,可以改用编程式事务管理。在方法内部显式地开启事务、提交或回滚。这种方式不依赖 AOP 代理,因此不存在自调用问题。
但编程式事务的缺点是侵入性高,代码冗长,且容易遗漏事务的关闭操作。建议仅在注解方式确实无法满足需求时使用。
七、排查清单:事务失效时的自查步骤
当你怀疑事务失效时,按照以下步骤逐一排查,基本可以定位问题。
第一步:确认方法是否为 public。 如果是非 public 方法,事务注解不会生效,这是最容易被忽略的原因。
第二步:确认调用方式是否为外部调用。 如果是同一个类内部的方法调用,大概率是自调用导致的代理丢失。
第三步:确认对象是否从容器获取。 如果是通过 new 关键字创建的对象,不会有任何 AOP 增强。
第四步:确认是否存在异步调用。 异步线程中的事务不会继承原线程的上下文。
第五步:确认启动参数与配置。 某些特殊配置可能关闭了事务的自动代理功能。