为什么 Spring AOP 自调用会失效?如何解决?
简化版
Spring AOP 基于代理对象增强方法调用,自调用失效是因为同一个类内部用 this.method() 调用时没有经过代理对象,而是直接调用目标对象方法。常见解决方式是拆分到另一个 Bean、通过代理对象调用、使用 AopContext.currentProxy(),或改用 AspectJ 编译期/加载期织入。
详细版
Spring AOP 的增强逻辑挂在代理对象上。外部调用 Bean 时,容器返回的是代理对象,调用会进入拦截器链;但类内部自调用时,本质是目标对象内部的普通方法调用,不会重新绕到代理对象。
典型例子:
@Service
public class OrderService {
public void createOrder() {
saveOrder(); // 自调用,不经过代理
}
@Transactional
public void saveOrder() {
// 保存订单
}
}
如果外部调用 orderService.saveOrder(),事务增强能生效;如果外部调用 createOrder(),再由 createOrder() 内部调用 saveOrder(),saveOrder() 上的事务可能不生效。
解决思路有:
- 把被增强方法拆到另一个 Spring Bean;
- 注入当前 Bean 的代理对象,通过代理调用;
- 开启
exposeProxy并使用AopContext.currentProxy(); - 对事务场景,调整事务边界到外部入口方法;
- 需要拦截内部调用时,考虑 AspectJ 而不是 Spring 代理式 AOP。
面试要讲清楚根因:不是注解失灵,而是调用路径没有经过代理对象。
完整版教学
一、Spring AOP 的增强挂在哪里
Spring AOP 默认是代理式 AOP。容器创建目标对象后,会在外面包一层代理对象。调用方从容器拿到 Bean 时,拿到的通常是代理对象,代理对象负责执行事务、日志、权限、缓存等横切逻辑,然后再调用目标对象。
外部调用方
-> 代理对象 Proxy
-> 事务拦截器
-> 日志拦截器
-> 目标对象 Target.method()
关键点是:增强逻辑不在目标对象自己的方法体里,而在代理对象的调用入口上。只有调用先到达代理对象,拦截器链才有机会执行。
记忆钩子:Spring AOP 不是把增强代码塞进原方法,而是在目标对象外面包一层代理入口。
二、自调用为什么绕过代理
所谓自调用,就是一个类内部方法调用同一个类的另一个方法。Java 里这种调用会编译成对当前对象的直接调用,语义上就是 this.saveOrder()。这个 this 指向目标对象本身,不是 Spring 容器外面暴露的代理对象。
public void createOrder() {
this.saveOrder();
}
执行路径会变成:
外部调用方
-> 代理对象 Proxy.createOrder()
-> 目标对象 Target.createOrder()
-> this.saveOrder()
-> Target.saveOrder()
saveOrder() 这一跳没有回到 Proxy,所以它上面的 @Transactional、@Cacheable、自定义切面等都不会按预期触发。这个问题在事务面试里特别高频,因为很多人把事务注解写在内部方法上,然后在同类入口方法里调用。
三、用事务例子看失效路径
假设订单服务里 createOrder() 先校验参数,再调用 saveOrder() 写数据库。saveOrder() 标了 @Transactional。
@Service
public class OrderService {
public void createOrder(OrderCommand command) {
validate(command);
saveOrder(command);
}
@Transactional
public void saveOrder(OrderCommand command) {
orderRepository.insert(command);
orderItemRepository.batchInsert(command.items());
}
}
外部调用 createOrder() 时,代理只拦截了 createOrder()。如果 createOrder() 本身没有事务注解,进入目标对象后再调用 saveOrder(),事务拦截器不会重新执行。
期望:
createOrder -> saveOrder -> 开启事务 -> insert -> batchInsert -> 提交
实际:
proxy.createOrder -> target.createOrder -> target.saveOrder
saveOrder 没经过 proxy,事务增强缺席
数字化理解:如果 saveOrder() 里有 2 次数据库写入,第二次失败,期望是 2 次都回滚;自调用失效时可能第一条已经提交,造成订单主表和明细表不一致。
四、方案一:拆分到另一个 Bean
最推荐的工程方案通常是拆 Bean。把被增强的方法放到另一个 Spring Bean 中,由原 Bean 注入并调用它。这样调用天然经过容器代理。
@Service
public class OrderService {
private final OrderWriter orderWriter;
public void createOrder(OrderCommand command) {
validate(command);
orderWriter.saveOrder(command);
}
}
@Service
public class OrderWriter {
@Transactional
public void saveOrder(OrderCommand command) {
// 写数据库
}
}
调用路径变成:
OrderService.createOrder()
-> OrderWriter 代理对象
-> 事务拦截器
-> OrderWriter 目标方法
这个方案的优点是结构清楚,也能顺便梳理职责。比如 OrderService 负责编排,OrderWriter 负责事务写入。它比在类里强行拿代理对象更容易维护。
五、方案二:通过代理对象调用自己
有些项目会通过注入自身代理来解决。例如给当前类注入 OrderService self,然后调用 self.saveOrder()。只要注入的是代理对象,增强就能生效。
@Service
public class OrderService {
private final OrderService self;
public OrderService(@Lazy OrderService self) {
this.self = self;
}
public void createOrder(OrderCommand command) {
self.saveOrder(command);
}
@Transactional
public void saveOrder(OrderCommand command) {
// 写数据库
}
}
这种方案能工作,但会让类对代理机制产生依赖,也可能引入循环依赖、可读性下降等问题。它适合短期修复或团队明确接受这种风格的项目,不如拆 Bean 稳。
还可以用 AopContext.currentProxy():
((OrderService) AopContext.currentProxy()).saveOrder(command);
但它需要开启代理暴露,而且代码直接绑定 Spring AOP 上下文,通常不作为首选。
六、方案三:调整事务边界或使用 AspectJ
很多自调用问题其实是事务边界设计不清。比如 createOrder() 是真正的业务入口,那么事务注解应放在 createOrder() 上,而不是放在内部 saveOrder() 上。
@Transactional
public void createOrder(OrderCommand command) {
validate(command);
saveOrder(command);
}
如果需求确实是拦截同类内部方法调用,Spring 代理式 AOP 天然不擅长。AspectJ 通过编译期或类加载期织入,增强代码能织进方法调用点,因此可以处理自调用。但 AspectJ 引入成本更高,配置、构建和排查都更复杂。
| 方案 | 优点 | 代价 |
|---|---|---|
| 拆 Bean | 清晰、推荐 | 类数量增加 |
| 注入自身代理 | 改动小 | 依赖代理机制 |
| AopContext | 快速 | 侵入性强 |
| 调整事务入口 | 语义正确 | 需要重新梳理边界 |
| AspectJ | 能拦截内部调用 | 工程复杂度高 |
七、还有哪些类似失效场景
自调用只是代理失效的一种。Spring AOP 还常见这些边界:private 方法不能被代理增强,final 方法无法被 CGLIB 覆盖,非 Spring 容器创建的对象没有代理,直接 new 出来的对象不会有 AOP。
高频失效路径:
1. this.method() 自调用
2. private 方法
3. final 类或 final 方法
4. 非 Spring Bean
5. 调用的是目标对象而不是代理对象
排查时不要只看注解有没有写,要看调用对象是不是代理、调用方法是否可代理、调用路径是否经过代理入口。
八、常见误区与追问
- 误区:只要方法上有 @Transactional 就一定生效。 注解只是元数据,必须经过 Spring 代理拦截器链才会执行增强逻辑。
- 误区:自调用失效是 Spring 事务专属问题。 缓存、日志、权限、自定义切面都可能因为自调用失效。
- 误区:用 CGLIB 就能解决自调用。 CGLIB 仍是代理式调用,同类内部
this调用不会自动回到代理入口。 - 追问:最推荐的解决方案是什么? 通常是拆到另一个 Bean 或把事务边界放到外部入口方法。
- 追问:AopContext.currentProxy() 好不好? 能解决,但侵入性强,需要暴露代理,优先级不如结构调整。
- 追问:private 方法上的事务为什么不生效? 代理无法从外部拦截私有方法调用,CGLIB 也不能覆盖 private 方法。
- 追问:怎么确认拿到的是代理对象? 可以用 Spring 的
AopUtils.isAopProxy(bean)或观察运行时类名。
九、加强记忆
自调用失效的记忆锚点是“增强在代理上,不在 this 上”。外部调用经过代理,内部 this 调用直达目标对象;解决时优先拆 Bean 或调整入口事务,少用强行拿代理这种补丁式写法。