ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring AOP核心机制与动态代理失效场景解析

Spring AOP核心机制与动态代理失效场景解析

1. 一个"方法消失"的现场:AOP出现的理由

先讲个很常见的排查场景。某次我在联调一个订单接口,前端一直说"提交订单后积分没有增加"。代码翻了几遍,业务逻辑里确实调用了grantPoints(),日志也打印了"开始发放积分",但积分表就是没有新的记录。最后跟踪到调用栈才发现,grantPoints()被同一个类里的另一个方法直接调用,而那个方法上标着@Transactional。事情到这里我基本就明白了:Spring创建的是目标对象的代理,积分发放那一行逻辑被事务切面"吞掉"了——当然不是真的吞掉,而是事务只在代理方法入口生效,同类内部调用根本不经过代理,等于绕过了事务边界。

这就是AOP(Aspect-Oriented Programming,面向切面编程)最典型的应用场景。很多人第一次接触AOP是被@Transactional、@Async、@Cacheable这几个注解带进来的,用完觉得"很方便啊,标个注解就行了",可一旦遇到上面的问题就懵了。这篇文章不打算重复官方文档,而是把Spring AOP的运行机制和那一套特别术语——切面、通知、切点、连接点、织入、目标对象、代理——按我自己的理解逐个拆开,最后再聊几个我踩过的经典失效坑。

如果你是刚看完几篇入门教程,正准备在项目里把日志切面、权限校验切面写起来,或者已经被"同类调用事务失效"这个问题困扰过,可以顺着这套思路把AOP的底子打牢。

2. 特别术语逐个拆解:从名字到原理

Spring AOP的官方术语其实很拗口,英文文档里一串大写开头的单词,中文资料里又翻译得五花八门。我习惯用一套"角色扮演"的方式来记它们。

2.1 Aspect(切面)——把横切逻辑打包的模块

切面是整个AOP的"总装车间"。它把要在哪里切入(切点)和切入后干什么(通知)组合成一个模块。在Spring里通常是一个用@Aspect标注的类,比如:

@Aspect @Component public class AccessLogAspect { // 切点表达式 + 通知方法 }

类比一下:如果你的业务代码是一栋楼的各个房间,切面就是楼宇的"安防系统"规划图——它不负责具体的房间功能,但规定了在哪些门口需要装监控、遇到什么情况触发什么动作。

2.2 JoinPoint(连接点)——可以被切的地方

连接点指程序执行过程中能够织入增强逻辑的特定位置。在Spring AOP里,连接点基本上就是方法调用点(更严格地说是方法执行点),因为在Spring AOP的设计中,只支持方法级别的拦截。

有些人会把连接点理解成"某个方法",其实不够准确。你调用一个方法的那一刻,才产生一个连接点;方法没有被调用,它只是一个候选位置。切点就是从众多连接点里"圈"出来的那部分,二者是包含关系。

2.3 Pointcut(切点)——匹配连接点的规则

切点是用来决定"到底切哪些方法"的表达式。Spring AOP用AspectJ的切点表达式语法,最常见的写法是这样:

@Pointcut("execution(* com.example.order.service.*.*(..))") public void serviceLayer() {}

* com.example.order.service.*.*(..)翻译成人话就是:com.example.order.service包下任意类的任意方法,参数不限,返回值不限。这一条规则圈出来之后,只有匹配上的连接点才会被增强。

注意:切点只是"规则",真正执行通知的是匹配后的连接点。很多新手分不清Pointcut和JoinPoint,就记一句话——JoinPoint是"哪里能切",Pointcut是"我选哪里切"。

2.4 Advice(通知)——切入后执行的逻辑

通知是切面里真正"干活"的代码,按执行时机分为五种。我一般用一个转账场景来记:转钱之前要校验余额,转钱过程中有异常要回滚,转钱完成后要发通知,整个过程耗时想记录一下。

