Spring AOP环绕通知实战:从原理到性能监控与排坑指南

📅 发布时间:2026/10/10 19:34:35
Spring AOP环绕通知实战:从原理到性能监控与排坑指南
【Spring Boot】Spring AOP中的环绕通知先说个我的真实经历。前两年公司做一次接口性能专项上线前大家信心满满结果凌晨三点被监控系统连环告警炸醒下单接口P99严重超时一看日志根本不知道是哪个环节慢的。当时最头疼的一件事就是想给核心接口加耗时统计、入参出参日志但又不想在每个方法里复制粘贴那几行System.currentTimeMillis()的代码。后来顺手用了Spring AOP的环绕通知几十行代码全部搞定后面运维和排查一下子轻松太多。这篇文章我就打算把Spring Boot环境下围绕环绕通知的完整玩法讲透——它是什么、为什么比另外几种通知更万能、实际项目里怎么写才不踩坑以及排查时那些特别容易让人怀疑人生的地方。无论你是刚接触AOP的新人还是已经写了不少切面但偶尔会踩到切面没生效的老手这篇文章都能给你一些可以直接照抄的参考。1. 环绕通知到底是什么为什么说它最灵活1.1 AOP五种通知类型先对一下账Spring AOP一共提供了五种通知很多人刚接触时总是记混。我用一句话帮大家理顺它们本质上是某一时刻的织入逻辑只是切入的位置不一样。Before前置通知方法执行前触发拿不到方法返回值也没法阻止原方法继续往下走除非你抛异常。AfterReturning返回通知方法正常返回后触发能拿到返回值但如果你想让方法换个返回结果就无能为力了。AfterThrowing异常通知方法抛出异常后触发可以记录异常信息但不能决定要不要继续抛出去。After后置通知方法结束后无论如何都会触发类似finally但同样不能改变方法执行结果。Around环绕通知最霸道的一种它把目标方法整个包住你可以在方法执行前、执行后任意位置做事情可以决定要不要执行原方法也可以换个参数、换个返回值还能把异常吞掉改成正常返回。从控制力来看环绕通知一个人就包揽了前面四种的活儿。你要说AOP五种通知谁最强那毫不犹豫是Around。1.2 环绕通知的核心优势把什么时候做握在自己手里为什么说它最适合实际项目核心在于ProceedingJoinPoint暴露了proceed()方法这个方法是通往原业务方法的唯一通道你什么时候调用它完全由你自己控制。我打一个生活化的比方Around就像是餐厅门口的迎宾巡场经理普通通知是客人进门时说一句欢迎光临Before或者客人走的时候说一句谢谢惠顾After。而环绕通知是你带着客人从进门、点菜、上菜、结账、送别全程陪同哪个环节你能加戏全看你自己的意愿。这个特性在真实场景里特别值钱比如你可以在proceed()之前做权限校验、做参数清洗、做限流判断一旦条件不满足直接让目标方法压根不执行。你可以在proceed()之后拿到返回值根据业务需要二次加工。你可以在proceed()抛出异常时选择继续抛出、包装成新异常、或者吞掉后返回一个兜底结果。你还可以重写传入的参数数组用proceed(newArgs)把新的参数传给目标方法。这些能力是Before和AfterReturning给不了的。所以如果你现在还不确定该用哪种通知我的建议很简单优先用环绕通知因为它的兜底能力最强后面想调整逻辑时不用改切面类型只改方法体内容就行。2. Spring Boot环境下搭建AOP基础环境2.1 引入依赖Spring Boot会帮你干什么先用一句话说清楚Spring Boot和Spring AOP的关系。Spring Boot本身不写AOP逻辑它只是把Spring Framework的AOP能力做成了自动配置。你在pom.xml里加上spring-boot-starter-aopSpring Boot检测到aspectjweaver在依赖里就会自动注册AnnotationAwareAspectJAutoProxyCreator相当于自动帮你把EnableAspectJAutoProxy这件事做了。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency提示如果项目是纯Spring而非Spring Boot就需要手动在配置类上标注EnableAspectJAutoProxy。但在Spring Boot工程里只要你引入starter切面类上标好Aspect和Component扫描到就能生效。这里顺便提一句版本问题。Spring Boot 2.x和Spring Boot 3.x对AOP的使用方式几乎一致3.x底层是Spring Framework 6AOP基本API没有变。你如果是从2.x升级到3.x的项目切面代码大概率不用动只需要确认依赖版本跟Boot主版本匹配。2.2 默认代理方式Spring Boot其实默认走CGLIB很多老教程还在讲如果目标类实现了接口默认用JDK动态代理如果没有接口才用CGLIB——这个说法在Spring Framework纯配置环境下是对的但在Spring Boot 2.x之后并不完全适用。Spring Boot从2.0开始spring.aop.proxy-target-class默认就是true也就是说即便目标类有接口容器也更倾向于使用CGLIB生成子类代理。这个默认行为影响有多大非常大。因为JDK动态代理只能代理接口方法而CGLIB可以代理非final的具体类方法所以Spring Boot环境下你的Around切面对有接口和无接口的类一视同仁。但这也带来一个小坑如果某个类被final修饰或者某个方法被final修饰CGLIB就无法生成子类去覆盖它切面也就拦不到。这个我在后面踩坑清单里再展开。2.3 先验证一下环境是否正常搭建完后最快的验证方式就是写一个最简单的切面然后随便写一个Service方法启动项目打一个请求看日志里有没有切面输出。不要一上来就写复杂切面先保证链路通了再扩展能省去一大半排查时间。3. 动手写第一个环绕通知从注解到切面完整实战3.1 定义一个自定义注解用于灵活指定监控点很多场景下我们不希望所有方法都被切面拦截。尤其是一些内部工具方法、静态方法或者压根不需要监控的路径全拦截反而会造成日志噪音。所以更好的做法是想监控哪个方法就在哪个方法上面标一个注解然后在切面里只对带注解的方法生效。package com.example.aopdemo.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface ApiMonitor { String bizName() default ; }有几个细节需要留神Target(ElementType.METHOD)表示只能标在方法上也可以加上ElementType.TYPE这样能类级别生效但方法级别的控制粒度更细。Retention(RetentionPolicy.RUNTIME)必须保留到运行时因为切面是在运行时反射读取注解的。如果你漏掉这行或者误用CLASS切面里拿不到注解信息。bizName()是用来自定义业务标识的。比如一个订单查询方法可以标ApiMonitor(bizName 订单查询)日志里直接输出中文名称排查问题时比Java方法名更直观。3.2 写一个基于自定义注解的环绕通知切面下面这个切面是实际项目里很好用的通用版。它做的事情包括记录方法名和业务名、打印入参、用环绕方式执行方法、记录耗时、捕获异常并标记日志同时把异常继续抛出去让上层事务逻辑保持原有行为。package com.example.aopdemo.aspect; import com.example.aopdemo.annotation.ApiMonitor; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.util.StopWatch; Aspect Component public class ApiMonitorAspect { private static final Logger log LoggerFactory.getLogger(ApiMonitorAspect.class); Around(annotation(apiMonitor)) public Object aroundApiMonitor(ProceedingJoinPoint pjp, ApiMonitor apiMonitor) throws Throwable { MethodSignature signature (MethodSignature) pjp.getSignature(); String className signature.getDeclaringType().getSimpleName(); String methodName signature.getName(); String bizName apiMonitor.bizName(); Object[] args pjp.getArgs(); StopWatch stopWatch new StopWatch(); stopWatch.start(); log.info([环绕-开始] biz{}, 目标方法{}.{}, 参数{}, bizName, className, methodName, args); Object result; try { result pjp.proceed(); log.info([环绕-返回] biz{}, 方法执行成功, 返回结果{}, bizName, result); return result; } catch (Exception e) { log.error([环绕-异常] biz{}, 方法执行异常, 异常信息{}, bizName, e.getMessage(), e); throw e; } finally { stopWatch.stop(); log.info([环绕-结束] biz{}, 总耗时{}ms, bizName, stopWatch.getTotalTimeMillis()); } } }这里有几个程序员很容易忽略但实际很影响效果的小点用StopWatch而不是自己写System.currentTimeMillis()做差值代码更可读而且Spring自带的StopWatch能处理分段时间后面要统计方法耗时后续处理耗时时很方便。try/catch/finally里catch到异常后我选择throw e继续往上抛。这个动作非常关键如果某些切面吞掉异常外层的事务拦截器、统一的全局异常处理器就感知不到错误很可能出现接口返回成功数据库却没执行的诡异问题。log.info的args数组直接打印前提是toString()方法没有坑。很多业务对象打印的是内存地址或一堆无用字段建议在参数对象上重写toString()或者统一用JSON序列化工具处理后再打印。3.3 哪里使用一个业务Service方法打上注解接着写一个业务类把ApiMonitor标到需要监控的方法上。package com.example.aopdemo.service; import com.example.aopdemo.annotation.ApiMonitor; import org.springframework.stereotype.Service; Service public class OrderService { ApiMonitor(bizName 查询订单) public Order getOrderById(Long orderId) { // 这里模拟一个耗时操作 try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return new Order(orderId, sample); } }启动项目调用getOrderById(100L)日志输出如下[环绕-开始] biz查询订单, 目标方法OrderService.getOrderById, 参数[100] [环绕-返回] biz查询订单, 方法执行成功, 返回结果Order{id100, namesample} [环绕-结束] biz查询订单, 总耗时203ms日志里能看到方法执行前后和耗时信息一个最简单的环绕通知就起作用了。3.4 不同切点表达式怎么选上面用的是annotation(apiMonitor)这个写法要求切面和注解参数绑定。还有一些常用的切点写法我总结一下切点写法含义适用场景execution(* com.example.service.*.*(..))匹配某个包下所有方法对指定包级粒度做统一拦截within(com.example.service..*)匹配某个包及其子包下所有方法多层级包结构统一拦截annotation(apiMonitor)匹配标注了指定注解的方法使用自定义注解精准控制拦截范围within(com.example.annotation.MyClassAnnotation)匹配标注了类注解的所有方法类级别统一加切面bean(orderService)匹配容器中名为orderService的Bean按Spring容器Bean名锁定目标实际项目中annotation结合自定义注解是最推荐的方式因为它把哪些方法应该被切的选择权交给了业务代码而不是靠正则匹配包名。包名匹配经常误伤改一下结构就失效排查起来非常痛苦。4. ProceedingJoinPoint核心API深挖4.1 你能从参数里拿到哪些信息ProceedingJoinPoint是环绕通知里最重要的参数。你可以把它理解成一个目标方法的说明书通行证。它的常用方法可以分成两类。第一类是从JoinPoint继承来的信息方法getSignature()拿到方法签名。可以强转成MethodSignature进一步拿到方法对象、返回类型、参数名、类名等。getTarget()拿到目标对象实例。注意是代理对象背后的业务对象不是代理对象本身。getArgs()拿到目标方法的入参数组这个数组是原方法的参数快照能不能改、改了有没有用后面细说。getThis()拿到代理对象本身有时候可以用来判断代理是否生效。第二类是ProceedingJoinPoint独有的方法proceed()执行目标方法返回目标方法的返回值。proceed(Object[] args)用新的参数数组执行目标方法。这就给了你改参数的能力。实际开发中getSignature().getName()用来获取方法名是最常用的如果需要获取方法上的其他注解可以先强转MethodSignature再用signature.getMethod()拿Method对象。4.2 proceed()的调用时机就是整个环绕通知的灵魂proceed()的时机选择决定了你在哪个节点做哪些事。我列几个典型模式前置校验模式在proceed()之前做权限校验或参数合法性校验不通过就抛出异常或者直接返回兜底对象目标方法完全不会执行。后置增强模式proceed()之后拿到返回值做敏感字段脱敏、结果包装统一格式。异常兜底模式在catch块里记录异常后把异常吞掉并返回降级结果保证接口不直接报500。但前面说过这会改变异常传播链路要做额外考量。全链路计时模式start到finally之间计算耗时。这是最普适的一种用法也是很多性能监控工具的基本盘。有人说环绕通知性能杀手其实恰恰相反。切面代码本身非常轻唯一要小心的就是在切面里做重复且重的操作比如每个方法都去查一次数据库、每个方法都做一次白名单校验。如果你发现自己写的切面比业务方法还重那你就要反思是不是应该把结果缓存或者把校验前置到网关层。4.3 修改入参和返回值权限到底有多大先说改返回值。环绕通知直接拦截目标方法返回值所以最简单的方式就是拿到result之后造另一个对象返回。这个是可行的也是环绕通知相对AfterReturning的一大优势因为AfterReturning虽然能拿到返回值但它的返回值修改能力非常鸡肋想改变方法最终返回结果基本做不到。再说改参数。pjp.getArgs()拿到的数组被称为目标方法的参数引用。这里有一个非常容易踩的幽灵坑你在切面里直接改数组里的对象属性是会生效的但你如果重新给数组的某一个下标赋值然后调用pjp.proceed()直接用这个数组新值也会传给目标方法。很多教程里会写只能修改对象引用无法真正改参数值——这是不对的因为proceed(Object[] args)本身就是为传新参数准备的。我遇到过的实际场景是参数加密外部接口传上来的参数是密文切面在proceed()之前先解密把解密后的明文对象放进新数组再通过proceed(newArgs)传给业务方法业务方法拿到的就是解密后的对象。Object[] newArgs new Object[args.length]; newArgs[0] decryptOrderId((String) args[0]); result pjp.proceed(newArgs);不过使用这个能力时要克制尤其是涉及权限和数据隔离的场景改参数可能造成越权数据泄露一定要结合业务规则反复确认。5. 实战案例用环绕通知做接口性能监控与统一异常处理5.1 一个能直接抄的接口耗时监控切面前面那个ApiMonitorAspect已经能记录耗时但在真正的生产项目里我们通常希望把监控数据输出成结构化格式方便日志系统采集。我在很多项目中会用类似下面的方式把耗时、链路ID、业务标识拼成一条JSON再配合ELK或云日志服务做分析。Around(annotation(monitorLog)) public Object recordMetric(ProceedingJoinPoint pjp, MonitorLog monitorLog) throws Throwable { String traceId MDC.get(traceId); StopWatch stopWatch new StopWatch(); stopWatch.start(); Object result; boolean success true; String errorMessage null; try { result pjp.proceed(); return result; } catch (Exception e) { success false; errorMessage e.getMessage(); throw e; } finally { stopWatch.stop(); if (log.isInfoEnabled()) { log.info(metric|{}|{}|{}|{}ms|{}|{}, monitorLog.bizName(), pjp.getSignature().toShortString(), traceId, stopWatch.getTotalTimeMillis(), success, errorMessage); } } }MDC.get(traceId)是分布式链路追踪的常用做法用来把一次请求的日志串联在一起。如果你项目里已经引入了spring-cloud-sleuth或者micrometer-tracing也可以通过TraceContext拿链路ID。总之生产环境监控日志务必带上能串联请求的ID否则单看耗时根本没法定。5.2 异常吞不吞吞了之后事务怎么办这是一个非常经典的问题也是很多人在写着写着就翻车的高发区。我来把这层逻辑彻底讲明白。环绕通知里try/catch捕获的是proceed()抛出的异常。如果你在catch块里记录完日志没有throw而是返回一个兜底结果那就意味着目标方法异常对于上层完全透明上层会把这个异常当作正常返回来处理。这个行为在什么时候危险当目标方法上还标了Transactional的时候。事务的执行边界其实在代理层TransactionInterceptor也在拦截链里。如果你这个环绕通知的执行顺序在事务拦截器之前并且把异常给吃掉了事务拦截器就看不到异常就不会触发回滚。结果可能是业务方法里执行了部分数据库操作出错了但异常被你吞掉事务提交数据变成半对半错的状态。所以在写环绕通知时我给自己定了一条铁律默认情况下绝不吞异常。就算一定要做降级处理也应该区分业务异常和系统异常只对明确标记可降级的业务异常进行兜底其他异常一律向上抛。如果你确实需要捕获后降级返回的效果就更要人为控制好切面的执行顺序比如让切面顺序比事务拦截器靠内或者干脆在业务代码里做try/catch这事不能图省事。5.3 统一返回结果包装的两种路径实际项目里还会看到一种用法把Controller层的返回结果统一包装成ResultT。有人会在环绕通知里做这件事但我更推荐在ResponseBodyAdvice或ControllerAdvice里做因为环绕通知离Controller层太远中间夹了参数解析、视图层等一堆环节容易出现包装错位。如果你是做前后端分离且接口统一返回JSON正确的姿势是环绕通知专注于日志、监控、幂等等非业务关心的事情返回结果的包装交给SpringMVC的增强组件。职责越单一日后维护越省心。6. 环绕通知实战中的高频踩坑与排查指南6.1 切面没生效先把这几个原因过一遍我Around写了业务方法也标了注解为什么就是没拦截这应该是AOP最常见的问题。总结下来绝大多数原因是下面五类。现象排查方向典型解法日志完全不打印切面类是否被Spring托管检查是否有Component或Aspect是否被组件扫描覆盖依赖starter没加AOP自动配置没触发引入spring-boot-starter-aop确定aspectjweaver在classpath同一类内部调用自调用不经过代理拆出独立Bean或注入自身代理或用AopContext.currentProxy()方法或类是finalCGLIB无法子类代理去掉final修饰或改用设计模式适配切点表达式不匹配表达式写错或漏了包名逐步简化表达式验证从execution(* *.*(..))开始排查第二个原因尤其隐蔽。Spring Boot中AOP自动配置是通过AopAutoConfiguration完成的它依赖spring-boot-starter-aop里的aspectjweaver。如果你项目里虽然有spring-boot-starter-web但没有AOP starter那么切面类也能被编译但运行时完全不会有任何代理生成。6.2 内部调用为什么拦不住怎么解决这是必考题。假设我在OrderService里写了一个createOrder方法内部直接调用sendNotify()但sendNotify()方法上标了ApiMonitor你会发现日志里压根看不到sendNotify被切面拦截。原因很简单Spring AOP代理是通过从容器获取Bean时返回代理对象来实现的。你在createOrder内部用this.sendNotify()这个this指向的是原始业务对象而不是代理对象所以切面对它不起作用。解决这个问题有三种路径把sendNotify()改成外部Bean的方法注入外部Bean再调用这是最干净的方式。在自身注入代理对象Autowired private OrderService self;然后调用self.sendNotify()注意这里注入的是代理所以AOP能生效。使用AopContext.currentProxy()获取当前代理对象前提是在配置上显式开启EnableAspectJAutoProxy(exposeProxy true)。但在Spring Boot里没有直接的配置项需要自己写一个配置类。我个人最推荐第一种。内部自己注入自己的代理对象容易在构造阶段产生循环依赖尤其在复杂Bean关系里风险较高。拆Bean不光解决AOP问题还让职责更清晰。6.3 多个切面执行顺序怎么控制当多个切面都拦截同一个方法时它们的执行顺序不是随机的而是可以通过Order注解控制的。Aspect Component Order(1) public class FirstAspect { ... } Aspect Component Order(2) public class SecondAspect { ... }这里有一个特别容易记反的点数字越小优先级越高但优先级体现在前置逻辑和进入顺序上。假设有切面AOrder1和切面BOrder2执行顺序是A的前置逻辑 - B的前置逻辑 - 目标方法 - B的后置逻辑 - A的后置逻辑这个顺序在结合事务切面的时候尤其重要。比如你想让某个环绕通知在所有事务之后处理异常那你的切面Order需要设置成比事务拦截器更晚执行也就是数字更大。所以一旦你的项目里环绕通知和Transactional联合使用一定要先在纸上把执行链排序画出来再决定Order的取值。6.4 环绕通知里使用try-with-resources的隐患有一个很少人注意的坑如果你在环绕通知中需要释放IO类资源比如数据库连接、文件流、HTTP客户端直接在里面写try-with-resources要格外小心。因为close()可能在proceed()还没有真正把资源使用完毕时就触发。我见过一个案例切面里给方法注入了InputStream环绕通知结束后立刻关闭流导致业务方法返回结果进行JSON序列化时流已经关闭系统抛出异常。正确的做法是确保资源关闭时机比业务流程更晚比如把close()放到finally里的异步延迟任务或者直接交给业务层管理。7. 最后再分享一点个人体会写AOP最容易上头因为可以用很短的代码做很多统一处理但只要一不留神就会做出看起来正常、出问题难定位的切面。我个人始终建议把环绕通知当成轻量级的横切逻辑运输车来用只做三件事记录日志、执行监控、做清晰定义好的横切校验。不要什么都往切面里塞尤其不要在里面重写一遍业务参数校验、不要在里面做需要查询数据库的复杂判断。切面一旦变重调试成本呈指数级上升。如果现在有人问我Spring AOP五种通知先学哪个我一定毫不犹豫地回答先学环绕通知。它不是最简单的但它是最能帮你理解AOP本质的。理解了proceed()的调用时机你就能理解为什么Spring会需要代理为什么事务要放在代理层为什么一个看似无害的吞异常操作会引发连锁反应。把这些底层逻辑搞透了以后再碰到SpringBoot里的各种怎么不生效的诡异问题你基本都能顺着思路五秒定位到原因。