恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Spring Boot 环绕通知实战:接口耗时统计、幂等防重与验签
首页
资讯中心
/
Spring Boot 环绕通知实战:接口耗时统计、幂等防重与验签
Spring Boot 环绕通知实战:接口耗时统计、幂等防重与验签
发布时间:2026/10/10 20:11:20
如果你在 Spring Boot 项目里做过接口耗时统计、防重复提交、第三方调用签名校验大概率经历过同一个噩梦Controller 里复制粘贴几十行一样的逻辑改一个需求就要全局搜索替换。Spring AOP 的环绕通知就是用来终结这种“模板代码满天飞”的利器。它把横切逻辑集中到一个切面里业务方法保持纯粹后续要调整规则也只需改一处。我用环绕通知处理过日志记录、幂等防重、开放接口验签、耗时告警这些典型场景也踩过自调用失效、异常被吞、切面重复执行之类的坑。这篇文章就把 Spring Boot 中环绕通知的完整用法、业务落地代码和避坑经验一次性讲清楚适合已经会用 Spring Boot 写接口、但想给代码做减法的人。1. 环绕通知是什么为什么实际业务里总是它1.1 先理清 AOP 的五种通知类型很多人能把“环绕通知”四个字说出来但真要解释它和其他通知的区别反而会含糊。Spring AOP 一共提供了五种通知先拉个表对比一下通知类型注解执行时机实际痛点前置通知Before目标方法执行前拿不到方法返回值异常时后续逻辑全断后置通知After目标方法执行后不论是否异常同样拿不到返回值返回后通知AfterReturning目标方法正常返回后能拿返回值但异常场景下不执行异常后通知AfterThrowing目标方法抛出异常后只能看异常不能处理返回值环绕通知Around目标方法执行前后全程所有时机自己掌控前置、后置、返回后、异常后这四种通知本质上都是“观察者”。它们像站在方法旁边的摄像机只能记录和反应不能改变事件本身。而环绕通知是“控制者”它把目标方法的调用过程整个包裹起来方法执不执行、执行时传什么参数、返回什么结果、抛出异常后怎么处理全部由你决定。在实际业务开发里我百分之九十的场景都直接选环绕通知因为它的能力覆盖了其他四种通知的合集还能额外做到两点修改目标方法的返回值比如把敏感信息脱敏后返回或者失败时返回一个统一的兜底结果。吞掉异常或者包装异常让调用方不用面对底层异常细节。1.2 环绕通知的设计思想把方法执行权握在自己手里环绕通知的底层思路是“代理模式 模板方法模式”。Spring 容器在启动时会为匹配切点表达式的 Bean 生成代理对象。外部调用这个 Bean 时真正执行顺序是调用代理对象的通知方法通知方法内部再通过ProceedingJoinPoint.proceed()触发原始方法。你可以把它类比成高铁进站闸机Around是安检员proceed()是放行闸门。安检员有权拦下乘客做额外检查也可以放行让乘客上车甚至可以把一个乘客的票改成另一个乘客的只要规则允许。目标方法就是那个乘客它根本感知不到安检员的存在乘客只知道自己从进站到上车这段路变得顺畅了。这种设计带来一个关键特性Spring AOP 根本不需要修改业务类的源码而是运行时生成代理所以目标类完全无侵入。这也是它比“写一个公共工具类在业务方法里手动调用”更优雅的根本原因——业务方法里一行横切代码都不用写。1.3 什么场景非环绕通知不可一句话总结只要你需要对“方法调用全过程”进行统一控制就只能用环绕通知。典型场景我列几个接口耗时统计必须在方法执行前记录开始时间执行后计算差值异常分支也要算这天然需要包裹整个过程。接口幂等防重要在方法执行前加锁、拦截执行成功后再决定是否释放锁只靠 Before 和 After 分开写很容易丢上下文。统一异常处理与返回值包装需要 catch 所有异常然后统一封装成固定结构返回只有环绕通知能拦截异常并替换返回值。带重试机制的方法调用proceed 失败后业务代码自己决定是否再次调用 proceed这也是环绕通知的独有玩法。第三方开放接口签名校验验签通过才把请求交给目标方法验签失败直接返回错误码不需要让业务代码参与。你自己回顾一下项目里的那些“非业务逻辑”性能日志、操作记录、权限校验、接口限流、幂等控制哪一个不是包裹在方法调用前后这正是环绕通知存在的价值。2. Spring Boot 里最快落地的一版环绕通知2.1 引入依赖与搭建环境Spring Boot 默认不会给你装 AOP 相关依赖需要手动加spring-boot-starter-aop。这个依赖会带着aspectjweaver和spring-aop一起进来足够支撑注解方式使用切面。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency如果你用的是 2.7.x 或 3.x 的 Spring Boot这个依赖坐标不需要指定版本交给父工程统一管理就行。从实践来看Spring Boot 3.x 里这套 AOP 用法和 2.x 几乎没差别下面的代码在两个大版本下都能直接跑。这里有个容易误会的地方Spring Boot 的 AOP 自动配置默认是开启的正常情况下你不需要写EnableAspectJAutoProxy。只有当你自己改了条件配置spring.aop.autofalse或者引入的依赖组合比较特殊时才需要显式加这个注解开启代理。开发环境用 IntelliJ IDEA 社区版、Eclipse 还是命令行 Maven 都完全不影响 AOP 功能它纯粹是 Spring 容器层面的东西。2.2 一个最小可运行的环绕通知切面先给你一个打开就能跑的最小示例目标是把controller包下所有方法执行耗时都打印出来。package com.example.demo.aspect; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; Slf4j Aspect Component public class TimeCostAspect { Around(execution(* com.example.demo.controller..*.*(..))) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { long cost System.currentTimeMillis() - start; log.info([耗时切面] {}.{}() 耗时 {} ms, joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(), cost); } } }Aspect告诉 Spring 这是个切面类Component把它注册成 BeanAround里的表达式决定了它拦截哪些方法。整个逻辑非常直观先记录开始时间调用proceed()真正执行目标方法finally块保证不论正常返回还是抛出异常耗时都能被算出来。注意一个细节around方法必须声明throws Throwable。因为ProceedingJoinPoint.proceed()会向上抛出目标方法的任何异常你不允许编译器通过的话代码都写不出来。2.3 ProceedingJoinPoint 能给你什么信息ProceedingJoinPoint是环绕通知里最核心的参数千万别把它当成摆设。我梳理一下它常用方法的用途方法返回内容典型用途proceed()Object触发目标方法执行可以拿到返回值proceed(Object[] args)Object用新的参数列表触发目标方法适合做参数修改/重绑getSignature()MethodSignature拿到方法签名包括方法名、类全限定名、参数名getArgs()Object[]拿到本次调用入参getTarget()Object拿到目标对象实例一般不建议直接用getThis()Object拿到当前代理对象可用它解决自调用问题实战中我几乎都会用getSignature()打印“哪个类哪个方法被切了”。因为切面表达式可能匹配一个包下的几十个方法没有这个信息你排查问题时会疯掉。有一个小坑提前说joinPoint.getArgs()返回的是 Object 数组如果方法没有参数这个数组长度是 0不是 null。取参数时不要直接args[0]先判断长度。再补充一个带参调用proceed的场景。有时候我们需要修改入参再放行目标方法比如统一给请求参数补一个公共字段代码可以这样写Object[] args joinPoint.getArgs(); if (args.length 0 args[0] instanceof CommonRequest) { CommonRequest request (CommonRequest) args[0]; request.setTraceId(UUID.randomUUID().toString().replace(-, )); } return joinPoint.proceed(args);这个能力只有环绕通知有其他通知想改参数基本要配合参数绑定绕得很。我在处理老系统接口兼容时经常用这一招老接口少了一个新字段我不改业务代码只在切面里给参数补上默认值。2.4 切点表达式控制你的影响范围切点表达式是环绕通知的“瞄准镜”。表达式写得太宽会把系统内部方法、定时任务、框架回调全都变成打击目标写得太窄业务方法又切不进来了。常见表达式类型我放在一个表里对比切点类型示例适用场景executionexecution(* com.example.service..*.*(..))按方法访问修饰符、类名、方法名匹配最常用annotationannotation(com.example.annotation.Demo)只匹配加了指定注解的方法精准投资withinwithin(com.example.controller.*)按类类型匹配粒度比 execution 粗beanbean(orderService)按 Bean 名称匹配适合快速限定单个服务argsargs(com.example.dto.OrderDTO)按入参类型匹配execution表达式里几个符号的含义要记牢*表示任意内容放在方法返回值位置表示任意返回类型。..表示任意子包或任意数量参数放在包名后缀或参数位置。(..)中的两个点表示任意参数数量和类型。举个例子execution(* com.example.demo.service..OrderService.*(..))表示匹配service任意子包下 OrderService 类里的所有方法不管返回值是什么、参数是什么。在 Spring Boot 项目里我建议优先用annotation或者“包范围 annotation”的组合。直接execution(* com.example.controller..*.*(..))虽然简单但容易把白名单、回调接口也切进去后续你只想去掉某些方法时反而要重新写表达式。3. 三个掉头就走的业务场景直接抄作业3.1 场景一统一接口耗时与日志记录这是环绕通知最经典的入门场景。需求是项目里所有 Controller 的接口都要自动打印“哪个接口被调用、入参是什么、耗时多少、返回结果是什么”。我给它定的规范是代码侵入为零日志量可控参数自动脱敏。千万注意直接把参数对象打印出来可能踩大坑——对象里的密码、身份证号、手机号会原样进日志这在安全审计节骨眼上是严重隐患。所以切面里我放了简单的脱敏工具方法。package com.example.demo.aspect; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; Slf4j Aspect Component public class ApiLogAspect { private final ObjectMapper objectMapper new ObjectMapper(); Around(execution(public * com.example.demo.controller..*.*(..))) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { String className joinPoint.getSignature().getDeclaringTypeName(); String methodName joinPoint.getSignature().getName(); long start System.currentTimeMillis(); Object result; try { result joinPoint.proceed(); log.info([API入口] {}.{} 入参{} 返回{} 耗时{}ms, className, methodName, buildParamText(joinPoint.getArgs()), objectMapper.writeValueAsString(result), System.currentTimeMillis() - start); return result; } catch (Exception e) { log.error([API异常] {}.{} 入参{} 异常{} 耗时{}ms, className, methodName, buildParamText(joinPoint.getArgs()), e.getMessage(), System.currentTimeMillis() - start); throw e; } } private String buildParamText(Object[] args) { if (args null || args.length 0) { return 无; } try { return objectMapper.writeValueAsString(args); } catch (Exception e) { return 参数序列化失败; } } }这套切面里有个关键选择异常分支我不吞而是先打印完整现场日志然后重新把异常抛出去。原因很简单接口日志切面是“观察者”不是“决策者”异常最终怎么处理应由全局异常处理器统一搞定。如果你在这里捕获后直接返回一个错误响应体后续想加全局异常处理、想统计错误率都会变得混乱。实际落地时还有几个细节值得注意ObjectMapper用局部实例没问题但如果你项目里已经注入了 Spring 管理的 ObjectMapper建议注入而不是 new能继承全局配置比如 Java 8 日期时间序列化配置。打印响应结果时如果返回值里有HttpServletResponse这类对象直接序列化会报错需要在代码里把javax.servlet.http.*类型的入参过滤掉。我这里的buildParamText没体现你写的时候记得判断一下。日志切面自身不要执行业务逻辑比如消息推送、远程调用否则接口性能会被切面拖垮。3.2 场景二接口幂等防重复提交防重提交是我个人觉着环绕通知“物超所值”的一个场景。以前没切面时每个提交接口都要手写一段 Redis 判断代码而且很容易漏掉。后来我用“自定义注解 环绕通知”做了一套通用防重业务流程里只需一个注解。先定义幂等注解package com.example.demo.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 Idempotent { /** 幂等 key 的前缀建议带上业务场景例如 order:submit */ String prefix(); /** key 过期时间单位秒默认 10 秒 */ int expireSeconds() default 10; }然后写切面。核心逻辑是利用 Redis 的SET NX EX语义实现“同一个 key 只能成功写入一次”。第二次请求带着相同 key 来时setIfAbsent会返回 false直接判定为重复提交。package com.example.demo.aspect; import com.example.demo.annotation.Idempotent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import java.util.UUID; import java.util.concurrent.TimeUnit; Slf4j Aspect Component RequiredArgsConstructor public class IdempotentAspect { private final StringRedisTemplate stringRedisTemplate; /** 注解里配的前缀 请求方唯一 ID拼成真正的 Redis key */ private static final String REQUEST_ID_HEADER X-Request-Id; Around(annotation(idempotent)) public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { String requestId resolveRequestId(); if (requestId null) { // 消息头里拿不到 requestId 时按“强制要求”处理 throw new IllegalArgumentException(缺少幂等请求头 X-Request-Id); } String key idempotent.prefix() : requestId; Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(key, UUID.randomUUID().toString(), idempotent.expireSeconds(), TimeUnit.SECONDS); if (Boolean.TRUE.equals(success)) { try { return joinPoint.proceed(); } catch (Exception e) { // 业务失败时是否删除 key 是一个产品决策。默认保留到过期避免用户快速重试把错误请求顶掉 throw e; } } log.warn([幂等拦截] key{} 重复请求已被拦截, key); // 这里抛异常还是返回兜底对象取决于你定义的错误响应方式 throw new RuntimeException(重复提交请稍后再试); } private String resolveRequestId() { // 简化实现从请求头获取你的项目里可以直接引入 RequestContextHolder return demo-request-id; } }这个切面真正“环绕”的体现在于加锁动作在proceed()之前锁的释放或不释放由切面决定业务方法全程无感知。防重逻辑是典型的不适合拆成 Before After 的场景——因为两次通知之间无法共享同一个局部变量 key。很多团队做防重时有个纠结什么时候删除 Redis key我踩过的坑告诉你别在执行成功后立刻删。假如你的业务方法执行成功了但接口响应在网络传输时超时客户端自动重试这时 key 已经删了重试请求会再次执行业务逻辑造成数据重复。正确做法是让 key 自然过期过期时间给一个稍微大于接口平均耗时的时间。3.3 场景三第三方开放接口签名校验很多公司会遇到“对外开放接口”这件事。项目里的开放接口可能单独部署一个服务也可能混在当前系统里。如果你问我是单放服务还是在原系统模块里我的建议是量大、需要隔离安全风险就单独开服务但代码组织上两者都可以先用切面统一验签。这样一来未来把接口模块拆成独立服务时验签逻辑直接搬走Controller 一行都不用改。我设计开放接口时给第三方调用方分配一个appId和appSecret。调用方请求时带上三个参数appId、timestamp、sign。签名规则先约定为MD5(appId timestamp appSecret)。切面要做的事从请求里取这三个参数。用appId查到对应的appSecret实际项目里会放到配置中心或者缓存里。按同样规则计算签名和请求里的sign比对。校验timestamp是否在 5 分钟内防止重放攻击。package com.example.demo.aspect; import com.example.demo.annotation.OpenApiSign; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.util.concurrent.ConcurrentHashMap; Slf4j Aspect Component RequiredArgsConstructor public class OpenApiSignAspect { /** 实际项目应从配置中心/数据库读取这里模拟 */ private static final ConcurrentHashMapString, String APP_SECRET_MAP new ConcurrentHashMap(); static { APP_SECRET_MAP.put(1001, abc-secret); APP_SECRET_MAP.put(1002, def-secret); } Around(annotation(openApiSign)) public Object around(ProceedingJoinPoint joinPoint, OpenApiSign openApiSign) throws Throwable { ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes null) { throw new RuntimeException(非法请求上下文); } HttpServletRequest request attributes.getRequest(); String appId request.getHeader(appId); String timestamp request.getHeader(timestamp); String sign request.getHeader(sign); if (!StringUtils.hasText(appId) || !StringUtils.hasText(timestamp) || !StringUtils.hasText(sign)) { throw new RuntimeException(缺少签名参数); } String secret APP_SECRET_MAP.get(appId); if (secret null) { throw new RuntimeException(未知的appId); } String serverSideSign md5(appId timestamp secret); if (!serverSideSign.equals(sign)) { log.warn([开放接口验签失败] appId{}, appId); throw new RuntimeException(签名校验不通过); } long diff Math.abs(System.currentTimeMillis() - Long.parseLong(timestamp)); if (diff 5 * 60 * 1000) { throw new RuntimeException(请求已过期); } return joinPoint.proceed(); } private String md5(String input) { // 正常写一个工具方法这里用 JDK 自带 MessageDigest 十六进制转换即可 return String.valueOf(input.hashCode()); } }这里我简化了 md5 实现实际项目里用DigestUtils.md5DigestAsHex或者引入commons-codec都行。但环切的思路值得借鉴所有开放接口的验签集中到一个切面新增开放接口时什么都不用关心只要加上OpenApiSign注解。这个方案比在基类 Controller 里写 init 方法干净多了——有些团队会用继承 BaseController 来做验签但 Java 是单继承如果以后 Controller 想继承别的东西就卡死了。AOP 没有这个约束。你要注意验签逻辑不要放在业务代码里否则其他人开发新开放接口时很容易漏掉验签接口裸奔在公网上。用注解 环绕通知从机制上逼迫开发者必须加上验签。4. 高频错误与排查思路踩坑实录4.1 切面不生效九成是这五个原因环绕通知写起来不难难的是“我写了切面但就是不执行”。我把大大小小项目里遇到的切面失效案例汇总一下按出现频率排序。第一个方法内部调用this.xxx()切面不生效。Spring AOP 是基于代理实现的当外部调用一个 Bean 的方法时进入的是代理对象所以环绕通知能生效。但当这个类内部调用它自己的另一个方法时用的是this关键字指向的是原始对象而不是代理对象所以切面就绕过去了。解决方式有三种把内部调用改为通过注入自身的代理对象调用配合Lazy避免循环依赖或者把需要被切面的方法抽到另一个 Spring Bean 里再或者使用AopContext.currentProxy()但前提是你开启了exposeProxytrue。我后面会再细讲。第二个切点表达式写错。最常见的是execution表达式里的包名大小写写错、..和*的位置不对。我排查这类问题时会先写一个特别宽的表达式比如execution(* com.example.demo.controller..*.*(..))确认能切进去后再逐步收紧范围。如果最宽的都能匹配但签名里又限制条件时就要检查方法访问修饰符——execution默认只匹配 public 方法除非把*放在第一位通配修饰符。第三个切面类没有被 Spring 扫描到。这个错误很隐蔽因为团队里一旦有人把切面类放在业务包外面而启动扫描又指定了scanBasePackages切面类就可能完全不会被加载。验证方法很简单启动日志里搜Aspect或者直接在切面构造方法里打断点一次没进来就说明没加载。第四个类或方法是 final 修饰的。Spring Boot 2.x 之后 AOP 默认走 CGLIB而 CGLIB 是基于继承生成子类代理的final 类无法被子类化。final 方法无法被重写。虽然 Spring Boot 2.x 默认已经使用 CGLIB但遇到 final 定义代理生成时同样会出问题或者干脆不匹配。所以不要给被切方法加 final。第五个JDK 动态代理与 CGLIB 的差异。如果你手工配置了spring.aop.proxy-target-classfalseSpring 会使用 JDK 动态代理。此时代理只对接口生效你如果注入的是一个具体类对象对象类型不匹配时代理对象也无法被强转导致注入失败。Spring Boot 默认配置是 CGLIB所以建议不要动这个开关。查代理对象是否生成有个很实用的调试手段在业务代码里注入 Spring 提供的AopUtils打印isAopProxy()的结果。boolean proxy AopUtils.isAopProxy(orderService); log.info(orderService 是否代理对象: {}, proxy);如果输出 false说明根本没代理切面表达式、启动加载这些方向一定有问题。4.2 proceed() 在 try-catch 里必须小心的坑环绕通知给了你异常处理的控制权但这个控制权也是刺客。很多人写切面时习惯性地用 try-catch 包住proceed()然后 catch 里直接 return 一个兜底值。表面上看接口不再报错了实际上是把所有异常都吞了调用方拿到一个“成功”的结果业务风险被埋了起来。看这个反面教材Around(execution(* com.example.demo.controller..*.*(..))) public Object around(ProceedingJoinPoint joinPoint) { try { return joinPoint.proceed(); } catch (Exception e) { return 系统繁忙; } }如果你只是做接口兜底这个写法也许能满足场景。但你要清楚异常被吞掉后外层的事务切面感知不到异常Transactional就不会触发回滚监控系统拿到的也是正常返回误报率会升高。我建议至少做两件事第一catch 里必须打 error 日志并带上上下文第二明确设计哪些异常要被吞、哪些要重新抛出。拿到异常后的处理有一个标准范式我比较推荐Around(annotation(demo)) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { try { return joinPoint.proceed(); } catch (BizException e) { throw e; } catch (Exception e) { log.error(捕获到未处理异常, e); // 你可以在这里把异常包装成自定义异常重新抛出 throw new BizException(系统内部异常, e); } }原则就是能识别的业务异常直接透传未预期的异常包装后抛出千万不要闷不做声地吞掉。4.3 多切面执行顺序与事务纠缠的问题当一个方法同时被事务切面、日志切面、权限切面环绕时执行顺序是有讲究的。Spring AOP 通过Order注解控制切面的执行顺序数字越小越早执行并且最外层包裹。顺序的一个指导原则是想“看见”事务提交后的结果就把对应切面放在事务内层不关心事务状态的可以放在外层。举个例子一个插入操作上有Transactional同时我又写了一个操作日志切面。如果日志切面的Order在所有其他切面之前也就是最外层那么执行顺序是日志切面开始 → 事务切面开启事务 → 目标方法执行 → 事务切面提交事务 → 日志切面记录日志。日志切面此时记录的是已经提交后的结果如果它内部要发出消息或做异步通知正好可以拿到稳定的数据。反过来如果日志切面在事务内层执行顺序就会变成事务切面开启事务 → 日志切面开始 → 目标方法执行 → 日志切面记录日志这时代事务还没提交→ 事务切面提交事务。如果日志切面里有耗时操作事务提交会被延后导致事务持有数据库连接的时间变长并发压力下连接池容易被占满。验证执行顺序有个土办法给切面代码里log.info加标记比如“第一个切面 start/第二个切面 start”然后跑一次请求看日志顺序就知道切面怎么嵌套了。4.4 异步线程里拿不到 RequestContextHolder 的值这个坑是我做日志链路时踩的。环绕通知里通过RequestContextHolder.getRequestAttributes()拿 HttpServletRequest主线程内一切正常但一旦业务流程里用了Async异步方法执行异步逻辑的子线程里RequestContextHolder就变成了 null。原因是RequestContextHolder基于 ThreadLocal 实现ThreadLocal 的数据是线程私有的子线程默认不会继承父线程的副本。解决方式很多我最后选了最优的一种配置一个TaskDecorator把主线程的请求上下文显式设置到异步线程里。Component public class ContextTaskDecorator implements TaskDecorator { Override public Runnable decorate(Runnable runnable) { RequestAttributes context RequestContextHolder.getRequestAttributes(); return () - { try { RequestContextHolder.setRequestAttributes(context); runnable.run(); } finally { RequestContextHolder.resetRequestAttributes(); } }; } }在注入线程池的地方设置ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setTaskDecorator(new ContextTaskDecorator());这样异步线程也能拿到请求上下文切面里的验签、日志、请求信息获取就不会折在半路了。注意这个方案是在 Spring 线程池的场景下生效的如果你用裸的new Thread()创建线程ThreadLocal 一样不会传递。5. 经验这样写环绕通知更好维护5.1 用自定义注解做精准切点别全局扫射直接写execution(* com.example.demo..*.*(..))虽然省事但会让排查问题变得很痛苦。我见过一个项目全局包匹配切面里堆了五种逻辑任何地方加一个类都可能被扫到后面的人根本不敢动那个切面。更好的方案是给切面定义一个明确的“开关语义”那就是自定义注解。你把它理解成打标记业务方法主动声明“我要被哪个切面处理”。加注解方式的切点表达式是annotation(...)没有任何“猜”的成分。以下是一个实际用过的“操作审计日志”注解和切面Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface AuditLog { String action(); }切面方法带有注解参数绑定Around(annotation(auditLog)) public Object around(ProceedingJoinPoint joinPoint, AuditLog auditLog) throws Throwable { log.info(操作类型{}, 方法{}, auditLog.action(), joinPoint.getSignature().getName()); return joinPoint.proceed(); }当切面逻辑出错时“哪些方法会被切”这个问题一目了然。你跟同事说“给方法加个 AuditLog 即可”比让他在 AOP 表达式里研究包路径靠谱多了。5.2 在切面里拿全参数名和注解让日志有生命力环绕通知里除了能拿到参数值还能拿到方法参数名。MethodSignature.getParameterNames()可以获取真实参数名前提是编译时加了-parameters参数Spring Boot 的 Maven 插件默认会加。拿到参数名后日志里可以直接输出“参数名参数值”排查问题时的可读性会好很多。稍微改造一下日志切面的buildParamTextMethodSignature signature (MethodSignature) joinPoint.getSignature(); String[] paramNames signature.getParameterNames(); Object[] paramValues joinPoint.getArgs();如果你还需要读取方法注解上的属性直接用annotation绑定注解实例是最快的不用反射。但如果注解同时在类和方法上且你想合并类级和方法级配置可能需要手动从joinPoint.getTarget().getClass().getMethod(...)取注解这种场景不多了解即可。5.3 给切面增加开关、排除清单和灰度策略生产环境里最怕的就是改一个切面全站跟着遭殃。所以我的切面类都会配一个“开关”和“排除清单”。开关用 Spring Boot 配置直接控制切面 Bean 是否加载Component ConditionalOnProperty(prefix app.aop, name enabled, havingValue true, matchIfMissing true) public class ApiLogAspect { ... }比如压测时想临时关掉接口耗时日志不用改代码改配置即可。排除清单可以在切面代码里根据方法名判断Around(within(com.example.demo.controller..*)) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { String methodName joinPoint.getSignature().getName(); if (excludeMethods.contains(methodName)) { return joinPoint.proceed(); } // 正常切面逻辑 ... }但这样会让代码变得冗长。更好的手段是用 !bean(...)或 !within(...)排除特定类比如Around(execution(public * com.example.demo.controller..*.*(..)) !within(com.example.demo.controller.HealthController))灰度策略同样适用于环绕通知通过配置中心动态下发“灰度比例”切面里Math.random()判断是否走新逻辑。这个思路在一些需要重放请求或者双写的场景里尤其好用比让业务代码写 if 判断干净得多。5.4 性能与嵌套环绕通知不是越多越好我见过一个项目给一个方法叠了九个切面调用链路又臭又长出问题时翻日志翻到怀疑人生。每加一个环绕通知方法调用链上就多一次代理转发和一次方法调用。虽然 Spring AOP 的性能损耗在大多数业务系统里可以忽略但嵌套过多会影响以下几点日志里链路变长排查问题时 n 个切面“披着”同一方法看哪个都像。切面之间可能互相影响一个切面改了返回值下一个切面拿到的结果就不是真实业务结果。异常处理链路复杂化某层吞了异常外层完全无感知。我的习惯是做减法能用自定义注解精准点名的就不用包匹配能合并的日志、耗时、监控逻辑就合并成一个切面尽量控制在一个方法最多两三个环绕通知。还有一点容易被忽略环绕通知里千万不要做重的 IO 操作。例如每次方法调用都同步写日志表、同步调远程接口这会把一个 5ms 的业务方法拖到 80ms。如果确实要丢数据到数据库建议切面里只记录另起一个线程或 MQ 异步消费。最后聊几句实际感受环绕通知是我用 Spring AOP 时最顺手、也是最有“掌控感”的工具。它就像一张织好的过滤网把所有重复的横切逻辑拦在业务代码之外。用熟了之后你会慢慢形成一种习惯遇到有统一规则的代码先想想是不是应该写切面而不是继续复制粘贴。但我也要提醒一句环绕通知的控制力越强越要谨慎设计。尤其是那些会修改返回值、吞异常、改变参数的环绕通知必须写清楚注释否则几个月后你自己回头看代码都会疑惑“这里为什么把异常 catch 掉了”。给团队成员定的规矩可以简单一点切面只做横切不替代业务决策异常要么透传要么写日志后按统一策略抛。这套规矩我沿着项目用到现在AOP 带来的维护成本控制在了很低的水平。如果你刚开始接触建议从接口耗时日志这个最安全的场景入手跑通了再尝试幂等防重、签名校验。先小范围实验别一上来就把整站包进一个大切面里。环绕通知本身是可以随时加随时撤的找准时机用它就是代码质量提升的加速器。