  • Before(前置通知):目标方法执行前触发,适合做参数校验、权限检查。比如校验当前用户是否有操作权限。
  • AfterReturning(返回后通知):目标方法正常返回后触发,适合记录成功日志、更新缓存。
  • AfterThrowing(异常后通知):目标方法抛异常后触发,适合记录错误日志、发送报警。
  • After(后置通知,也叫最终通知):不管正常返回还是抛异常都会执行,类似finally块,适合释放资源。
  • Around(环绕通知):最强大的一种,可以在目标方法前后自定义逻辑,甚至可以阻止方法执行、修改返回值。@Transactional本质就是环绕通知式的增强。

2.5 Target(目标对象)——真正干活的实例

目标对象就是被切面"盯上"的那个业务Bean。比如订单接口里有一个OrderService实现类,里面写满了真正的业务SQL和逻辑——它是我们的目标对象。切面增强的不是"它的代码本身",而是"调用它的通道"。

2.6 Proxy(代理)——替目标对象接客的"门面"

这是Spring AOP最核心的机制。客户端代码拿到的并不是OrderService对象本身,而是一个代理对象。代理对象在外面包了一层,先执行切面逻辑,再决定是否把调用转发给真正的目标对象。

绝大多数人对AOP的困惑都出在这一点上:你以为你调的是A,其实你调的是A的代理A-Proxy。A-Proxy知道A的一切接口,但A-Proxy里可以夹带私货——这恰恰是AOP能力的基础。

2.7 Weaving(织入)——把切面和目标对象组装起来

织入是指把切面逻辑应用到目标对象、创建出代理对象的过程。Spring AOP的织入发生在运行时(Run-time),也就是Spring容器启动阶段创建Bean时,通过动态代理技术完成的。与之相对的是AspectJ的编译期织入、类加载期织入,那个更狠,直接改字节码。

理解织入时机很重要,因为它决定了Spring AOP的很多限制(后文会讲)。

2.8 Introduction(引入)——给目标对象"加新功能"

引入允许你在不改动目标类源码的情况下,给目标对象添加新的接口实现。比如你希望某个UserService在运行时额外拥有Auditable接口的能力——记录操作人、操作时间——就可以用@DeclareParents来实现。这个特性日常项目里用得不多,但在写通用组件时偶尔能遇到。

2.9 术语之间的血缘关系

我用一句话把这些术语串起来:

切面(Aspect)= 切点(Pointcut)+ 通知(Advice),切点负责回答"在哪里切",通知负责回答"切了以后做什么";被切的目标是Target,生成的替身是Proxy;整个过程发生在容器启动阶段,叫织入(Weaving)。

只要把这个骨架装进脑子里,后面所有源码、配置都是在往这些位置上填细节。

3. 切点表达式:从入门到够用的几个写法

Spring AOP的切点表达式基于AspectJ,但只支持其中一部分。我建议不要背语法,而是理解几个高频表达式的语义,用的时候会自然地组合。

3.1 execution:最常用的"方法签名匹配器"

execution能精确匹配方法的修饰符、返回类型、类路径、方法名、参数列表。常用变形:

写法含义
execution(public * *(..))匹配所有public方法
execution(* com.demo.service.*.*(..))匹配service包下所有类的所有方法
execution(* com.demo.service..*.*(..))匹配service包及其子包下所有方法
execution(* com.demo.service.OrderService.create*(..))匹配OrderService中以create开头的方法
execution(String com.demo.service.OrderService.get*(String))匹配返回String、入参是String的get开头方法

提示:中间那个*是返回类型通配,..表示任意参数或子包,String参数括号里是精确类型。新手最容易漏掉返回类型那一格,导致表达式怎么配都不生效。

3.2 within:按类限定的粗粒度匹配

within只关心方法所在的类,不关心方法签名。within(com.demo.service..*)表示service包及其子包下所有类的所有方法。它比execution写起来短,但没法限定具体方法名、参数。

3.3 annotation:跟着注解走,最优雅的接入方式

@annotation(annotationType)匹配当前执行方法上标注了指定注解的连接点。这是最推荐在团队中使用的方式,因为调用方只需标注解,不需要理解切点表达式。

@Aspect @Component public class RateLimitAspect { @Around("@annotation(rateLimit)") public Object checkRateLimit(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable { // 方法上带 @RateLimit 注解的连接点都会进来 return pjp.proceed(); } }

这里参数RateLimit rateLimit会自动绑定到方法上的注解实例,可以读取注解属性,比如限流阈值。

3.4 args / target / this:参数与对象类型匹配

  • args(com.demo.dto.OrderDTO):匹配参数是OrderDTO的方法。
  • target(com.demo.service.OrderService):匹配目标对象是OrderService类型。
  • this(com.demo.service.OrderService):匹配代理对象是OrderService类型。

在Spring AOP中,target和this的行为差异非常细微:target判断的是被代理的原始对象,this判断的是当前代理对象。由于Spring默认使用JDK动态代理时,代理对象和目标对象实现的接口可能不同,这两者会导致匹配结果不一样。日常业务中我建议优先用execution和annotation,足够覆盖95%的场景,剩下的再考虑args。

3.5 组合运算符

切点可以用&&(与)、||(或)、!(非)组合,注意在Spring XML时代使用的是and、or、not文本形式,用注解时写Java的运算符即可。比如:

@Pointcut("execution(* com.demo..*.*(..)) && @annotation(com.demo.annotation.RateLimit)") public void rateLimitPointcut() {}

4. 动态代理的两条路线:JDK Proxy与CGLIB

理解了术语之后,真正的硬核问题来了:Spring AOP到底是怎么把代理对象造出来的。Spring容器在初始化Bean时,如果发现这个Bean命中了某个切面,就会用动态代理包一层,生成一个代理Bean返回给调用方。具体有两种技术路线。

4.1 JDK动态代理:基于接口的"影子分身"

JDK自带的java.lang.reflect.Proxy要求目标类必须实现至少一个接口。代理对象和目标对象实现同样的接口,所有方法调用进来后,都会交给InvocationHandler.invoke()统一处理。

Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxy, method, args) -> { // 前置增强 Object result = method.invoke(target, args); // 后置增强 return result; } );

JDK代理只能拦截接口中定义的方法。如果你在实现类里写了一个接口没有的方法,外部通过代理对象根本调用不到它(除非强转回具体类,但这样代理就失效了)。所以Spring早期版本经常出现的一个问题:@Transactional标在实现类私有方法上不生效,或者标在只有实现类才有的方法上不生效。

4.2 CGLIB:基于字节码的"复制忍者"

CGLIB通过生成目标类的子类来实现代理,因此不要求目标类实现接口。它直接继承目标类,重写其中的方法,在重写版本里插入增强逻辑。代价是无法代理final类和final方法,因为没法继承和重写。

Spring大版本演进里有一个知名调整:从Spring Boot 2.x开始,官方默认把代理机制从JDK Proxy切换成了CGLIB(准确说是Spring内置的SpringProxy机制配合CGLIB)。原因是现代应用里很多Bean只有实现类,接口越来越少,使用JDK代理反而容易处处碰壁。

4.3 怎么判断当前Bean用的是哪种代理

调试的时候可以在代码里打印:

System.out.println(bean.getClass().getName());

如果类名里带$Proxy说明是JDK代理,带$$EnhancerBySpringCGLIB说明是CGLIB代理。我排查看错时这一招很管用,能快速确认"我拿到的对象是不是代理"。

4.4 为什么说"AOP救不了自调用"

动态代理决定了Spring AOP只能拦截外部对代理对象方法的调用。如果目标方法内部调用同类的另一个方法,比如:

@Service public class OrderService { public void createOrder(OrderDTO dto) { // ... this.updateStock(dto.getSkuId()); // 直接this调用,不是代理调用 } @Transactional public void updateStock(Long skuId) { // 事务增强在此 } }

updateStock上的@Transactional不会生效。因为this指向的是原始目标对象,不是代理对象。外部代码调用createOrder时进入的是代理的createOrder,但代理里的this是目标对象,内部继续调用同一个目标对象的updateStock,完全绕过了代理。

这个场景可以说是Spring AOP最常见的"翻车现场"。解决方案有几种,我后面专门列一节展开。

5. 我在项目里踩过的AOP失效坑与排查链路

AOP"失效"的问题,本质上绝大多数都不是Spring坏了,而是切面根本没有被织入,或者织入了但没有匹配到目标。我把项目里真实遇到过的几类坑排一下,每个都附排查思路。

5.1 自调用导致事务/异步失效

这是事务场景里最高频的坑,问题表现是:方法A调用同类方法B,B上标了@Transactional,但异常发生时数据没有回滚。

排查链路:

  1. 确认@EnableTransactionManagement(或Spring Boot自动配置)是否开启。
  2. 确认B方法的异常类型是否默认回滚——Spring默认只回滚RuntimeException和Error,受检异常默认不回滚。
  3. 确认入口方法A是否也处于事务中——如果A没有事务,B肯定没有。
  4. 三分确认法:在B方法打断点,观察调用栈是不是经过TransactionInterceptor;如果栈里只有OrderService.updateStock直接往上,说明确实是自调用绕过了代理。

解决办法有三条路:

  • 方案一:把B拆到另一个Service类里,注入B所在的新Bean,通过代理调用。
  • 方案二:从ApplicationContext重新拿代理对象来调用。
  • 方案三:Spring提供了AopContext.currentProxy()绕线方案,((OrderService) AopContext.currentProxy()).updateStock(...),但要注意需要在启动类加@EnableAspectJAutoProxy(exposeProxy = true)。

我个人强烈推荐方案一,因为它让事务边界显式化,代码可读性更好。方案三虽然改动小,但会在业务代码里藏一个魔法调用,后续维护的人容易一头雾水。

5.2 切点写错:看似有切面,实际一个都没切中

有个同事同时排查了两个问题:一是切面没有日志输出,二是接口报错了。我检查切点时发现他把execution(* com.demo.service..*.*(..))写成了execution(* com.demo.service.*.*(..))。service.*只匹配service包下一级子包,service..才匹配任意层级子包。看着只差一个点,效果天差地别。

排查切点是否命中,推荐两个土办法:

  1. 启动时打印所有匹配的Bean:用一个极简切面临时@Around,在通知里直接输出方法签名和代理类型。
  2. 在application.yml里开启AOP日志(Spring Boot下可以调logging.level.org.springframework.aop=DEBUG),观察Creating AOP proxy日志。

一条经验:定义切点时临时用execution(* *(..))跑通,再逐步收紧到具体包/方法。先证明链路通,再证明匹配对,排查效率高得多。

5.3 private方法和final方法:AOP真的切不进去

Spring AOP的动态代理机制决定了:JDK代理只能代理接口方法,CGLIB代理只能重写非final、非private方法。所以private方法、final方法、static方法都没法用Spring AOP做增强。如果确实需要给private方法加逻辑,正确做法是把这部分逻辑提到一个单独的Bean方法里,再通过依赖注入调用。

5.4 多个切面的执行顺序:别以为谁先写谁先执行

多个切面同时命中同一个方法时,它们的执行顺序由@Order控制。这个顺序在多层切面场景(比如先日志、再鉴权、再事务)非常关键。默认情况下Spring按切面类的注册顺序排序,但这个顺序不稳定,建议显式声明:

@Aspect @Component @Order(1) public class LogAspect { } @Aspect @Component @Order(2) public class TransactionAspect { }

@Order(1)先执行,即先进入的切面最先proceed()。多层切面包裹顺序有点像洋葱:最外层先执行前置逻辑,然后逐层向内,最内层才是目标方法,方法返回时报文再逐层向外做后置逻辑。

5.5 拦截了Controller却被RequestBody"吃掉"参数

有一个特别隐蔽的场景。用AOP在Controller层做参数校验或日志打印时,想在Around通知里读取方法入参的body内容,发现取到的args[0]是一个空对象或者只有引用。原因多半是Spring MVC的RequestResponseBodyMethodProcessor在参数解析阶段已经消费了请求流,或者参数绑定发生在代理通知之前/之后,时机的错位导致拿不到完整数据。

这个坑的通用解法是:不要在Around里依赖pjp.getArgs()做太深的参数修改,只做只读记录;如果必须修改参数,要在proceed(args)里传入修改后的参数数组,并确认Spring MVC允许参数解析器重新处理。

6. 动态代理的边界:何时该上AspectJ

我们一直在讨论的Spring AOP,依赖的是运行期动态代理,它有两个天然边界:一是只能切方法调用,二是只对Spring容器管理的Bean生效。如果你要对字段访问、构造器调用、静态方法做增强,或者要监控一个"压根没走Spring容器"的对象,Spring AOP就束手无策了。

这时候需要的是AspectJ的编译期织入(CTW)或加载期织入(LTW)。AspectJ在编译阶段直接修改字节码,把切面逻辑"写死"进目标类,所以能切构造函数、静态初始化块、字段访问等更多连接点。但代价也很大:构建流程要插入AspectJ编译器,IDE调试栈变得难看,学习成本直线上升。

我的建议是:绝大多数Web项目的需求,Spring AOP完全够用。先想清楚你要增强的"连接点"类型是不是方法调用;如果是,老老实实用Spring AOP。等真遇到Spring AOP满足不了的场景,再去考虑AspectJ,那时候你基本也已经知道为什么要用它了。

7. AOP的经典落地场景:不只是事务和日志

很多人以为AOP就是给方法加日志、给事务打标记,其实它的价值远不止这些。我从实际项目里总结几个值得借鉴的场景。

7.1 多级缓存:切面拦一次,命中即返回

缓存逻辑如果写在业务方法里,代码会被getCache、putCache、deleteCache包围,业务代码可读性直线下降。用切面做缓存,业务方法只需要专注核心逻辑。

@Around("@annotation(cacheEvict)") public Object handleCache(ProceedingJoinPoint pjp, CacheEvict cacheEvict) throws Throwable { String key = buildKey(pjp.getTarget().getClass(), pjp.getSignature().getName(), pjp.getArgs()); Object cached = cache.get(key); if (cached != null) { return cached; } Object result = pjp.proceed(); cache.put(key, result); return result; }

实际落地时要注意缓存Key的生成策略:全限定类名 + 方法名 + 参数序列化,否则很容易出现Key冲突或者该失效的缓存一直没删除。

7.2 分布式锁:把加锁和解锁收起来

在并发场景下,给某个方法加分布式锁,常规写法是手动获取锁、try/finally释放锁,代码重复且容易漏释放。用AOP注解加环绕通知,锁的逻辑收拢到一个切面里,业务方法只留一个@DistributedLock(key = "#orderId"),清爽很多。

这里有一个细节值得注意:如果方法内部有事务,锁的释放时机要小心。事务提交后释放锁更稳妥,否则可能出现"锁释放了但事务还没提交,另一个线程读到旧数据"的问题。这个顺序问题在写分布式锁切面时一定要和事务切面的执行顺序一起设计。

7.3 接口调用审计:记录参数、耗时和结果

审计切面是另一个高频场景。把入参、出参、耗时、操作人、方法描述统一记录,接入日志平台或审计库,为后续追责、性能分析提供数据。写这种切面时我有一条铁律:审计逻辑绝不能影响主流程,通知里必须用try/catch包住所有代码,任何异常都只能记录不能向上抛。

7.4 参数校验与权限控制

用@annotation绑定校验注解,在Before通知里做参数检查,不合法就直接抛异常,不让请求进入业务方法。权限控制同理,在Before通知里先解析当前登录人,再比对方法要求的权限码。这种写法的好处是和业务逻辑完全解耦,切面可以作为一个独立的"安全层"存在。

8. 看懂切面生效全过程:容器启动时的"织入地图"

理解了前面所有概念后,我把Spring容器启动时AOP的工作流程串一遍,方便你心里有一张完整的图。

  1. Spring容器扫描所有Bean定义。
  2. 解析@Aspect注解类,收集切面中的@Pointcut和各类通知方法。
  3. 对每个普通Bean,用切面的切点表达式进行匹配。如果命中,就用AnnotationAwareAspectJAutoProxyCreator这个后置处理器生成代理对象。
  4. 代理对象被放入容器,后续所有依赖注入拿到的都是这个代理。
  5. 外部调用代理方法时,代理按照@Order顺序把方法调用交给各个切面的通知逻辑处理,最后再进入目标对象真实方法。

这里特别值得留意的是第3步,即使方法从未被调用,容器启动时代理就已经生成好了。所以如果切面表达式写得太宽,比如execution(* com..*.*(..)),容器启动时会为大量Bean创建代理,内存和启动时间都会有明显开销。

9. 我最后想补充的几条实战建议

第一,给切面方法写清晰的命名规范。切面方法名不要总叫doAround、doBefore,而是叫logOperation、checkPermission、handleCache,代码即文档。

第二,切面内不要抛非受检异常之外的东西。如果你在环绕通知里catch了异常又想重新抛出,要注意@Transactional的异常是否会被吞掉。一个常见的错误是在切面中catch住异常并返回一个默认值,导致事务拦截器认为方法成功返回,不会执行回滚。如果确实需要"吞掉异常但也要回滚",要显式调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

第三,用切面和用注解要配套设计。在团队里推广自定义AOP工具时,一定要提供注解和文档,让使用方在方法上打一个注解就行,不要逼着大家去理解切点表达式。好的封装是使用者只需要知道"标了注解就能获得能力",不需要知道切点怎么写。

第四,尽量把切面做成无状态的。通知方法不要持有成员变量来存储请求状态,因为切面本身是单例的,并发情况下成员变量会产生数据串扰。真的需要保存数据,使用ThreadLocal并确保在finally里清理。

第五,切面能不用就不用,用了就要测。不是所有场景都适合AOP,简单的两行代码用切面反而让流程变得隐晦。如果一个切面的通知逻辑复杂到超过100行,我会怀疑设计出了问题,这时候更合理的做法是把逻辑下沉到一个独立的Service中,切面只做薄薄的一层转发。

回到最开始那个"积分没增加"的现场,我已经不会再慌张了。先分清目标是代理还是原始对象,再确认切点表达式是否命中,最后检查调用链是否经过了代理层。这三板斧下来,八成AOP相关的问题都能定位到根因。剩下的两成,就是动态代理的天然边界问题——知道了边界在哪里,你也就知道绕行的路在哪里了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进