Spring Boot AOP环绕通知实战:日志、耗时统计与异常处理
我先说个场景你的项目里每个接口是不是都在重复做这几件事——记录入参、统计耗时、把异常信息喂给告警系统我之前接手的系统光这段复制粘贴的日志代码就有二十多处每次想改统一格式都得全局替换稍不留神某个分支就漏改了。后来用 Spring AOP 的环绕通知Around把这些横切逻辑一次性抽出来一个注解加一个切面散落几十处的代码收敛成一行标注这才算真正舒了口气。这篇文章就围绕 Spring Boot 里 AOP 的环绕通知展开它和其他通知到底差在哪、ProceedingJoinPoint 该怎么用、什么场景非它不可以及哪些坑我踩过之后特别想提醒你。适合刚开始接触 AOP 的新手也适合被各种重复逻辑折磨到想重构的老手。看完全文你至少能自己写一个带日志、耗时统计、异常处理的通用切面还能顺手做个接口限流或者缓存访问控制。1. 环绕通知解决的是什么问题1.1 先从一段“带日志的接口”说起假设你有个保存订单的接口PostMapping(/order) public Result createOrder(RequestBody OrderReq req) { long start System.currentTimeMillis(); log.info(创建订单参数{}, JSON.toJSONString(req)); try { Result result orderService.create(req); log.info(创建订单成功耗时{}ms, System.currentTimeMillis() - start); return result; } catch (Exception e) { log.error(创建订单失败耗时{}ms, System.currentTimeMillis() - start, e); throw e; } }这段代码看着没什么问题但你把三个项目里所有 Controller 拉出来扫一眼会发现每个类都是这个模板的变体有人只打参数不打耗时有人 catch 之后吞掉异常直接返回 null有人把日志打到 JSON 序列化失败自己都没发现。真正的问题不是代码丑而是这些和业务无关的逻辑正在污染业务方法架构上把它们归为一类叫横切关注点——日志、鉴权、限流、事务、缓存全部属于这一类。AOP 的解法是反过来思考与其在每个方法里重复写“计时、记日志、处理异常”不如让这些逻辑在方法执行的前后自动出现。你只需要声明“哪些方法要套上这段逻辑”框架负责织入。1.2 五种通知类型为什么重活都交给 AroundSpring AOP 一共提供了五种通知能用一张表看清它们的定位通知类型执行时机能否阻止目标方法执行能否影响调用方拿到的结果典型场景Before目标方法调用前不能只能靠抛异常阻断不能前置校验、权限检查、简单日志AfterReturning目标方法正常返回后不能再阻止不能改返回值记录成功日志AfterThrowing目标方法抛出异常后不能再阻止不能异常通知、告警After目标方法结束后无论是否抛异常不能再阻止不能释放资源、清理状态Around目标方法调用前到返回后全程接管可以不调用 proceed() 即可可以对返回值再加工后返回耗时统计、缓存、限流、幂等、分布式锁从这张表能看出来Before 只能在门口看一眼AfterReturning 只能目送离开真正拥有“生杀大权”的只有 Around。它把目标方法的调用封装在一个方法入口里你可以开工前做校验也可以不进入目标方法直接返回一个兜底结果目标方法返回后你可以拿返回值修饰一番再交给调用方抛异常了你也可以记录完再原样抛出。所以“重活”落到 Around 上是必然的。你要想在 Spring Boot 里做接口耗时监控、缓存穿透保护、防重复提交靠其他四种通知不是做不了而是组合起来非常别扭——比如想做缓存命中就直接返回Before 根本做不到“跳过方法体”这件事。1.3 环绕通知的优点和代价优点很直接控制力最强一段逻辑覆盖方法执行的完整生命周期精简代码量最明显。而且因为整个调用链都在你手里你能拿到“最全上下文”——参数、目标对象、方法签名、返回值、异常信息这对打日志或者做监控来说非常友好。代价同样明显环绕通知是最容易被误用的通知类型。最大的风险点在于proceed() 代表了“继续执行目标方法”如果代码分支较多某个分支忘了调用 proceed()目标方法会静默不执行。接口不报错、日志也打了但前端左等右等没结果这种 bug 排查起来相当头疼。后面第 4 部分我会专门讲这个坑。另外环绕通知因为能力太强容易贪多。见过不少同事把日志、权限、限流、事务语义全塞进一个切面结果切面自己成了一个没人敢改的上帝类。我的习惯是一个切面只干一类事日志归日志、限流归限流宁可多写几个切面类也不要造一个万能切面。2. ProceedingJoinPoint控制目标方法的那只手2.1 JoinPoint 和 ProceedingJoinPoint 差在哪环绕通知的方法签名里第一个参数必须是 ProceedingJoinPoint这是它区别于其他通知最明显的特征。ProceedingJoinPoint 是 JoinPoint 的子接口多了两个关键方法proceed()和proceed(Object[] args)。其他通知里拿到的是 JoinPoint只能访问静态信息目标对象是谁、方法叫什么、参数有哪些。但 ProceedingJoinPoint 多出来的 proceed()意味着你手里握着“是否继续执行、用什么参数继续执行”的决定权。打个比方普通通知是在流水线旁边站着看产品经过时你打个标、拍个照环绕通知则是你把整个车间包下来了开工前检查材料生产时盯着流程产品出来后你想贴标、换箱、甚至认为这批不合格直接扔掉都可以。所以 ProceedingJoinPoint 就是那个“车间总控钥匙”。实际开发中环绕通知里最常用的几个方法Object target pjp.getTarget(); // 目标对象 MethodSignature signature (MethodSignature) pjp.getSignature(); // 方法签名 String methodName signature.getDeclaringTypeName() . signature.getName(); // 全限定方法名 Object[] args pjp.getArgs(); // 入参 Object result pjp.proceed(); // 放行目标方法2.2 proceed() 的两个重载和异常出口proceed() 无参版本效果等于用原参数调用目标方法。如果目标方法正常返回它的返回值会作为 Object 返回目标方法抛出异常它会把异常原样抛出来。还有一种场景你必须使用proceed(Object[] args)你想在切面里篡改参数。比如一个接口的入参统一在切面里补上当前登录用户 ID就是你从 header 里解析出 userId然后对参数数组做修改再用新数组放行目标方法。注意修改只在本次调用有效如果你在同一个环绕通知里多次调用 proceed()每次传入的数组可以不同相当于用不同参数多次调用目标方法——这种玩法通常只出现在特殊框架代码里业务上不要乱来否则一次请求悄悄执行了两次下单逻辑谁也救不了你。环绕通知里处理异常的出口要格外小心。最常见的安全写法是把 proceed() 包在 try-finally 里Object result; try { result pjp.proceed(); return result; } finally { // 无论成功还是异常这段代码一定执行 // 适合放耗时统计、清上下文 }异常出口的关键记住一点你 catch 到的异常百分之九十九的场景要重新抛出去不要吞掉。 尤其是方法上还挂了 Transactional 的时候你把异常吞了事务管理器感知不到异常可能把本该回滚的数据提交了这是我在现场排查过最棘手的问题之一。第 4 部分我会展开讲。2.3 通知写法的标准框架无论业务多复杂一个标准环绕通知的骨架是固定的。用伪代码表示就是Around(你的切点表达式或注解) public Object handle(ProceedingJoinPoint pjp) throws Throwable { // 1. 前置逻辑校验、日志、计时起点 Object result; try { // 2. 放行目标方法 result pjp.proceed(); // 3. 正常返回后逻辑加工返回值、记录成功日志 return result; } catch (Exception e) { // 4. 异常逻辑记录异常、告警 throw e; } finally { // 5. 最终逻辑耗时统计、资源清理最终一定执行 } }这个骨架能覆盖绝大多数场景。注意方法签名要声明throws Throwable因为 proceed() 抛出的可能是任意异常。你把异常抛给框架Spring 会根据目标方法的异常声明决定如何处理如果你在切面里重新包装成 RuntimeException 再抛调用方对异常类型的感知就会发生变化做业务开发时容易引发歧义。3. 实战一个包含日志、耗时统计、异常兜底的环绕通知3.1 环境准备与依赖引入Spring Boot 项目引入 AOP 只需要一个 starterdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency这个 starter 会把 spring-aop、aspectjweaver 一起带进来不需要单独加 aspectj 依赖。版本选择上Spring Boot 2.3.x、2.6.x 对 AOP 的 API 差异极小用你熟悉的稳定版本即可切到 Spring Boot 3.x 后底层换了 Jakarta EE但环绕通知的写法基本不变还是 Aspect、Around 那一套。有一点要注意Spring Boot 3 要求 JDK 17 起跳如果公司压着 JDK 8 不放老老实实留在 2.x 更稳妥。IDE 方面多说一句很多人被 IDEA 社区版卡住觉得没法开发 Spring Boot其实完全够用手动建 Maven 项目、配好 pom再装上 Lombok 插件就齐了。向导功能少一点而已不耽误写代码、跑测试。3.2 自定义注解 切面代码实现我推荐在真实项目里用“自定义注解”来圈定连接点而不是直接写 execution 表达式匹配所有 Controller。自定义注解的好处有三个第一能精确控制哪些方法需要这个逻辑第二别人看方法上的注解就明白这里有横切逻辑第三后面你想在注解里配置参数比如限流窗口、超时阈值会非常顺手。先定义一个注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface WebLog { /** 业务描述会打印到日志里 */ String value() default ; }再写环绕通知切面Slf4j Aspect Component public class WebLogAspect { private static final ObjectMapper MAPPER new ObjectMapper() .setSerializationInclusion(JsonInclude.Include.NON_NULL); Around(annotation(webLog)) public Object handleWebLog(ProceedingJoinPoint pjp, WebLog webLog) throws Throwable { long start System.currentTimeMillis(); MethodSignature signature (MethodSignature) pjp.getSignature(); String methodName signature.getDeclaringTypeName() . signature.getName(); log.info([{}] 开始调用{}, webLog.value(), methodName); // 参数序列化要过滤掉无法序列化的 Servlet 相关对象 String params toJson(ignoreServletArgs(pjp.getArgs())); log.info([{}] 请求参数{}, webLog.value(), params); Object result; try { result pjp.proceed(); log.info([{}] 返回结果{}, webLog.value(), toJson(result)); return result; } catch (Exception e) { log.error([{}] 调用异常{}, webLog.value(), e.getMessage(), e); throw e; } finally { long cost System.currentTimeMillis() - start; log.info([{}] 方法{}执行完毕耗时{}ms, webLog.value(), methodName, cost); } } private Object[] ignoreServletArgs(Object[] args) { if (args null || args.length 0) { return args; } return Arrays.stream(args) .filter(arg - !(arg instanceof ServletRequest) !(arg instanceof ServletResponse)) .toArray(); } private String toJson(Object obj) { if (obj null) { return ; } try { return MAPPER.writeValueAsString(obj); } catch (JsonProcessingException e) { return [序列化失败]; } } }注意几个细节第一参数序列化务必过滤掉 HttpServletRequest、HttpServletResponse、MultipartFile 这类无法 JSON 序列化的对象不然日志切面自己先炸了第二finally 里的耗时统计是“无论如何都会执行”的而 catch 里的日志只会在异常时触发这样即使目标方法抛异常耗时依然能记录下来第三catch 之后我选择重新 throw e保持异常语义不变调用方不会被蒙在鼓里。3.3 演示与日志效果验证写个简单的接口测一下RestController public class DemoController { WebLog(创建订单) PostMapping(/order) public Result createOrder(RequestBody OrderReq req) { return Result.success(订单创建成功订单号 req.getOrderNo()); } }启动 Spring Boot 后POST 一次 /order 接口控制台输出顺序非常清晰[创建订单] 开始调用com.example.DemoController.createOrder [创建订单] 请求参数[{orderNo:ORD20250601,amount:99.5}] [创建订单] 返回结果{success:true,message:订单创建成功订单号ORD20250601} [创建订单] 方法com.example.DemoController.createOrder执行完毕耗时12ms能看到四行日志正好对应该环绕通知的四段逻辑前置日志、参数日志、正常返回日志、最终耗时日志。这里有个值得注意的细节因为返回日志写在 try 里异常日志写在 catch 里而最终耗时日志写在 finally 里所以只有当目标方法正常返回时才会出现“返回结果”这行日志如果方法异常你看到的会是“调用异常”而不是“返回结果”。这种设计能让人一眼分辨出调用成功还是失败。3.4 扩展用环绕通知做接口限流同样的套路换个注解就能做出一个防止重复提交的限流切面。先定义注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface AccessLimit { /** 限流时间窗口单位秒 */ int seconds() default 3; }切面里结合 Redis 的 SETNX 原子命令实现Aspect Component RequiredArgsConstructor public class AccessLimitAspect { private final StringRedisTemplate redisTemplate; Around(annotation(accessLimit)) public Object handleAccessLimit(ProceedingJoinPoint pjp, AccessLimit accessLimit) throws Throwable { MethodSignature signature (MethodSignature) pjp.getSignature(); String methodName signature.getDeclaringTypeName() . signature.getName(); // key 建议带上参数摘要防止相同方法的重复调用示例简化处理 String key access:limit: methodName : requestParamsKey(pjp.getArgs()); Boolean canAccess redisTemplate.opsForValue().setIfAbsent( key, 1, Duration.ofSeconds(accessLimit.seconds())); if (Boolean.FALSE.equals(canAccess)) { throw new RuntimeException(操作过于频繁请稍后再试); } return pjp.proceed(); } private String requestParamsKey(Object[] args) { // 实际项目中用用户 ID 或设备 ID 更合理这里仅示意 return args.length 0 ? : String.valueOf(args[0].hashCode()); } }为什么这个场景非得用环绕通知因为“限流不通过时直接拒绝执行”这个动作本质上就是“不进入目标方法”的能力Before 只能眼睁睁看方法执行Around 可以一票否决。再看缓存场景也一样命中缓存时直接返回缓存值目标方法根本不会被执行这种控制力只有 Around 能给。4. 踩坑实录切面不生效、proceed 漏调用、多切面顺序4.1 切面不生效的排查地图切面不生效是 AOP 新手最常见的问题我总结一张快速排查表按命中率从高到低排问题现象根本原因排查方法方法被调用但切面日志一次都没打切面类没有被 Spring 扫描到比如漏了 Component检查切面类上是否有 Component 或被 ComponentScan 覆盖类注了 Aspect 但毫无作用非 Spring Boot 环境没加 EnableAspectJAutoProxySpring Boot 下极少见确认项目依赖 spring-boot-starter-aop检查启动类打印的切面 Beanexecution 表达式写半天匹配不上包路径、方法修饰符匹配规则写错用一个绝对简单的表达式先打点再逐步扩大范围接口直接调服务方法时切面生效了同类内部方法调用不生效内部调用没有走代理对象属于自调用问题拆分另一个 Bean 调用或用 AopContext.currentProxy()只有 public 方法匹配JDK 动态代理只能代理接口方法CGLIB 对非 public 方法也有诸多限制确保业务方法声明为 public别指望切面切 private 方法说个具体的真实案例。我同事在 Controller 上写了一个 Around(annotation(WebLog))结果请求进来切面一个日志都没打。第一反应是看有没有引依赖pom 是好的看切面类Aspect Component 都在。最后发现他把注解加到了 Service 类的实现方法上但接口类里没有加——他用的 JDK 动态代理目标对象是代理接口注解信息在实现类方法上切点表达式匹配不到。换 CGLIB 代理或者直接在接口方法上加注解就正常了。4.2 proceed() 漏调用的静默失败这是环绕通知最阴的坑。有次我优化一个审批流程在切面里加了一堆校验逻辑其中一个 if 分支判断“非管理员直接返回提示”我直接 return 了一个封装好的错误对象完全没调 proceed()。看起来一切正常但同一段逻辑如果是通过“多条件组合”控制的某个组合下所有分支都会绕过 proceed()——接口返回 200Body 是 null前端页面数据空白。没报错、没异常、日志也看不出问题但实际上目标方法压根没执行。避免这个坑的办法有三条环绕通知里的代码路径要简化最好只有“放行”和“不放行”两种出口放行出口统一调用 proceed()。如果做了不 invoke proceed() 的返回务必在日志里打印明确的提示例如“切面拦截未调用目标方法”否则排障时你会怀疑业务逻辑写错了。最后可以在测试里补一个“目标方法是否被调用”的验证比如注入一个 Mockito spy 检查方法有没有被触发。4.3 多个切面都会包到一起时的执行顺序一个方法上可能同时挂了日志切面、限流切面、权限切面、事务切面。Spring 如何决定谁先执行答案是 Order。用 Order(1) 标注的切面优先级最高会在最外层先执行数字越小的越靠外。可以用“洋葱模型”来理解方法调用从外层往里穿透第一层切面 A 的环绕逻辑先执行然后它调用 proceed()进入第二层切面 BB 再调用 proceed() 才真正进入目标方法。方法返回时顺序相反先走出 B再走出 A。所以Order(1) Aspect public class LogAspect { ... } Order(2) Aspect public class AccessLimitAspect { ... }执行顺序是日志切面前置 → 限流切面前置 → 目标方法 → 限流切面后置 → 日志切面后置。这里有个实践坑日志切面通常是最后收尾、耗时统计最准确的如果你把耗时统计放在最外层它会包含限流、缓存等所有内层切面的耗时。有时候你看到接口耗时高以为是业务方法慢实际是内层切面在 Redis 上阻塞了而这个时间被外层日志切面记录了下来。排查慢接口时要从最内层日志开始逐层看而不是只看最外层总耗时。4.4 内部自调用导致代理失效同一个类里方法 A 调用方法 BB 上挂了 Around但这个环绕通知不会生效。原因很简单Spring 注入给你的对象通常是代理对象但类内部用 this.methodB() 调用时this 指向的是原始对象完全绕过了代理层。我之前负责的一个订单模块就是这么翻车的。订单 Service 里的 createOrder 调用了同类的 sendNotify() 方法sendNotify() 上挂了事务切面和限流切面。上线后发现限流完全不生效因为走的是 this 调用。解决办法有三种把 sendNotify 拆到另一个 Service 里通过注入调用走代理。用 AopContext.currentProxy() 获取代理对象再调用但需要配置 exposeProxytrue。接受这个限制在方法注释里注明“禁止内部调用”。推荐第一条结构最清晰。顺便说一句事务切面的 Transactional 也有完全相同的自调用失效问题很多类似场景的表现是“方法没报错但事务没生效”实则是同一个根因。4.5 catch 异常后要不要重新抛出前面在标准骨架里我已经强调过要重新抛出这里再展开讲一个真实事故。有个支付回调接口挂了 Around 做异常日志开发在 catch 里只记录了日志没有 throw e。结果支付回调里有一段操作订单状态、扣库存的代码业务抛了异常后被吞掉外层框架收到的是“正常返回”于是回调重复通知又触发了下一次处理造成库存被扣两次。深挖一层事务和异常的关系也在这里Spring 的 Transactional 默认只在 RuntimeException 和 Error 时回滚。如果切面把受检异常吞掉并返回正常值事务管理器根本感知不到提交了本应该回滚的数据。所以我的建议是环绕通知里 catch 住异常绝大多数场景做两件事——记录日志、抛出异常。实在要用“吞异常”来容错必须明确知道自己在干什么并确保不会影响事务和调用方的错误感知。5. 环绕通知在真实项目中的应用思路5.1 缓存直通车命中缓存不执行方法用环绕通知做缓存是它能力的完美体现。业务方法本身不关心缓存逻辑只需要在方法上打一个注解Around(annotation(cacheable)) public Object handleCache(ProceedingJoinPoint pjp, Cacheable cacheable) throws Throwable { String cacheKey buildCacheKey(pjp); Object cached cache.get(cacheKey); if (cached ! null) { return cached; // 缓存命中目标方法根本不执行 } Object result pjp.proceed(); // 缓存未命中才执行目标方法 cache.put(cacheKey, result); return result; }注意“缓存命中目标方法根本不执行”这句话。这是 Around 独有优势别的通知做不到。如果目标方法是查询数据库的命中缓存时整个查询直接被跳过接口响应时间从几十毫秒降到个位数。这就是为什么我说环绕通知适合做“所有能提前打断调用链”的功能——限流、防重、缓存命中、熔断降级本质上都是同一个模式根据当前状态决定放行还是返回替代结果。5.2 方法级耗时统计从宏观监控到微观定位很多项目里能看到 Spring Boot Admin 一类的监控面板它们负责的是健康状态、堆内存、线程池这种进程级指标。但“某个订单接口为什么比别人慢”这类问题Admin 面板很难答上来——你需要的是方法级调用耗时这恰恰是环绕通知最擅长的领域。我在一个老项目里做过这样的方案给核心 Service 方法加 Timed 注解环绕通知统计执行耗时并把超过阈值的慢日志单独输出到一张表。后面排查线上问题时先看 Admin 上的进程指标确认 CPU、内存没问题再打开慢日志记录直接定位到是哪个 Service 方法耗时超过 500ms最后针对那个方法看 SQL 执行计划或者远程调用日志。这样整套链路就补全了宏观监控负责“知道系统病了”环绕通知的微观日志负责“找到哪个方法拖慢了”。这里有个细节统计耗时不能只统计成功路径异常路径也要统计。所以耗时统计放在 finally 里而不是 try 块末尾。否则一旦方法抛异常你就看不到这次异常调用花了多久而“异常调用耗时”恰恰是很多线上事故的关键线索。5.3 把环绕通知做成可复用的团队基石在多人协作项目里环绕通知最好的归宿是沉淀成一个公共模块比如叫aop-common里面统一放了日志切面、限流注解、耗时监控注解。业务团队接入时只要引依赖、在方法上加注解不需要理解内部实现。要求是切面代码要足够稳因为它是横切面任何一个细节问题都会被放大到所有业务方法上。上线前至少要通过完整的调用链测试确认正常路径和异常路径的日志输出都符合预期。6. 一点个人经验环顾这些年经手的项目真正让我对 Spring AOP 有体感的不是读懂概念的那一刻而是在真实系统里发现问题、定位问题、最终用切面把重复逻辑收口的过程。环绕通知确实是五种通知里最“重”的一个但它的重体现在控制力上而不是代码复杂度上。如果让我给刚接触 AOP 的人一条建议我会说别一上来就想一次搞定日志权限限流缓存的大而全切面先从一个环绕通知做起把 proceed() 的生命周期、参数序列化、异常处理的套路练熟再谈扩展。把一个通知写透胜过翻十篇教程。等你在项目里亲手把第一段复制粘贴了二十遍的代码收进切面看到所有 Controller 清爽下来的时候你大概就能明白 AOP 存在的价值了。最后再分享一个小技巧给自定义注解加一个 value 属性用来描述业务名称这会让你的日志在排障时容易辨认得多一件小事收益却很大。