1. 失效的根源:代理机制是理解所有失效场景的钥匙
Spring AOP失效这个话题,几乎每个Java后端都会在某个深夜遇到一次。最常见的画面是:明明把@Aspect、@Around都写好了,启动日志也没有任何异常,可被切的方法就是没有输出日志、没有进入统计逻辑、事务静悄悄回滚了却没提示。我在几个项目里都帮同事排查过这类问题,最后发现绝大部分Root Cause绕不出一个词——代理。Spring AOP的本质就是在运行时为目标Bean生成代理对象,然后通过代理去调用真正的方法。一旦你绕过了代理、或者代理本身根本没法处理某个方法,切面就必然失效。这篇文章我会从代理机制讲起,把自调用、方法修饰符、切点表达式、Bean获取方式这些高频失效场景逐个拆开,再带你看一条完整的线上排查链路。适合正在被AOP不生效折磨的人,也适合刚接触Spring AOP还不清楚原理的初学者。
1.1 JDK动态代理与CGLIB动态代理到底在做什么
Spring AOP不是AspectJ那种编译期字节码增强,而是运行时给Bean创建一个代理对象,把目标方法包一层。代理方式有且只有两种。
第一种是JDK动态代理。它要求目标类实现至少一个接口,核心是java.lang.reflect.Proxy和InvocationHandler。运行时生成的代理类叫com.sun.proxy.$Proxy0之类,代理和原始对象是"兄弟"关系——都实现了同一个接口,但代理不是原始对象的子类。所以用JDK代理时,proxy instanceof 接口成立,proxy instanceof 目标类不成立,强转会直接ClassCastException。
第二种是CGLIB动态代理。CGLIB不要求接口,它直接生成目标类的一个子类,通过重写父类的非final方法来完成拦截。Spring从5.3版本开始把CGLIB的类迁移到了org.springframework.cglib包下,你在堆栈里看到的类名通常长这样:com.example.service.OrderServiceImpl$$EnhancerBySpringCGLIB$$xxx。
为了让你直观感受两者的差异,这里给出一段极简实现。JDK动态代理大概是这样的:
public class JdkProxyFactory { public static <T> T create(Class<T> interfaceType, T target) { return (T) Proxy.newProxyInstance( interfaceType.getClassLoader(), new Class[]{interfaceType}, (proxy, method, args) -> { System.out.println("before"); Object result = method.invoke(target, args); System.out.println("after"); return result; }); } }CGLIB实现则更像"重写方法":创建一个子类,在子类方法里先执行切面逻辑,再调用super.xxx()。所以CGLIB天生要求目标类可以被继承,目标方法可以被重写。
1.2 Spring Boot项目的默认代理方式,和旧博客说的不一样
关于代理方式的选择,有一个特别容易过时的地方。Spring Framework时代的默认策略是:目标类有接口就用JDK动态代理,没有接口才用CGLIB。但Spring Boot 2.0之后,spring.aop.proxy-target-class默认值被设成了true,也就是说你现在用Spring Boot写项目,默认情况下走的是CGLIB。很多老博客还在说"Spring默认JDK动态代理",你现在照着操作会发现对不上。
这个差异直接影响你排查失效时的判断。比如你在Boot项目里注入一个实现类类型的字段,哪怕类实现了接口,因为默认CGLIB,代理对象可以直接强转成目标类,不会报ClassCastException;但如果某处配置把proxy-target-class改回了false,再用实现类类型去接收代理对象,启动阶段就可能抛类型转换错误。这不是AOP"失效",但表现上非常像"被切的方法没生效",容易把人带偏。
1.3 代理对象和原始对象是两个完全不同的对象
整个失效排查的思维起点就是一句话:你在容器里拿到的Bean是代理对象,代理对象内部持有原始对象的引用,然后代理转发调用。也就是说proxy.updateStock()走的是切面逻辑再转发给原始对象,而original.updateStock()就是纯粹的执行方法,切面根本不会参与。
这个区别解释了后面所有场景。Spring AOP能够拦截的,仅限于"通过代理对象发起的public方法调用"。字段访问拦不了,构造器拦不了,private方法拦不了,非Spring管理的对象更拦不了。记住这个边界,再看任何失效场景都会觉得通透。
2. 第一类高频失效场景:同类内部方法调用
在所有失效场景里,同类内部调用出现频率最高。它特别隐蔽,因为代码写起来完全没毛病,切面类也注册了,切点表达式看着也对,可结果就是"部分方法没记录"。
2.1 为什么this调用总是绕过切面
结合Spring AOP的原理,原因一句话能说清:同类内部通过this调用另一个方法时,this指向的是原始目标对象,不是代理对象,所以切面压根不会进。下面这个例子这些年我见过太多次了:
@Service public class OrderService { public void createOrder() { System.out.println("创建订单"); this.updateStock(); } public void updateStock() { System.out.println("扣减库存"); } }假设切点是execution(public * com.example.OrderService.*(..))。从Controller调用orderService.createOrder()时,因为Controller注入的是代理对象,所以createOrder()会被切面拦一次。但进入方法之后,里面的this.updateStock()是原始对象调用,这个updateStock()的切面不会执行。
你可能会想:CGLIB生成的子类不是重写了updateStock()吗?注意,CGLIB重写后的逻辑在子类里,而this指向的是父类原始实例(代理对象持有target引用,但内部 createOrder执行时方法的接收者是原始对象)。CGLIB代理对象上调用updateStock()会走进子类重写方法,从而触发切面;原始对象的updateStock()不会。
2.2 一个典型的日志切面空白案例
我遇到过一个更隐蔽的版本。同事做了一个全链路耗时统计切面,目标是记录某个服务类里所有public方法的耗时。测试时发现:外部接口调的placeOrder()有耗时日志,但placeOrder()内部调用的sendNotify()、reduceStock()都没有日志。他在切面里加了日志打印方法签名,发现压根没匹配到那几个方法。
排查到最后,问题不在表达式,而在调用来源。sendNotify()和reduceStock()都是同类里用this.xxx()调用的,自然进不了切面。这也是为什么"同一个切面,有的方法生效有的方法不生效"时,第一个要怀疑的就是调用链上有没有同类自调用。
2.3 三个靠谱的修复方案,怎么选不踩坑
方案一:拆Bean,把被调方法挪到另一个Service里。
这种方式最干净,也是我最推荐的。因为跨Bean调用时,被调方的引用来自Spring容器,必然是代理对象,切面一定生效。
@Service public class OrderService { private final StockService stockService; public OrderService(StockService stockService) { this.stockService = stockService; } public void createOrder() { stockService.updateStock(); } }方案二:注入自身代理。如果你实在不想拆类,可以把自己注入进来,绕开this调用:
@Service public class OrderService { @Resource private OrderService self; public void createOrder() { self.updateStock(); } }这里有两个坑。第一,不要用构造器注入自身,那样会触发循环依赖,启动直接报错。字段注入能工作,本质上依赖Spring三级缓存里的早期引用机制,在AOP场景下这个引用就是代理对象。第二,字段注入自身对团队其他成员来说可读性较差,很多人一看到self.updateStock()反而更困惑,所以这个方案适合代码量少、不需要长期维护的场景。
方案三:用AopContext.currentProxy()。需要在启动类上开启exposeProxy:
@EnableAspectJAutoProxy(exposeProxy = true) @SpringBootApplication public class Application { // ... }然后在方法内部强制获取当前代理:
public void createOrder() { ((OrderService) AopContext.currentProxy()).updateStock(); }这个方案的问题在于:如果没有配置exposeProxy=true,运行时会抛IllegalStateException: Cannot find current proxy。而且强转类型时要清楚当前代理是CGLIB还是JDK动态代理,写起来不够省心。我的建议是优先拆Bean,注入自身和AopContext作为备选。
2.4 顺带说一句:事务注解为什么也跟着失效
在Spring里,@Transactional本质也是通过AOP实现的。所以同类自调用导致的问题,事务注解会遇到一模一样的场景。比如OrderService.createOrder()里调用this.updateStock(),而updateStock()上标了@Transactional,这个事务不会生效,因为调用者根本没走代理,事务切面根本没机会介入。
这个现象在社区里的讨论热度一直很高。很多人先说"AOP失效",后来发现事务也失效,两者是同一个原理。解决思路也一致:要么拆Bean,要么注入自身代理,要么把事务边界直接提到createOrder()这个外部方法上,让内部方法共享同一个事务。
3. 第二类边界失效:private、final、static与类设计上的不兼容
这一类失效的共同特点是:代码语法完全正确,切点表达式也没问题,但代理机制在物理上就拦不住这些方法。你不需要修改设计,但得知道它们是"不可切"的。
3.1 private方法:连CGLIB都不给面子
private方法在所有代理方式下都无法被拦截。JDK动态代理基于接口,private方法根本不可能出现在接口里;CGLIB虽然生成子类,但Java语言层面不允许子类重写父类的private方法,所以CGLIB对private方法无能为力。
实际开发中,很少有人专门给private方法配切点。真正的风险在切点表达式写得过宽,比如execution(* com.example.service.*.*(..))会把该包下所有的private方法也纳入考虑范围,但运行时代理又拦不了它们。结果就是"看起来切面没生效",实际上是你试图拦截一个Spring AOP根本拦截不了的东西。
3.2 final方法:CGLIB重写不了它
CGLIB靠"重写方法"实现AOP,而final方法不能被重写,所以final方法上的切面必然失效。Spring在生成CGLIB代理时遇到final方法通常不会报错,它会默默跳过,线上日志一切正常,唯独切面不执行。
更值得警惕的是final类。如果一个类本身被final修饰,CGLIB连子类都生成不了,启动阶段通常直接抛异常。日志里的关键字很显眼,比如CGLIB is unable to instantiate之类。遇到这种问题,首先要做的不是调整AOP配置,而是改类设计、去掉final修饰。
3.3 static方法:属于类级别的行为,代理拦不到
static方法属于类本身,不属于某个实例。代理机制是建立在实例多态基础上的,proxy.xxx()这种调用方式对static方法不成立。你写OrderService.staticMethod()时,编译器已经把调用绑定到了具体类,不存在通过代理转发的空间。不少工具类的静态方法想加耗时统计切面,用Spring AOP是做不到的,这种场景得靠AspectJ编译期织入才能实现。
3.4 类设计层面的隐性不兼容
还有一个容易忽略的点是构造器。CGLIB需要生成目标类的子类,创建子类实例时要调用父类的构造器。如果目标类只有私有构造器,Spring在某些情况下会尝试用Objenesis之类的库绕过,但这是非常规路径,不同版本表现不一致,搞不好就启动失败。我的建议是:要让一个方法被Spring AOP稳稳地拦截,它最好是public、非final、且由Spring容器管理。容器管理的Bean的构造器访问级别也不要做成private。
4. 第三类配置性失效:切点表达式、通知绑定与Bean注册问题
这类失效和代理机制无关,纯粹是切点没匹配上,或者切面类根本没有被Spring容器管理。它最好排查,但也最容易因粗心而浪费时间。
4.1 execution表达式最容易踩的坑
execution的标准结构是:
execution(修饰符? 返回类型 类路径? 方法名(参数) 异常?)实际开发里最常见的几个坑:
- 只匹配当前包不匹配子包。
execution(* com.example.service.*.*(..))里的com.example.service.*只匹配一层。如果你有个com.example.service.order.OrderService,这个表达式匹配不到。要匹配子包,得写com.example.service..*.*(..),注意是两个点。 - 返回类型写错。你想匹配
getOrder()方法,实际返回Order,切点里却写了execution(void com.example.service.OrderService.getOrder()),那当然匹配不上。 - 泛型擦除导致的参数签名不一致。
getOrder(List<String>)在字节码层是getOrder(List),参数类型要写java.util.List,不要只写List。 - 方法名大小写、包路径少写一层,这类低级错误也很多。最好在切面里临时打一行日志输出方法签名,确认每次调用的是不是你想切的方法。
4.2 @annotation与@within的语义差异
这两个指示符经常被混用,混用了就会出现"切点写了但没拦住"的现场。
@annotation(com.example.ApiLog)匹配的是当前执行方法上有指定注解的方法。@within(com.example.ApiLog)匹配的是当前方法所在类上有指定注解的所有方法。
最常见的失效:你在类上加了@ApiLog,切点却写@annotation,只想拦这个类的所有方法的时候你写成@within的人也很常见。这两者用反了,启动不报错,线上静悄悄。
还有一个关于接口注解的坑。如果你的注解标在接口方法上,而实现类重写了该方法——在Spring Boot默认CGLIB代理下,读取方法注解时可能读不到接口上的注解,导致@annotation切点失效。我的经验是把方法级注解直接放在实现类的方法上,不要依赖接口注解穿透。这个问题在社区里讨论过很多次,行为在不同Spring版本下不完全一致,别赌。
4.3 切面类没注册,以及目标对象不是Spring Bean
@Aspect只是一个标记注解,它本身不会让类自动注册到容器。很多新手写了一个带@Aspect的类,忘了加@Component,结果切面类压根不是一个Bean,所有切面全部静默失效。启动日志不会有任何提示,因为Spring扫描器根本不认识它。
反过来,被切的目标对象如果不是容器管理的Bean,AOP同样不会生效。比如你在某个普通配置方法里用new OrderServiceImpl()创建对象,然后直接调用,那Spring根本不会为它生成代理。记住:Spring只代理自己创建并管理的Bean。你new出来的对象从出生到调用,全程和Spring无关。
4.4 多个切面时,顺序和proceed()都是隐形炸弹
多个切面作用于同一个方法时,执行顺序由@Order决定,数值越小优先级越高。如果第一个切面的@Around里忘记调用pjp.proceed(),目标方法以及后续所有切面都不会执行,外部表现很像"方法被吞了"。
更隐蔽的是异常被吞。切面方法里catch了异常却没有重新抛出,这时候@AfterThrowing不会触发,业务方也看不到异常,容易造成"切面失效"的假象。排查时一旦发现某个切面其他方法都正常、只有特定方法表现异常,先看看这个切面里有没有大范围catch。
5. 一次线上日志缺失事故:AOP失效的完整排查复盘
理论讲再多,不如走一遍真实排查链路。下面这个案例我印象很深,因为它的现象极具迷惑性:切面类注册了,表达式写到类和方法了,注入对象的类型也是代理,但日志就是不记录。
5.1 现象与初步定位
某个订单模块加了一个操作日志切面,用来记录创建订单的入参、出参和耗时。上线后跑了一天,日志表里一条记录都没有。项目其他模块正常运行,也没有报错。
第一步,我在切面的入口加了一行日志,打印匹配到的方法签名:
@Around("execution(* com.example.order.service.OrderServiceImpl.createOrder(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { log.info("[LogAspect] matched method: {}", pjp.getSignature()); return pjp.proceed(); }重新部署后调用一次创建订单接口,日志里没有任何输出。这就排除了"切面执行了但写库失败"的可能,问题确实出在匹配或者调用链上。
5.2 逐步排除,最后定位到裸对象
第二步,确认切面类有没有被Spring管理。我在LogAspect的构造器里打了个断点,启动时断点命中,说明Bean没问题。
第三步,确认表达式是否匹配。我用了一个比较暴力的方式,把表达式临时改成execution(* com.example..*.*(..)),然后重新调用接口,日志里哗啦啦打印出一堆方法签名,其中能看到OrderServiceImpl.createOrder。这说明表达式本身没写错,是某个更细节的原因拦住了它。
第四步,打印Controller里实际注入的对象类型。在调用入口加一行:
log.info("bean class = {}", orderService.getClass());结果输出:
bean class = com.example.order.service.OrderServiceImpl$$EnhancerBySpringCGLIB$$xxxx这里就很矛盾了。容器注入的是CGLIB代理,切面匹配也没问题,为什么切面没执行?
第五步,我检查了Controller的代码,发现它调用orderService.createOrder()时,拿到的对象根本不是注入的那个字段,而是一个静态字段:
private static OrderService orderService = new OrderServiceImpl();当时这个Controller为了某些单测方便,把Service对象放到了静态字段里,Spring注入被忽略了。静态字段里的OrderServiceImpl是裸对象,没有任何代理,切面当然不生效。
修复方案很简单:删掉静态赋值,改回构造器注入,让对象来源回归Spring容器。改完之后,调用一次接口,日志正常输出。
5.3 复盘:这个案例的排查要点
这个案例最误导人的地方在于,容器里的Bean类型是对的、代理类型也是CGLIB,唯独代码里真正用到的是另一个裸对象。所以排查AOP失效时,光是看某个Bean的类名还不够,你必须在实际调用链路上打印"当前使用的对象"的类名。如果一个方法里打印出来的this.getClass()不带EnhancerBySpringCGLIB字样或者不是$Proxy开头,就说明这条路线上压根没有代理。
我还习惯在入口处和切面里同时打日志,对比执行顺序。入口日志先出来、切面日志没出来,说明问题一定出在"从入口到目标方法"这一段的对象引用上。这个思路可以覆盖大部分外部调用型失效。
6. 多线程与Bean获取方式:那些绕开代理对象的场景
使用Spring时,绝大多数场景通过依赖注入获取Bean是安全的。一旦代码里出现new、静态缓存、手动保存对象引用,代理链就可能断掉。
6.1 手动new目标对象,代理一定不在
下面这种写法在多线程代码里出现频率很高:
@Component public class OrderEventPublisher { public void publish() { new Thread(() -> { OrderService service = new OrderServiceImpl(); service.updateStock(); }).start(); } }线程Runnable内部new了一个实现类。这个对象从创建到销毁都和Spring容器无关,切面和事务统统不会生效。你可能会觉得这是很明显的错误,但在异步任务、定时任务、MQ消费者里,类似的写法相当常见。
6.2 多线程不背锅:关键在引用来源
多线程本身不会导致AOP失效。切面拦截的是"哪个对象在调用方法",不是"哪个线程在调用"。如果你在Runnable里使用外部传入的Spring注入Bean,只要这个Bean是代理对象,切面依然正常:
public void publish() { new Thread(() -> orderService.updateStock()).start(); }这里orderService是外部传入的代理引用,线程里调用时切面一样生效。所以排查多线程失效时要盯住引用来源,而不是怀疑线程。
6.3 循环依赖与提前引用:Spring其实帮你想多了
AOP代理的创建发生在Bean初始化阶段的后置处理器里。如果一个Bean发生了循环依赖,Spring的三级缓存机制会在getEarlyBeanReference阶段提前创建一次代理,保证另一个Bean持有的引用也是代理。所以循环依赖本身通常不是AOP失效的原因,Spring已经帮你处理掉了。
但如果你在代码里自己做了"提前缓存",那就另当别论。比如在某个静态Map里手动缓存了一个原始对象,后面所有调用都从这个Map里取,这就会绕过Spring的代理管理。遇到这种设计,直接改成从容器获取,或用注入的方式传递引用。
6.4 从ApplicationContext获取Bean的正确方式
如果确实需要在非Spring管理的类里获取Bean,用ApplicationContext.getBean()拿到的也是代理对象,切面没问题。工具类里缓存的是代理引用就能正常工作,前提是别缓存错对象。
一个通用的排查技巧:在调用链路上打印this.getClass().getName(),再打印从容器获取的bean.getClass().getName(),两者不一致说明当前链路里拿到的不是代理。这个技巧在多线程、异步、定时任务场景里特别管用。
7. 把AOP失效扼杀在编写阶段:检查清单与团队约定
等到线上出了问题再去排查,成本总是高的。我更倾向于把一些约束前置到写代码阶段,让团队在新增切面的时候天然避开常见坑。
7.1 新增切面前先回答三个问题
写任何切面之前,先确认三件事:
- 目标方法是
public且非final吗? - 调用方拿到的对象是从Spring容器注入的吗?
- 切点表达式在当前项目的默认代理方式下,真的能匹配这个方法吗?
这三个问题都确认过了,AOP生效概率非常高。反之,如果有一项不满足,先停下来改设计。
7.2 一张快速排查表,照着眼睛找
| 现象 | 优先怀疑 | 验证动作 |
|---|---|---|
| 所有方法都不进切面 | 切面类未注册、切点表达式错误 | 确认类被@Component扫描;临时打印匹配方法签名 |
| 只有同类内部方法不进切面 | 自调用 | 在方法里打印this.getClass() |
| 同一个方法运行时偶尔失效 | 手动new、静态缓存、线程池对象来源错误 | 打印当前调用对象的类名 |
| 注解式切点不生效 | 注解放在接口还是实现类、@annotation与@within弄混 | 把注解移到实现类方法上 |
| 方法被调用但没有进入后续逻辑 | 某个@Around里忘了proceed() | 检查切面里有没有吞掉ProceedingJoinPoint |
| 启动时报CGLIB相关错误 | 类或方法final、构造器访问级别问题 | 调整类设计结构 |
7.3 我给自己和团队定的几条约定
基于这些年踩过的坑,我整理了下面这组约定,也算是团队规范:
- 不在同一个类里调用本类中已被切面标注的方法。需要拦截时就拆Bean,通过注入对象调用。
- 切点表达式统一放在公共常量类里,避免每个人手写时少了包层级。
- 新增切面先不做完整逻辑,只在切面进出处各打一行日志,观察匹配范围再收口。
- 方法级注解标在实现类方法上,不标在接口上,除非确认当前Spring版本能正确穿透接口注解。
- 所有
@Around里都要仔细处理异常透传,不要轻易catch住不抛。 - 凡是涉及AOP的类,写完顺手输出一行日志打印注入对象的类名,上线前看一眼就能确认代理情况。
最后分享一个我自己的习惯:排查AOP失效时,我永远把"确认调用链上的对象是不是代理"放在第一位,而不是先抠表达式写法。因为表达式写错一般启动日志里多少能看出端倪,对象来源错了则完全无提示。每次项目里用到AOP,我都会在入口处打一行类名日志,留作后续排查的依据。这套小习惯帮我节省了大量排查时间,也希望能帮你少走几次弯路。