
1. 环绕通知在Spring AOP中的定位与价值1.1 一个真实场景日志、耗时统计与参数体检我先说一个很常见的场景业务接口越来越慢你接到需求说“给核心接口加日志记录参数、响应、耗时”。如果每个接口都去改业务代码工作量不说侵入性也很强而且很容易漏掉几个关键方法。我当时在排查一个线上偶发慢查询时就是先手动在Controller里打点结果每个接口都要加两行日志查完还要删非常折腾。后来我把这些横切需求统一收敛到一个切面里用环绕通知Around把目标方法的调用完全包起来前置记录时间戳调用proceed()让目标方法执行后置计算耗时finally里输出日志。这样一来业务代码一行没改所有需要监控的方法自动获得完整的日志和耗时数据。这个例子基本就是环绕通知的典型价值在不侵入业务代码的前提下把日志、权限、限流、事务、缓存等横切逻辑统一收口。你只需要定义“哪些方法需要被拦截”和“拦截后做什么”剩下的交给Spring AOP的代理机制去处理。对于刚接触AOP的开发者来说环绕通知是所有通知类型里最值得优先掌握的一种因为它的能力边界最大理解清楚之后其他通知类型几乎无师自通。1.2 前置、后置、异常、最终与环绕怎么选Spring AOP一共提供五种通知注解很多人一开始会搞混它们各自适合干什么。我平时会这样归类Before目标方法调用前执行适合参数校验、权限校验、初始化上下文。AfterReturning目标方法正常返回后执行适合处理返回值、记录成功日志。AfterThrowing目标方法抛出异常后执行适合记录错误、触发告警。After目标方法结束后执行无论正常还是异常都会走这里相当于finally。Around最强力的一个能控制目标方法是否执行、何时执行、传什么参数、返回什么值、抛什么异常。这五种类型可以单独用也可以组合用。如果只是想在方法执行前做个判断用Before就够如果想在方法成功后写一条日志AfterReturning更清晰。但当你需要同时拿到前置、后置、返回值和异常信息或者需要主动控制流程时Around几乎是你唯一的选择。我整理了一个对比表方便快速理解通知类型执行时机是否接管方法调用典型用途Before方法执行前否权限校验、参数校验AfterReturning方法正常返回后否日志、返回值包装AfterThrowing方法抛出异常后否异常记录、告警After方法结束后否清理资源Around方法前后及异常都可控是耗时统计、重试、限流、分布式锁有一个经验想分享能用具体通知解决的就别用Around。因为Around一旦写得不严谨很容易出现漏调proceed()、吞异常、乱改返回值这些问题。它的灵活是一把双刃剑适合的是“需要对执行过程做整体控制”的场景而不是单纯想在方法前加一行日志的情况。1.3 为什么Spring Boot项目常用环绕通知而不是过滤器有同学会问同样做日志用Servlet的Filter拦截请求不行吗我的答案是Filter工作在Servlet容器层面拿到的是HttpServletRequest它只能看到HTTP请求的URL、Header和流根本感知不到具体哪个Service方法会被调用也拿不到方法上的自定义注解和参数对象。换句话说它能拦“请求”但拦不到“方法”。环绕通知不一样它工作在Spring容器内部通过代理对象拦截方法调用可以很方便地获取方法签名、参数值、注解、返回值甚至能干预方法的执行过程。类似“某个Service方法加了CacheEvict方法执行完自动清缓存”这种需求用Filter是没法实现的。Spring Boot对AOP的支持也很成熟spring-boot-starter-aop加上后大多数场景零配置就能跑这也是为什么很多Spring Boot项目里接口级日志、权限、限流、分布式锁都是用环绕通知来做的。2. 动手写一个环绕通知核心实现与参数说明2.1 引入依赖与基础配置如果你用的是Spring Boot 2.x或3.x在pom.xml里加入AOP起步依赖就行dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency这个starter会引入spring-aop和aspectjweaver并提供AOP自动配置。大多数情况下Spring Boot的AopAutoConfiguration会帮你把EnableAspectJAutoProxy自动开启你不需要手动加任何配置。只有当你自己改动过自动配置或者项目是很老的非Spring Boot工程时才需要显式加上EnableAspectJAutoProxy。注意如果你看到切面一直不生效先确认pom里是不是真的把spring-boot-starter-aop加进来了而不是只加了spring-boot-starter-web。这个问题我见过太多次。2.2 定义切面类与Around方法下面是我最常用的一套标准写法建议直接收藏import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; Aspect Component public class ServiceLogAspect { private static final Logger log LoggerFactory.getLogger(ServiceLogAspect.class); Around(execution(* com.example.demo.service.*.*(..))) public Object serviceLogAround(ProceedingJoinPoint pjp) throws Throwable { String method pjp.getSignature().toShortString(); Object[] args pjp.getArgs(); long start System.currentTimeMillis(); log.info(方法开始{}参数{}, method, java.util.Arrays.toString(args)); Object result; try { result pjp.proceed(); } catch (Throwable throwable) { log.error(方法异常{}耗时{}ms, method, System.currentTimeMillis() - start, throwable); throw throwable; } long cost System.currentTimeMillis() - start; log.info(方法结束{}耗时{}ms返回值{}, method, cost, result); return result; } }拆开来理解几个关键点Aspect声明这是一个切面类。Component把切面类交给Spring容器管理。Around(execution(...))括号里的表达式是切入点决定哪些方法会被拦截。ProceedingJoinPoint环绕通知专属参数它比普通的JoinPoint多了proceed()方法。pjp.proceed()必须手动调用目标方法才会执行。不调用的话业务方法压根不会跑。返回值要原样返回除非你有意包装异常建议先记录再原样抛出去不要擅自吞掉。2.3 切入点表达式匹配Service层方法的三种写法环绕通知能不能命中目标方法完全看切入点表达式。我平时常用的有以下几种execution表达式基于方法签名匹配最精确Around(execution(public * com.example.demo.service.*.*(..)))annotation表达式匹配带指定注解的方法适合自定义注解做标记Around(annotation(com.example.demo.annotation.ApiLog))within表达式按类或包范围匹配Around(within(com.example.demo.service..*))你可能会踩的一个坑切入点是写接口还是写实现类。Spring AOP默认用JDK动态代理如果目标类没有实现接口会用CGLIB代理。为了避免代理方式不同带来的签名问题我的建议是切入表达式直接写到实现类所在的包比如com.example.demo.service.impl。这样不管底层用哪种代理都能稳定命中。2.4 通过自定义注解精准控制如果只想给个别方法加日志或限流我不建议用全局包扫描因为很容易误伤。更好的做法是定义一个自定义注解然后在切面里只拦带注解的方法。定义注解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 ApiLog { String value() default ; }切面改为Around(annotation(apiLog)) public Object apiLogAround(ProceedingJoinPoint pjp, ApiLog apiLog) throws Throwable { String method pjp.getSignature().toShortString(); long start System.currentTimeMillis(); try { return pjp.proceed(); } finally { long cost System.currentTimeMillis() - start; log.info(方法{}执行耗时{}ms备注{}, method, cost, apiLog.value()); } }在业务方法上加上ApiLog就只对这个方法生效ApiLog(创建订单) public Order createOrder(OrderDTO dto) { // 业务逻辑 }用注解控制的好处是显式、可控适合最终落地到生产环境的做法。全局扫描适合快速验证自定义注解适合长期维护。3. 关键细节proceed()、返回值处理与异常传播3.1 proceed()为什么必须手动调用这是环绕通知和前置通知最大的区别。Before执行完之后框架会自动帮你去调用目标方法你不需要管。但Around对应的是MethodInterceptor的实现责任完全交给你你必须自己决定要不要放行、什么时候放行。可以这样理解pjp.proceed()这行代码就是整个拦截链的“闸门”。不调用它目标方法就被卡住了接口返回null或者你指定的默认值。这在实现“降级开关”功能时很有用但大多数人出问题也是因为忘记调它。明明配了切面接口却一直不返回数据十有八九就是这块写漏了。还需要注意一点proceed()可以被多次调用。比如实现简单的重试机制第一次调用抛异常隔一下再调用一次。这不是bug而是环绕通知“接管控制权”的体现。但这也意味着你不小心调了两次proceed()目标业务方法就会执行两次这是后面要重点避开的坑。3.2 返回值与异常的最佳实践我见过不少刚写的切面把一个正常的业务返回值改了导致前端拿到的数据结构变了排查半天才发现是切面动了手。如果只是记录日志、统计耗时返回值原封不动返回就好。如果确实要做返回值包装比如统一返回Result一定要确认方法签名和接口文档是否接受这个变更。异常处理上最稳妥的做法是try { return pjp.proceed(args); } catch (Exception e) { // 记录日志或做兜底 throw e; }原则是不吞异常不改返回值。尤其是涉及Spring事务的方法异常被吞掉之后事务会因为感知不到RuntimeException而错误提交这个后果比想象中严重得多。我有一个习惯在切面里只做“记录”和“补充”不做“篡改”。3.3 参数传递给proceed()传不一样的入参环绕通知还允许你在目标方法执行前修改参数。比如某些参数需要解密或者分页参数没有默认值可以在切面里统一处理。示例Object[] args pjp.getArgs(); if (args.length 0 args[0] instanceof PageDTO) { PageDTO page (PageDTO) args[0]; if (page.getPageSize() null) { page.setPageSize(20); } } return pjp.proceed(args);注意如果不想修改参数就直接调用pjp.proceed()不要传null。如果修改了数组建议基于原数组做浅拷贝后再改动避免影响其他方法的逻辑。传参这个能力虽然好用但也容易让切面和业务逻辑耦合建议只做统一性比较强的操作不要在里面塞太多业务判断。3.4 多切面嵌套时的执行顺序当多个切面切到同一个方法时Spring会按优先级依次执行。默认顺序不太可控建议用Order注解显式指定Component Aspect Order(1) public class FirstAspect { } Component Aspect Order(2) public class SecondAspect { }数字越小越先执行。先执行的外层切面如果调用proceed()会进入后一个切面真正目标方法在所有切面的proceed()都放行之后才执行。可以形象地理解成洋葱模型最外层切面先拿到请求然后一层层往里传最后执行核心方法执行完再一层层往外返回。实际操作中我习惯把入口日志、限流这类横切逻辑放在最外层把事务、缓存这类和业务紧密相关的放在里层。顺序一旦颠倒可能出现日志还没打事务已经提交或回滚的情况排查起来会非常混乱。4. 常见问题与排查技巧实录4.1 环绕通知没生效的四个原因这是新手最高频的问题我整理了一个速查表现象原因排查方法通知不执行Aspect类没有被Spring扫描到确认Component注解存在检查启动类的ComponentScan扫描范围通知不执行切入点表达式不匹配临时用日志打印切入点或写个空方法做验证通知不执行调用的是类内部this.method()Spring AOP基于代理内部调用不走代理注入自身代理或拆分Bean通知不执行用了final类或final方法CGLIB无法代理final类和方法改用接口实现或去掉final异常被吞切面catch后没重新抛出让异常继续向上传递还有一个容易忽略的坑如果你的项目里同时用了aspectj-maven-plugin做编译期织入而Spring Boot默认是运行期代理两者混用会带来很多奇怪行为。我的建议是Spring Boot项目老老实实依赖starter做运行期代理不要去折腾编译期织入除非你有非常特殊的静态方法拦截需求。4.2 proceed()调用两次导致业务重复执行我有一次实现“超时重试”业务时在切面里写了for循环调proceed()结果忘了业务方法本身不是幂等的导致订单状态被重复处理最后还是靠日志发现了问题。这里一定要记住proceed()调用几次目标方法就执行几次没有任何兜底。如果你确实需要在切面里做重试建议只捕获特定异常增加重试次数上限并且确认业务操作幂等。更稳妥的方案是使用Spring Retry的Retryable注解而不是自己手写循环因为它有退避策略和重试模板控制起来更安全。4.3 泛型返回值与代理类型转换问题如果接口方法返回的是泛型T比如public T T query(ClassT clazz)切面里如果对返回值做了统一包装很容易出现类型转换异常。原因大多是返回值被转成Object后调用方按泛型擦除后的类型强制转换结果两边对不上。建议环绕通知里尽量保持原始类型返回前不要做多余操作。如果一定要缓存或序列化返回值也要在保存和读取时显式处理类型兼容。4.4 在异步线程中丢失上下文环绕通知里如果开了子线程去执行部分逻辑子线程很可能拿不到主线程的ThreadLocal比如登录用户信息、链路追踪ID。这个问题不是AOP特有的但在切面里做异步日志、异步告警时特别明显。我常用的两种处理方式一是把需要的上下文参数显式传进子线程二是用TransmittableThreadLocal之类的组件在线程创建时自动传递。不建议在切面里做太复杂的异步逻辑因为切面的职责是横切控制塞入太多线程管理会让代码变得很难调试。4.5 调试环绕通知的一个小技巧当切面不生效时除了反复打印日志还有一个更直观的调试方法在pjp.proceed()这一行打断点然后观察调用栈。如果调用栈里能看到CglibAopProxy或JdkDynamicAopProxy相关类说明Spring的代理机制正常切面确实生效了。如果调用栈直接进入真实Bean中间没有任何代理类就说明调用没有走代理。这个观察方法帮我定位过好几次“切面为什么没执行”的问题比瞎猜效率高很多。5. 从日志监控到分布式锁环绕通知的进阶玩法5.1 接口耗时监控与阈值告警在环绕通知里统计耗时超过阈值就告警是很实用的能力。代码结构上只要在finally里判断耗时即可Around(execution(* com.example.demo.service.*.*(..))) public Object slowQueryAlarm(ProceedingJoinPoint pjp) throws Throwable { long start System.currentTimeMillis(); try { return pjp.proceed(); } finally { long cost System.currentTimeMillis() - start; if (cost 1000) { log.warn(慢接口告警{}耗时{}ms, pjp.getSignature().toShortString(), cost); } } }这一步能帮你在生产环境快速发现接口性能退化。我经常把它和监控平台打通一旦耗时超过阈值就自动发告警不用给每个业务方法手动埋点。5.2 用环绕通知做简单的Redis分布式锁分布式锁的经典姿势是“加锁、执行业务、释放锁”。用Around表达特别自然Around(annotation(redisLock)) public Object redisLockAround(ProceedingJoinPoint pjp, RedisLock redisLock) throws Throwable { String lockKey redisLock.key(); boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, redisLock.expire(), TimeUnit.SECONDS); if (!locked) { throw new BusinessException(系统繁忙请稍后再试); } try { return pjp.proceed(); } finally { redisTemplate.delete(lockKey); } }注意释放锁时最好用Lua脚本判断持有者避免误删别人的锁。切面只是提供了整体结构锁的可靠性还需要结合具体业务设计。生产环境我建议用Redisson之类的成熟客户端分布式锁自研的成本并没有想象中那么低。5.3 事务控制中谨慎使用环绕通知Spring事务本身也是基于AOP实现的。如果你自定义的环绕通知和Transactional同时作用于同一个方法顺序控制非常重要。建议把事务切面的Order值设得小一点让事务先开启自定义切面包在事务外面做日志或限流时要清楚异常是否会影响事务。说白了环绕通知越强大越要清楚自己放在第几层。否则会出现“日志打了数据却回滚了”这种让人摸不着头脑的结果。我见过一个团队把业务校验写在切面里校验失败后直接抛异常结果方法事务正常回滚了但调用方拿到的错误信息不够清晰排查问题花了很多时间。5.4 与Spring Boot Actuator配合的切入点选择如果项目里开启了Spring Boot Actuator健康检查端点也是通过Spring MVC暴露的。如果你的切面一下子把所有Controller方法都拦了那/actuator/health、/actuator/metrics这些端点也可能被无差别拦截造成监控数据被日志淹没甚至影响健康检查的稳定性。我的建议是用包路径或自定义注解精确限定业务方法范围不要用宽泛的execution(* com.example..*.*(..))这样的表达式。尤其当选择包范围时要确保不要把Actuator、Swagger等框架相关端点切进来。这个细节看起来小但在真实运维中很影响体验。在我实际的项目里早期的做法是把所有Controller和Service方法都用环绕通知包一层日志量迅速膨胀后来调整为只在Service层做耗时监控在Controller层只对带ApiLog的方法打业务日志。整体上环绕通知的粒度一定要服务于业务能窄就不要宽能显式就不要模糊。这个经验我踩过几次坑之后才真正体会到有多重要。