
直接切入如果你已经在Spring Boot项目里用过AOP大概率最先接触的是Before、AfterReturning这类前置或后置通知。用着用着会发现它们拆开写确实简单但要拿到一次完整调用链路的上下文、要统一处理异常、要在方法执行前后共享一份数据就非常别扭。这时候就该上环绕通知Around了。这一篇我完整讲清楚环绕通知它和其他通知的关系、底层原理、实际项目中怎么封装才不踩坑以及我踩过之后回头梳理出来的一套设计习惯。内容偏实战适合已经在用Spring Boot做业务开发、想在项目里用AOP做日志、权限、性能监控、重试机制的同学。1. 为什么必须有环绕通知其他四种通知的致命短板1.1 四种基础通知的职责边界Spring AOP里一共有五种通知类型除了Around还有Before、AfterReturning、AfterThrowing、After。很多人最开始学的时候会觉得“既然前置通知和后置通知都有了还要环绕通知干什么”我举个具体场景你就明白。假设你要给一个下单接口加操作日志需要记录这么几个信息方法入参是什么方法返回值是什么方法执行耗时多少如果抛异常异常信息是什么用Before AfterReturning AfterThrowing三件套你也能抓数据但数据是割裂的Before里拿到入参存到一个成员变量或ThreadLocal里AfterReturning里拿到返回值再存一份AfterThrowing里拿异常又存一份。最后想去凑一份完整的日志时需要跨多个通知方法去拼装还得处理“前置通知执行了但后置通知没执行”这种状态分支。代码量翻倍不说数据传递全靠上下文容器并联调排错都很难受。更要命的是如果要在方法执行前判断“参数非法直接拦截”在Before里你能干这事但你没有“终止方法继续执行”的控制权。你只能抛异常让业务代码跟异常谷堆缠在一起。可如果你想优雅地返回一个默认值或错误码呢在Before里几乎做不到。1.2 环绕通知的控制权拦截、放行、改写一把抓Around是唯一一个能够完全包住目标方法执行过程的通知。它能代替前置、后置、异常、最终通知四者的组合而且拥有三项独有能力第一可以决定目标方法是否真正执行。参数校验不过直接返回错误结果不调用proceed()方法想要幂等拦截同样在proceed()之前就能挡住。第二可以篡改目标方法的返回值。无论原方法返回什么环绕通知都可以在拿到结果后做包装、脱敏、二次计算再返回给调用方。这在做接口统一响应包装时非常有用。第三可以在目标方法执行前后共享同一份上下文。一个方法体内前后逻辑天然共享局部变量不用再依赖外部容器传递数据。从实现模型上看环绕通知就是一个典型的装饰器模式它包裹住真实的方法调用调用方甚至感知不到这层装饰的存在。这也是AOP的核心价值——在不入侵业务代码的前提下包裹一层通用逻辑。所以在我的项目里日志记录、耗时统计、限流控制、权限校验这类通用横切逻辑我全部统一用Around做。其他四种通知基本只在我需要做极轻量的、单一维度的横切操作时才会用到。2. 环绕通知的底层原理ProceedingJoinPoint与代理机制2.1 核心参数ProceedingJoinPoint到底是什么Around通知方法的入参必须是ProceedingJoinPoint这是它与所有其他通知最明显的区别。其他通知拿的是JoinPoint只有环绕通知能拿到ProceedingJoinPoint因为ProceedingJoinPoint多了一个proceed()方法。很多初学者第一次看到proceed()时会问这个proceed()到底是怎么帮我执行原方法的其实拆开看就清楚了。ProceedingJoinPoint内部持有目标对象、目标方法、方法参数这三样核心信息。proceed()的作用就是通过反射、或者说通过代理链路把当前拦截到的目标方法真实地调用一次。你把它理解成“调原方法的开关”调用几次proceed()原方法就被执行几次一次都不调用原方法就不会被触发。这里有个非常重要的细节proceed()可以被调用多次。比如我做重试机制时第一次调用失败后可以在catch块里再调用一次proceed()实现方法级别的重试。这在其他通知类型里完全做不到。ProceedingJoinPoint常用的方法有getSignature()拿到方法签名从中获取方法名、类名、参数类型等信息getArgs()拿到当前请求的参数数组可以读取也可以修改getTarget()拿到目标对象实例注意是代理背后的真实对象proceed()执行目标方法proceed(Object[] args)以新的参数数组执行目标方法相当于可以动态篡改入参其中proceed(Object[] args)用途很广比如在AOP层对入参做统一加解密时解密后通过新的参数数组放行业务代码拿到的就是明文数据完全无感知。2.2 代理机制JDK动态代理与CGLIB的抉择Spring AOP底层依赖代理模式。一个类被AOP切中后Spring不会直接把这个类的对象交给你用而是生成一个代理对象。调用方实际调用的是代理对象的方法代理对象再沿着通知链依次处理后最终反射调用真实目标方法。Spring Boot 2.x默认使用CGLIB代理这和Spring Boot 1.x时代默认JDK动态代理不同。两者核心区别在于JDK动态代理只能代理接口生成一个实现该接口的匿名代理类CGLIB通过生成目标类的子类来实现代理不要求目标类实现接口这一差异在实际项目中会带来一个典型坑如果目标类不是接口实现类并且你的Spring版本里默认策略还是JDK动态代理AOP就会直接失效。Spring Boot 2.x之后默认开了CGLIB所以大多数场景下不会遇到这个问题但如果你是老项目升级或者手动构建代理必须注意检查proxyTargetClass配置。另外CGLIB代理方式下目标方法不能是final的目标类也最好别是final的。因为CGLIB靠继承子类来覆盖方法如果方法是final的子类无法覆盖通知自然也无法织入。这个在写切面时要时刻记得避免给被切方法加final修饰。2.3 通知链的调用顺序环形嵌套结构当多个切面同时命中同一个方法时会组成一条通知链。环绕通知的执行顺序不是单纯的“先来后到”而是嵌套结构切面A的Around开始 - 切面B的Around开始 - 目标方法执行 - 切面B的Around结束 - 切面A的Around结束这种模型很像洋葱圈或者一层套一层的俄罗斯套娃。每一个Around方法都是“进入”和“出口”之间的一个完整闭环。这个机制直接影响了我们对切面顺序的认知你在Around前半部分写的逻辑越靠外层切面越先执行后半部分写的逻辑越靠外层切面越后执行。实际项目中我使用Order注解显式控制切面顺序数字小的先执行。比如日志切面设为1权限切面设为2缓存切面设为3。没有特殊需求就别依赖默认顺序因为不同版本、不同切面定义方式下默认顺序可能完全不一样真出现问题时排查成本极高。3. 完整实战一个通用环绕通知模板的落地过程3.1 先明确需求清单在看代码之前先统一认识我们要写的这个环绕通知到底解决什么问题。我拿最通用的“接口日志记录与耗时统计”来演示把它拆成需求点记录被调用方法的类名、方法名、入参、返回结果、异常信息、耗时入参做脱敏处理不能把密码、身份证这类敏感字段直接打出来异常场景也要完整记录但异常要继续往上抛不能影响业务方原有的异常处理逻辑提供一个开关在配置中心动态控制日志切面的开启与关闭这些需求点很典型也几乎覆盖了环绕通知的核心能力。下面我按步骤落地。3.2 自定义注解与切面骨架第一步定义一个注解用来标记哪些方法需要被环绕通知管理。这样比直接写execution表达式精确得多也更好维护。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface OpLog { // 操作模块比如订单管理用户中心 String module() default ; // 操作类型比如insertupdatedeletequery String type() default ; }第二步定义切面类。这里我先给出一个完整的骨架把核心流程写在startLog()方法里。Aspect Component public class OpLogAspect { private static final Logger log LoggerFactory.getLogger(OpLogAspect.class); Around(annotation(operationLog)) public Object around(ProceedingJoinPoint joinPoint, OpLog operationLog) throws Throwable { // 前置记录开始时间、解析方法信息、打印入参 long start System.currentTimeMillis(); String methodInfo buildMethodInfo(joinPoint); Object[] args joinPoint.getArgs(); if (log.isInfoEnabled()) { log.info([OpLog] 开始 | {} | 入参: {}, methodInfo, LogMask.maskArgs(args)); } Object result null; Throwable throwable null; try { // 核心放行目标方法 result joinPoint.proceed(); return result; } catch (Throwable t) { throwable t; throw t; } finally { long cost System.currentTimeMillis() - start; if (throwable ! null) { log.error([OpLog] 异常 | {} | 异常信息: {} | 耗时: {}ms, methodInfo, throwable.getMessage(), cost); } else { log.info([OpLog] 结束 | {} | 返回: {} | 耗时: {}ms, methodInfo, LogMask.maskResult(result), cost); } } } private String buildMethodInfo(ProceedingJoinPoint joinPoint) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); String className signature.getDeclaringTypeName(); String methodName signature.getName(); return className # methodName; } }注意两个关键点第一我用了try-finally而不是try-catch-finally并且在catch里将异常重新抛出。这样做的目的是既拿到异常信息用于记录又不改变原方法的异常传播路径。很多初学者会在catch里打印完日志后忘记重新抛出结果上层业务完全感知不到异常数据还能正常提交这在业务系统中是非常严重的事故。第二我特意把result和throwable两个变量定义在try外部是为了在finally里能够同时判断是否发生异常。如果你在catch里直接打印日志那正常路径的日志就得在try块最后单独打一份代码就冗余了。3.3 入参脱敏一个容易被忽略的细节日志切面的最大隐患是“把不该打的打出来了”比如用户密码、身份证、手机号明文。以前我们做过一次线上复盘就是因为日志里打印了完整密码然后日志文件又被泄露出去导致用户隐私事件。所以我在日志切面里强制加了脱敏处理。public class LogMask { public static String maskArgs(Object[] args) { if (args null || args.length 0) { return []; } ListString maskedList new ArrayList(); for (Object arg : args) { maskedList.add(maskObject(arg)); } return maskedList.toString(); } private static String maskObject(Object obj) { if (obj null) { return null; } // 如果对象包含敏感字段统一转JSON后按key脱敏 try { String json JSON.toJSONString(obj); json json.replaceAll((\password\\\s*:\\s*)\[^\]*\, $1\******\); json json.replaceAll((\idCard\\\s*:\\s*)\[^\]*\, $1\******\); return json; } catch (Exception e) { return obj.toString(); } } }这里的正则替换方式适合演示真实项目里更推荐写一个通用的脱敏工具类基于Jackson的ValueFilter或者自定义序列化器来实现。对于返回结果的脱敏同理但这里有个实战建议返回结果如果是一个大型分页对象全部序列化会导致日志体积非常大建议只截取前几百个字符后面用省略号表示。3.4 配置开关让切面可以随时下线切面挂在核心业务上时最怕什么最怕切面自身逻辑出问题比如序列化超时、日志打印卡IO导致整个接口变慢甚至报错。所以线上规范里我会要求日志切面必须有开关可以随时通过配置中心一键下线。Component ConfigurationProperties(prefix oplog) public class OpLogConfig { // 是否开启操作日志切面默认开启 private boolean enabled true; // getter、setter省略 }然后在切面方法开头加一个判断Around(annotation(operationLog)) public Object around(ProceedingJoinPoint joinPoint, OpLog operationLog) throws Throwable { if (!opLogConfig.isEnabled()) { return joinPoint.proceed(); } // 原有逻辑... }这个判断必须放在切面方法的第一行而且开关关闭时直接放行目标方法不做任何额外处理。这样即使后续有人往切面里塞了很重的逻辑只要开关关掉整条切面链路就等于没存在过。这个点在技术上很简单但极少有人一开始就做到等你真被线上日志切面拖垮过一次就长记性了。4. 环绕通知的高阶玩法重试、限流、缓存穿透防护4.1 基于proceed()多次调用的方法重试上一节讲了日志切面这一节我把环绕通知更硬核的用法展开。之所以环绕通知能实现重试核心就在proceed()可以被调用多次。举个例子假设我们的支付结果查询接口偶尔因为网络抖动会抛出通讯异常业务上允许重试两次。我们用环绕通知来实现一个通用的重试切面Aspect Component public class RetryAspect { Around(annotation(retry)) public Object retry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable { int maxAttempts retry.attempts(); Throwable lastThrowable null; for (int i 0; i maxAttempts; i) { try { // 执行目标方法如果成功则直接返回不进入下一次循环 return joinPoint.proceed(); } catch (Throwable t) { lastThrowable t; // 判断是否应该重试按条件过滤比如只对网络异常重试 if (!shouldRetry(t, retry)) { throw t; } // 等待间隔避免快速连续重试加重下游压力 Thread.sleep(retry.intervalMillis()); } } // 达到最大重试次数仍然失败抛出最后一次异常 throw lastThrowable; } private boolean shouldRetry(Throwable t, Retry retry) { for (Class? extends Throwable clazz : retry.retryFor()) { if (clazz.isAssignableFrom(t.getClass())) { return true; } } return false; } }这样定义的好处是重试策略跟业务解耦你只需要在需要重试的方法上打一个Retry注解业务代码里完全不用写循环。我在项目里还加了一个时间衰减策略第一次重试间隔100ms第二次200ms避免重试流量瞬间洪峰打到下游。但这里必须提醒一句重试切面只能解决瞬时异常对于幂等性要求极高的写操作要谨慎。如果目标方法已经写入了数据但在返回前抛异常重试就可能导致重复插入。写操作要用重试必须在业务表里带上幂等键否则宁可只对查询或对通讯类操作做重试。4.2 基于方法级参数的简单限流限流在Spring Boot里通常会引入Guava的RateLimiter或RedisLua脚本但如果你只想对极少数热点方法做一层轻量保护用环绕通知也能快速实现。我先给出一个基于Guava RateLimiter演示版Component public class RateLimitAspect { private final MapString, RateLimiter limiterMap new ConcurrentHashMap(); Around(annotation(rateLimit)) public Object limit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { RateLimiter limiter limiterMap.computeIfAbsent(rateLimit.key(), k - RateLimiter.create(rateLimit.qps())); // 尝试获取令牌 boolean acquired limiter.tryAcquire(rateLimit.timeout(), TimeUnit.MILLISECONDS); if (!acquired) { // 限流触发返回降级结果 throw new RateLimitException(当前请求过多请稍后再试); } return joinPoint.proceed(); } }这里我用了ConcurrentHashMap缓存每个方法对应的RateLimiter对象避免每次请求都重新构建一个令牌桶。如果项目已经接了Redis推荐用RedisLua做分布式限流因为单机RateLimiter在微服务多实例部署时会导致整体限流失效每台机器的阈值叠加后就不是你配置的QPS了。4.3 缓存穿透的场景环绕通知里做空值缓存缓存切面也经常用Around实现。但有一类细节值得单独讲就是缓存空值。假设一个用户详情接口先查缓存缓存没有就查数据库查不到则返回null。如果直接对null做缓存很多缓存框架默认不支持缓存空值导致每次请求都会穿透到数据库数据库压力全被打满。在环绕通知里你可以这样做拿到proceed()的结果后如果发现结果为空且允许缓存空值就手动构造一个空对象占位并设置较短过期时间。这样就可以有效缓存穿透。Object result joinPoint.proceed(); if (result null cacheNull) { // 设置短过期时间的占位空值比如 5 分钟 cacheService.set(cacheKey, NULL_PLACEHOLDER, 300); } return result;这种细节在面试里经常被拿出来问但在实际项目里它往往比高深的技术更能救命。缓存穿透、击穿、雪崩三重问题里穿透是最好解决也最容易被忽略的靠一个短时空值就能挡掉大量无效DB查询。5. 环绕通知高频踩坑清单每一个都是线上教训5.1 自调用导致AOP失效这是新手最容易踩的第一个大坑我来演示一个典型的例子Service public class OrderService { public void createOrder(Order order) { // 业务逻辑... sendMessage(order.getId()); } OpLog(module 消息通知) public void sendMessage(Long orderId) { // 业务逻辑... } }当你从createOrder()方法内部直接调用this.sendMessage()时OpLog切面不会生效。因为这里是普通方法调用走的是真实对象引用而不是代理对象。只有从外部注入OrderService时拿到的才是代理对象调用代理对象的方法时AOP才会拦截。解决办法有几种第一种把需要被切的逻辑拆到另一个Service里注入后调用。这是最推荐的做法结构清晰代理能正常生效。第二种在类内部注入自身代理比如通过ObjectProvider或Lazy注入然后通过代理对象调用自身方法。这种方式能够达到目的但代码可读性差不推荐在多人协作项目里铺开。第三种使用AopContext.currentProxy()前提是配置exposeProxytrue。这种方式效率不高而且依赖上下文容易出问题。5.2 在环绕通知里吞掉异常导致事务失效这个坑在校招同学写的代码里出现次数非常高而且后果很严重。很多人在环绕通知里写了try-catch后把异常吞掉或者只记录日志不抛出然后继续返回一个默认结果。对于GetMapping接口来说这可能看起来没啥问题但一旦目标方法上标了Transactional这个坑就会破坏事务。Spring事务的本质是事务管理器拦截方法方法抛出异常时触发回滚。如果你的环绕通知在事务通知的外层把异常吞掉了事务管理器根本感知不到异常自然就不会回滚。结果就是数据已经写了上层还觉得调用成功了线上数据出现“诡异的部分写入”状态。我的建议是环绕通知永远不要吞异常。除非你有非常明确的降级语义并且业务方能接受降级结果否则异常一定要原样抛出。如果你确实需要既记录异常又继续向上抛就用我前面日志切面里的写法catch后重新throw。5.3 切点表达式写得太宽误伤无关方法Around配上execution(* com.example...(..))这种粗粒度表达式会拦截到所有类所有方法包括Spring内部的一些回调方法也会被卷入常常导致意想不到的问题。比如有些对象在Spring容器初始化阶段被代理切面里又依赖了更高优先级的Bean就可能触发循环依赖或初始化顺序异常。我的习惯是优先用自定义注解标记目标方法比如OpLog、Retry而不是用execution表达式一把梭。自定义注解的好处有三个第一精确控制范围避免误伤第二同一个注解可以携带参数让切面逻辑更通用第三代码仓库里搜注解就能知道哪些接口被切了可维护性远高于纯表达式。如果你想用execution表达式控制范围尽量按方法级签名来写并且严格控制包路径定期做Review。真实线上出现过一次事故有人把execution表达式写成com.example...(..) 拦截了所有Service方法导致日志切面为每次查询都做了一次JSON序列化和一次性全量日志输出整个接口RT从50ms涨到2秒。5.4 环绕通知里的性能开销问题环绕通知虽然好用但也不是免费的。每一次代理调用都会增加栈深度、反射调用开销以及切面自身代码的执行消耗。如果切面里还有大对象序列化、远程调用、加锁操作那对主链路的影响会非常明显。一个经验值日志类切面里不要同步打印太长的内容如果确实要打全量日志建议用异步日志框架或直接发到消息队列。入参和返回值的JSON序列化只在log.isInfoEnabled()为true时才执行避免在生产环境日志级别调整后白白付出序列化成本。还有切面里的代码尽量保持轻量任何缓存、限流、鉴权逻辑都提前评估耗时超过毫秒级就要考虑是否值得放在这条链路上。我见过一个真实案例某团队在环绕通知里加了反作弊查询每次调用都查一次远程规则库接口平均耗时增加了900ms最后上线第二天就被迫下线。核心问题不是切面方案不对而是把一个重操作塞进了通用拦截链路导致所有被切接口都遭殃。5.5 多切面协作时的顺序混乱当多个环绕通知同时作用于一个方法时顺序控制是多切面开发中最容易失控的地方。在没有Order注解的情况下切面顺序完全取决于Spring内部对Bean的加载顺序显得相当随机。我建议在一个项目里建立以下规范所有切面类都必须指定Order按业务优先级从高到低排序性能监控最高尽量靠外 日志 幂等/防重 缓存 事务 异常转换最靠内编号统一从0开始每5一档预留扩展位为什么性能监控要放在最外层因为如果要统计整条AOP链路上的完整耗时性能监控切面必须被第一个进入、最后一个退出。越外层的切面记录的耗时越接近API整体耗时。如果你把性能监控放在最内层它统计出的就只是目标方法本身的时间链路开销全丢了。5.6 加解密、脱敏等AOP操作与参数修改的边界最后提一个很实际的问题环绕通知对参数和返回值的修改作用范围到底有多大通过proceed(args)修改参数时修改的是传给被代理方法的参数值这会影响调用链上的下游逻辑。但如果目标方法内部把参数对象引用赋值给了其他局部变量你再改同一个对象引用指向的属性是能同步生效的。所以这里有个重要区分修改参数对象的内容是生效的直接替换整个参数对象则需要通过proceed(newArgs)传入。返回值同理。你可以在环绕通知里对返回值做包装、脱敏、二次处理。但如果目标方法返回值是final的或者基础类型只能通过重新构造新值返回。切面的处理结果最终会替代原返回值传给上层调用方所以在做返回值包装时一定注意接口契约比如原方法返回null表示“未找到”你的切面就不该擅自改成一个空对象否则上层判空逻辑会直接失效。6. 从“会用Around”到“写好多层切面”的思路升级我看了很多项目里的AOP代码最大的问题不是不会用Around而是没有把切面当成一个独立的“中间件”来设计。环绕通知的写法本身不难难的是把切面之间的层次感设计出来、把每个切面的职责边界划定清楚。好的实践应该是每个切面只做一件事。日志切面只负责记录不掺和权限判断权限切面只负责校验不顺手改业务返回值缓存切面只负责存取不把缓存空值时长写死。如果两个切面都要改动目标方法的返回值你需要非常明确它们的前后顺序以及各自改动的范围否则日志里打印的可能就是权限切面包装过的结果而不是目标方法的原始返回。我在做团队Code Review时会特别关注环绕通知里的代码规模。一个Around方法超过50行通常意味着职责过重请拆开。一句简单的原则环绕通知包裹的是目标方法但它本身也是一段需要能被读懂的代码。当别人打开你的切面类时他应该能在10秒内说出这个切面做了什么、会影响到哪些方法、出问题时怎么关掉它。Spring AOP是个好工具但它只是众多横切手段的一种。环绕通知能搞定的事往往也意味着你正在往业务代码里塞一些通用的逻辑。如果你的项目里已经有了这种通用切面建议给它们配上完整的开关和监控指标有开关才有兜底方案有指标才能提前发现异常增长。这一篇把环绕通知从原理到实战到踩坑都梳理了一遍希望对正在做Spring Boot项目的同学有帮助。如果你在做AOP方案时遇到过更隐蔽的问题欢迎在评论区交流。