订单服务里有一段看起来没问题的代码:OrderService.createOrder 先落订单,再调用同一个类里的 deductStock 扣减库存。deductStock 上标着 @Transactional,但库存扣减抛异常时,订单记录却没有回滚。日志里没有任何报错,事务注解也没有被忽略的警告。排查方向很容易跑偏到数据库隔离级别或者连接池,直到有人打印出 this.getClass(),才发现问题出在调用方根本没有经过代理。
这不是配置写错,而是 Spring AOP 的代理边界决定的:切面逻辑挂在代理对象上,而类内部用 this 发起的调用,走的是原始对象的方法,代理根本没有机会介入。理解这条边界,需要先看清代理对象是怎么被造出来的。
代理对象与目标对象是两个不同的实例
Spring AOP 默认采用代理模式实现切面。容器启动时,对于需要被增强的 Bean,Spring 不会把原始实例直接放进单例池,而是生成一个代理对象,让代理对象持有目标对象(target),并在调用目标方法前后插入通知逻辑。外部通过 ApplicationContext.getBean() 拿到的,是代理对象。
关键在于,代理对象和目标对象是两个不同的 Java 对象。代理对象实现了与目标相同的接口或继承了目标类,它重写了需要被增强的方法。当外部调用 orderService.deductStock() 时,实际执行的是代理类的方法:先开启事务,再通过内部持有的 target 引用调用真正的 deductStock,最后提交或回滚。
而 createOrder 内部写下的 this.deductStock(),这里的 this 指向的是目标对象自己,也就是那个没有被增强的原始实例。目标对象上的 deductStock 只是一个普通方法,没有事务拦截器,没有缓存拦截器,调用它就像调用任意一个私有方法一样直接。切面被绕过的原因,不是注解没生效,而是这次调用根本没有经过代理。
用一段示意逻辑可以看得更清楚:
// 伪代码:代理对象与目标对象的关系
class OrderServiceProxy extends OrderService {
private OrderService target;
@Override
public void deductStock(Long skuId, int count) {
TransactionStatus tx = beginTransaction();
try {
target.deductStock(skuId, count); // 真正执行
commit(tx);
} catch (Exception e) {
rollback(tx);
throw e;
}
}
}
目标对象里执行 this.deductStock() 时,this 是 target,它没有 beginTransaction,所以事务边界不存在。
JDK 动态代理与 CGLIB 的生成方式不同
Spring 选择哪种代理,取决于目标类是否实现了接口。如果目标类实现了至少一个接口,Spring 默认使用 JDK 动态代理;如果没有实现接口,则使用 CGLIB 生成子类代理。这个选择会影响代理对象的类型,也影响自调用问题的表现形式。
JDK 动态代理基于 java.lang.reflect.Proxy,在运行时生成一个实现了目标接口的类。代理对象与目标对象实现同一接口,但代理类并不是目标类的子类。外部只能通过接口类型引用代理对象,调用接口上声明的方法才会被拦截。如果目标类有一个不在接口中的 public 方法,即使它标了 @Transactional,通过接口引用也无法调用到它,更谈不上增强。
CGLIB 则通过字节码生成技术,在运行时创建目标类的子类,并重写非 final 的 public 方法。代理对象是目标类的子类,因此可以拦截类中定义的 public 方法。但 CGLIB 也有边界:final 方法不能被重写,private 方法不可见,static 方法属于类而非实例,这些方法上的注解都不会触发切面。
两种代理在自调用场景下的结果是一致的:无论代理对象是接口实现还是子类,内部 this 调用都指向目标对象,都会绕过代理。区别在于,CGLIB 代理下 this 的类型是目标类本身,而 JDK 代理下目标类可能根本没有实现代理接口,但这不影响自调用失效的结论。
自调用失效的完整调用链
回到订单服务的场景。假设 OrderService 实现了 OrderApi 接口,容器为它生成了 JDK 动态代理。外部调用链如下:
flowchart TD
A[外部调用 createOrder] --> B[代理对象拦截]
B --> C{方法是否需要增强}
C -->|createOrder 无事务注解| D[直接调用 target.createOrder]
D --> E[目标对象执行落订单]
E --> F[this.deductStock 调用]
F --> G[目标对象直接执行扣库存]
G --> H[异常抛出]
H --> I[无事务拦截器回滚]
I --> J[订单已提交 库存未回滚]
图中关键转折在 this.deductStock 这一步。代理对象在调用 createOrder 时,如果 createOrder 本身没有事务注解,代理可能只是简单转发,不做任何增强。真正的问题发生在目标对象内部:它调用 deductStock 时,this 是目标对象,不是代理对象,所以事务拦截器完全没有参与。
如果 createOrder 本身有 @Transactional,情况会略有不同:代理会为 createOrder 开启事务,deductStock 在同一个事务里执行,扣库存异常会导致整个事务回滚。但这只是因为外层方法自带事务,而不是因为 deductStock 的注解生效了。一旦外层方法没有事务,内层注解就形同虚设。
为什么 @Cacheable 和 @Async 也会踩同一个坑
自调用失效不是 @Transactional 独有的问题。@Cacheable、@Async、@Retryable 等基于 Spring AOP 的注解,都依赖代理拦截。
以 @Cacheable 为例,资料中给出的 MathService 复现了同样的现象:sumOfSquareOf2 内部两次调用 this.square(2),square 上标了 @Cacheable,但测试断言 counter 等于 2,说明 square 被执行了两次,缓存没有生效。调试时可以看到,测试类注入的 mathService 指向 MathService$$EnhancerBySpringCGLIB$$... 实例,而 MathService 内部的 this 指向原始的 MathService 实例。代理类负责拦截缓存逻辑,原始类不具备缓存能力,内部调用自然拿不到缓存值。
@Async 的情况更隐蔽。异步方法需要代理把调用提交到线程池,如果内部 this 调用,方法会在当前线程同步执行,调用方以为已经异步,实际却阻塞了当前线程。这类问题在压测时才会暴露:接口响应时间没有下降,线程池也没有被使用。
三种绕过代理边界的方案及其代价
既然问题出在 this 不指向代理对象,解决思路就是让内部调用拿到代理引用,或者干脆不用代理。
自注入:把 Bean 自己注入到自己里面,通过注入的引用调用方法。资料中的做法是给类加上 @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS),再用 @Autowired 注入自身,解决循环依赖。这样 self.square(3) 走的是代理对象,缓存生效。自注入的代价是引入了额外的字段和代理层级,代码可读性下降,而且如果注入的是接口类型,仍然受 JDK 代理只能拦截接口方法的限制。
AopContext.currentProxy():需要开启 @EnableAspectJAutoProxy(exposeProxy = true),然后通过 ((OrderService) AopContext.currentProxy()).deductStock(...) 调用。它的实现依赖 ThreadLocal 保存当前代理对象,因此不能跨线程传递。如果方法内部又提交了异步任务,在异步线程里调用 AopContext.currentProxy() 会拿不到代理。资料中也指出,这种方式还受代理模式限制,比如只能作用于 public、非 static 方法。
拆分 Bean:把需要增强的方法移到另一个 Bean 中,让调用方通过依赖注入拿到那个 Bean 的代理。这是最符合 Spring 设计意图的方式,切面边界清晰,不依赖额外配置。代价是类的职责被拆开,如果两个方法共享大量私有状态,拆分后需要重新组织数据传递。
AspectJ 织入:切换到 AspectJ 的编译时织入或加载时织入,把切面逻辑直接织入目标类的字节码,不再依赖代理对象。资料中展示了通过 @EnableCaching(mode = AdviceMode.ASPECTJ) 配合 spring-aspects 依赖和 AspectJ Maven 插件实现编译时织入,或者通过 @EnableLoadTimeWeaving 和 -javaagent 实现加载时织入。织入后,内部调用也能触发切面,因为增强逻辑已经写进了方法本身。代价是构建配置变复杂,加载时织入还需要 JVM 启动参数,对部署环境有额外要求。
| 方案 | 内部调用是否生效 | 主要代价 | 适用边界 |
|---|---|---|---|
| 自注入 | 生效 | 额外字段、循环依赖处理、接口代理限制 | 方法仍在同一个类,改动可控 |
| AopContext.currentProxy() | 生效 | 依赖 ThreadLocal,不能跨线程,需开启 exposeProxy | 同步调用链,不涉及异步传递 |
| 拆分 Bean | 生效 | 类职责拆分,共享状态需重新组织 | 方法边界清晰,适合长期维护 |
| AspectJ 织入 | 生效 | 构建配置复杂,加载时织入需 javaagent | 大量自调用、需要统一切面语义 |
生产排查:如何确认是自调用而不是事务配置问题
遇到事务不回滚,先确认调用是否经过代理。最直接的方式是在方法入口打印 this.getClass().getName(),如果输出的是目标类而不是 ...$$EnhancerBySpringCGLIB$$... 或 com.sun.proxy.$Proxy...,说明当前执行的是目标对象。
也可以在切面通知里打印调用栈,观察 deductStock 是否出现在代理类的调用链中。如果调用栈里只有目标类的方法帧,没有代理类的帧,自调用的可能性很高。
对于 @Transactional,还可以打开 Spring 的事务日志,观察方法执行时是否有 Creating new transaction 或 Participating in existing transaction 的输出。如果完全没有事务日志,说明事务拦截器没有被触发。
需要区分的是,事务失效还有别的原因:方法不是 public、异常类型不在回滚规则内、数据库引擎不支持事务、多数据源未正确绑定等。自调用的特征是:方法本身配置正确,外部调用时事务生效,只有内部调用时不生效。
设计层面的规避原则
自调用问题本质上是代理边界与类内聚之间的冲突。Spring AOP 只能拦截经过代理的调用,而类内部的方法调用天然不经过代理。与其在出问题后逐个修补,不如在设计阶段就避免把需要切面的方法放在会被内部调用的位置。
一个可操作的判断方法是:如果一个方法需要事务、缓存或异步语义,就把它视为对外暴露的入口,不要从同一个类的其他方法里直接调用它。如果确实需要复用逻辑,把逻辑抽到无切面注解的私有方法或独立组件中,让带注解的方法只负责切面边界和委托。
对于已经存在大量自调用的遗留代码,拆分 Bean 通常比自注入和 AopContext 更稳定,因为它不依赖运行时状态,也不受线程模型限制。如果拆分成本过高,再考虑 AspectJ 织入,但要接受构建和部署复杂度的上升。
代理边界不会因为注解写得漂亮而消失。它就在 this 和代理对象之间,只有明确知道当前调用走的是哪一条路径,才能判断切面是否真的生效。