尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Spring AOP @Around深探:从核心语法到实战踩坑全记录
做了几年Java后端Spring AOP里的Around是我打交道最多的一个注解也踩过不少它埋的坑。它和Before、After那些只会“旁观看一眼”的通知不一样Around能把整个方法执行过程从头到尾包裹起来决定目标方法要不要跑、参数能不能改、返回值要不要换、异常要不要吞。性能监控、权限校验、缓存处理、重试降级、操作日志这些场景基本都能用它一套解决。这篇文章不打算从头背概念而是把我实际项目中反复用到的代码模板、切点写法、ProceedingJoinPoint的细节以及那几个特别容易踩空的深层问题一次讲清楚。适合已经会用Spring但还没系统掌握AOP特性、或者正在为大量重复的日志和校验逻辑头疼的后端同学。1. AOP的必要性与Around的独特地位1.1 业务代码里为什么总有“横切逻辑”写企业应用时间长了你会发现很多方法的核心业务只有几行但前后左右被日志、计时、权限判断、参数校验围得严严实实。以订单服务为例真正下单的流程可能就三步查库存、扣库存、保存订单。但实际代码里要记录谁在什么时候下了单、操作耗时多少、用户是否还有下单权限、入参是否合法这些逻辑几乎出现在每一个类似方法中而且跟“订单”本身没有任何关系。这种跨越多个模块、反复出现在同类方法里的逻辑习惯上叫“横切关注点”。在没有AOP的年代最原始的做法就是在每个方法里复制粘贴同一段代码等改一个需求时才发现有几十个方法要同步修改。后来有人用装饰器模式、模板方法模式去收敛但都不彻底装饰器要改调用链模板方法要改继承结构侵入性都太强。Spring AOP的核心价值就是把这些横切逻辑从业务方法里抽出来放到独立切面中由框架在运行时把逻辑“织入”到目标方法前后。业务代码保持干净横切逻辑可以复用改动时只需要动切面一处。业界的说法叫“关注点分离”说白了就是让写订单的人专心写订单让打日志的人专心打日志。1.2 五种通知类型对比Around到底强在哪Spring AOP一共定义了五种通知通知类型执行时机能否中断原方法Before目标方法执行前不能只能做前置准备After目标方法执行后无论正常返回还是抛异常不能只能做收尾AfterReturning目标方法正常返回后不能只能读取返回值AfterThrowing目标方法抛出异常后不能只能处理异常Around目标方法前后整个过程由开发者手动控制能可完全接管调用从表格可以看到前四种通知各自负责一个“点”组合起来才能覆盖完整流程。而Around本身就等于把前四种的能力合在一起还多了一个关键控制权它可以决定是否调用proceed()。这句话的意思很实际。Around方法的参数是ProceedingJoinPoint目标方法的真正执行发生在你调用pjp.proceed()的那一行。你可以在这一行之前模拟Before在这一行之后模拟AfterReturning用try-catch捕获异常模拟AfterThrowing用finally块模拟After。但你还能做前四种做不到的事情缓存命中时直接返回不让proceed()执行第一次调用抛出特定异常后自动重试甚至可以把原方法的返回值包装成另一个数据结构返回给调用方。我经常跟同事说Around是一把“手术刀”前四种通知是“绷带”。如果你只是想记录日志用AfterReturning就够了但只要涉及“拦截后重新决定流程”就别无选择只能上Around。1.3 从代理模式看Around的执行机制Around之所以能接管方法执行本质是Spring AOP的动态代理机制在起作用。目标Bean交给Spring容器管理后如果满足切面匹配条件容器最终放进调用方手里的并不是原始Bean而是一个代理对象。这个代理对象在目标方法调用链上织入了切面逻辑Around就是整条调用链上最核心的一环。具体实现分两种目标类实现了接口默认使用JDK动态代理目标类没有实现接口则使用CGLIB生成了类。无论哪种方式外部调用bean.method()时真正执行顺序是先进入代理的拦截逻辑触发Around代理内再调用目标类原始方法。这也是为什么理解代理模型对排查“切面不生效”问题至关重要——很多坑的根源都在于“调用的到底是不是代理对象”。一个实际项目中的案例有个服务需要给所有暴露给前端的方法做统一耗时上报包括成功耗时和异常耗时。有人提议用Filter但Filter拿不到Service层的方法签名和具体参数在Controller层写又太零散每个接口都要手动包一层。最后方案是在Service实现层加Around切面这样既能拿到精确的Method信息又能按异常类型分别记录前端接口层一行改动都没有。这个案例基本代表了Around最典型的用法跨接口统一增强不改业务代码。2. 核心语法与一个可以直接落地的日志切面2.1 最小可用切面每一行都讲清楚先给一个能跑的日志切面注解、切点、ProceedingJoinPoint全部包含Aspect Component public class LogAspect { Around(execution(* com.example.service.*.*(..))) public Object logAround(ProceedingJoinPoint pjp) throws Throwable { String methodName pjp.getSignature().getName(); long start System.currentTimeMillis(); try { Object result pjp.proceed(); return result; } finally { long cost System.currentTimeMillis() - start; System.out.println(methodName 耗时 cost ms); } } }逐行拆开看Aspect声明这个类是一个切面。这是一个标记注解只告诉Spring这个类里有通知定义和切点定义不参与实际拦截。Component把切面类注册为Spring容器管理的Bean。没有这一步Aspect只是个空壳不会被装配到代理链路中。Around(...)在Around注解里写切点表达式表示这个环绕通知会作用的范围。这里匹配了com.example.service包下所有类的所有方法参数不限。ProceedingJoinPoint pjp环绕通知特有的入参。它继承了JoinPoint额外多了proceed()方法是调用目标方法的唯一入口。pjp.proceed()执行目标方法并拿到返回值。这里必须调用否则目标方法根本不会执行调用方拿到的是一个null或者干脆被拦截掉。finally块不管目标方法正常返回还是抛异常都能记录耗时。这是日志切面的一个通用习惯能确保异常路径的数据也不会漏。这个切面虽然短但很多团队的第一个日志切面就是从这个模板演化来的。实际生产环境里会把System.out.println换成日志框架的log.info再加上方法参数、调用来源、耗时阈值等维度。2.2 ProceedingJoinPoint的常用方法环绕通知能玩出的花样大部分都来自ProceedingJoinPoint提供的方法。proceed()无参版本按原样执行目标方法。日常多数情况用这个。proceed(Object[] args)替换参数执行目标方法。可以在执行前对入参做二次修正例如把空字符串参数修正为null、给分页参数设置默认值。注意传入数组的长度和类型要和原方法参数列表匹配否则运行时直接报错。getArgs()拿到目标方法的参数数组。适合做日志记录、参数校验、缓存key拼装。getSignature()拿到方法签名从中可以提取方法名、返回类型、声明类等信息。在做通用切面时这些信息往往要拼进日志或缓存key。getTarget()拿到被代理的目标对象。极少直接使用但在某些特殊场景需要反射调用目标类自有方法时会用到。getThis()拿到代理对象本身。前面提过AopContext.currentProxy()其实本质跟这个相关。我从实际经验得出的建议在一个切面里做的操作不要太多。很多人喜欢在Around里同时塞入参数序列化、耗时统计、权限校验、异常转换最后切面本身变成了一个超长方法可读性急剧下降出了问题还很难定位。正确的做法是每种横切逻辑一个切面必要时通过Order控制顺序互相之间不要耦合。2.3 切点表达式写法整理Around里写的表达式决定了这个切面管哪些方法写得太宽会误伤大量无关方法写得太窄又可能漏掉目标。下面是实际用得最多的几种// 1. 按类和方法匹配com.example.service包下所有类的所有方法 execution(* com.example.service.*.*(..)) // 2. 包含子包com.example.service及其子包的所有方法 execution(* com.example.service..*.*(..)) // 3. 限定返回类型和参数只匹配void且无参数的方法 execution(void com.example.service.*.*()) // 4. 按Bean名称匹配只匹配名为userService的Bean bean(userService) // 5. 按注解匹配只匹配带指定注解的方法 annotation(com.example.annotation.MyLog) // 6. 按包内类匹配com.example.controller包下所有Bean的所有方法 within(com.example.controller.*)从项目维护角度我最推荐的是第5种方式自定义一个方法注解标记哪些方法需要增强然后在Around里通过annotation来匹配。这样做有三个好处控制粒度精准不会误伤同包内不需要增强的方法给后续流量切换、按方法关闭日志留了开关读代码时看到注解就知道这个方法有切面逻辑维护成本低。如果注解同时需要支持类级别和方法级别annotation匹配方法注解within匹配类注解。还可以在切面方法入参上直接声明注解参数例如Around(annotation(myLog)) public Object around(ProceedingJoinPoint pjp, MyLog myLog) { // 直接使用myLog注解里的属性比如myLog.value()作为日志操作描述 }这种写法省掉了反射解析代码更干净也更快。3. 实战场景与完整案例拆解3.1 接口耗时监控与慢请求告警耗时监控大概是Around用得最多的场景。一个生产环境可用的耗时切面核心逻辑就是记录调用前后时间差超过阈值打告警日志异常时单独记录。Aspect Component public class CostTimeAspect { private static final Logger log LoggerFactory.getLogger(CostTimeAspect.class); Around(annotation(com.example.annotation.CostTime)) public Object recordCostTime(ProceedingJoinPoint pjp) throws Throwable { long start System.currentTimeMillis(); String methodName pjp.getSignature().toShortString(); try { return pjp.proceed(); } finally { long cost System.currentTimeMillis() - start; if (cost 3000) { log.warn(slow method: {} cost {}ms, methodName, cost); } else { log.info(method: {} cost {}ms, methodName, cost); } } } }这里有几个细节值得注意第一用System.currentTimeMillis()做耗时统计够用但如果方法本身执行极快、精度要求高可以换成System.nanoTime()本质区别不明显不必为了“理论精度”牺牲可读性。第二日志一定放在finally里。如果只放在try块末尾目标方法抛异常时耗时记录就丢了慢请求告警会漏报这是新手最容易犯的错。第三切点用了自定义注解CostTime而不是execution(* com.example..*.*(..))这种大范围匹配。直接按包扫会把框架内部调用、定时任务、初始化方法全部统计进去日志量爆炸还容易统计出一些莫名其妙的慢调用。3.2 敏感操作权限校验与统一拦截权限校验也经常用Around实现。举个例子某些后台接口只允许指定角色操作如果按传统写法每个方法里都要写一遍“当前用户是否ADMIN”的判断代码重复度很高。用切面可以把它统一起来。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String value(); } Aspect Component public class PermissionAspect { Around(annotation(requireRole)) public Object checkPermission(ProceedingJoinPoint pjp, RequireRole requireRole) { String role requireRole.value(); if (!ADMIN.equals(CurrentUserHolder.get().getRole())) { throw new BusinessException(无权限执行该操作); } return pjp.proceed(); } }注意这里有两种实现风格。一种是在切面里直接抛异常阻止后续执行另一种是返回一个默认结果比如null或空对象。我建议权限校验、非法参数这类“必须阻断”的逻辑直接抛业务异常让调用方能明确感知失败而对于“有就能做没有就跳过”的优化型逻辑才考虑返回默认值。还有一个容易被忽略的顺序问题如果同一方法上既有权限切面又有事务切面权限判断应该放在最外层。否则一个没有权限的用户已经进入了事务方法等切面在proceed()之前才抛出异常虽然事务也会回滚但一些前置资源可能已经消耗了比如数据库连接已经获取整体不经济。通过Order可以调整切面优先级权限校验这类轻量逻辑越靠前越好。3.3 缓存处理命中缓存时直接不执行方法Around的另一个高价值应用场景是方法级缓存。传统缓存的写法是在业务代码里先查缓存、再写缓存业务逻辑被缓存穿插得很难看。用切面可以做到在方法执行前查缓存命中则直接返回未命中才执行方法并把结果写入缓存。Aspect Component public class CacheAspect { Around(annotation(cacheable)) public Object aroundCache(ProceedingJoinPoint pjp, Cacheable cacheable) throws Throwable { String cacheKey buildKey(pjp, cacheable.key()); Object cached cacheManager.get(cacheKey); if (cached ! null) { return cached; } Object result pjp.proceed(); cacheManager.put(cacheKey, result, cacheable.expire()); return result; } }这个方法级缓存切面看起来很爽但使用门槛很高。首先要确保被缓存的方法是幂等的换句话说用相同参数调用得到的结果必须可预期不会因为调用次数不同而改变结果。其次返回值的序列化方式要和缓存中间件匹配如果直接存对象而缓存底层是Redis的JSON序列化取出来就是一个字符串而不是原本的对象类型。我在实际项目中见过有人在这个切面里直接依赖了某个RedisTemplate实现结果缓存抽象层换掉后整片切面跟着重写正确做法是通过自定义的缓存接口做中转。另外缓存切面要非常注意“穿透”问题。如果目标方法返回null缓存里存null还是存一个特殊标记决定了下一次调用是否还会打到数据库上。一般做法是只缓存非null结果或者用一个空对象占位避免每次都扎进方法里。3.4 重试机制捕捉异常后再次调用proceed重试是Around另一个不可替代的场景。微服务架构下远程调用经常偶发超时如果因为一次网络抖动就返回失败对用户体验影响很大。常见做法是在Around里循环调用proceed()捕获特定异常后重新执行。Around(annotation(retry)) public Object retry(ProceedingJoinPoint pjp, Retry retry) throws Throwable { int maxAttempts retry.maxAttempts(); int attempts 0; Throwable lastException null; while (attempts maxAttempts) { attempts; try { return pjp.proceed(); } catch (Exception e) { lastException e; } } throw lastException null ? new RuntimeException(retry failed) : lastException; }重试切面的一个严重陷阱在于事务边界。如果被重试的方法本身开启事务事务切面与重试切面的相对顺序决定了每次重试是否真的生效。假设事务切面在内层重试切面在外层第一次proceed()异常后事务已经回滚第二次proceed()开启新事务重试才有意义。如果反过来事务切面把整个重试循环包在一个事务里第一次异常整个事务就标记为rollback-only重试几次都是无效操作最后仍然整体回滚。所以重试逻辑一定要放在事务边界外层同时要仔细评估重试的副作用。一次扣减库存的操作不能简单重试因为第一次可能已经扣成功了只是返回结果时网络异常重试会造成重复扣减。这种场景需要幂等键配合而不是无脑重试。4. 高频踩坑与问题排查实录4.1 同一个类内部方法调用导致切面失效这是Around被问得最多的一个问题没有之一。看这段代码Service public class OrderService { Autowired private StockService stockService; public void createOrder(OrderDTO dto) { // 业务逻辑 updateStock(dto); // 直接调用同类方法 } RequireRole(ADMIN) public void updateStock(OrderDTO dto) { stockService.deduct(dto); } }外部调用orderService.createOrder(dto)时createOrder进入的是代理对象方法内部执行到updateStock(dto)时走的是this.updateStock()也就是原始对象方法根本不是代理对象。RequireRole切面对这次内部调用完全不可见权限校验直接被跳过。整个执行链路里只有最外层入口经过了代理内部调用全部是“裸奔”的。解决办法有三种把updateStock抽到另一个Service注入后从外部Bean调用走代理最推荐。在OrderService里注入自身实例OrderService self内部调用改成self.updateStock(dto)强制走代理。开启exposeProxy并通过AopContext.currentProxy()拿到当前代理对象再调用代码侵入性更小但有性能开销且从线程上下文中取代理容易出问题。我强烈推荐第一种不只是为了让切面生效更是为了让代码结构清晰。一个方法如果既被外部Controller调用又被自己内部当工具方法调用那它本身就处在一个模糊的边界上切面规则很难定清楚。抽独立Service之后这个方法的职责边界自然就明朗了。4.2 返回值类型错配void、基本类型和泛型列表Around方法签名都有返回值Object不管目标方法返回什么切面都要原样把proceed()的返回值传出去。有几个边界情况特别容易出问题。如果目标方法返回voidproceed()返回null切面方法返回null是没问题的调用方本来就不接收返回值。但如果你写了一个通用缓存切面被缓存的方法恰好返回voidproceed()得到的null会被当缓存值存进去下一次所有调用都命中缓存方法体一次都不执行这种错误极其隐蔽因为代码不报错。如果目标方法返回基本类型int目标方法真正返回后调用方拿到的实际是包装类型Integer切面返回Object是没问题的。但如果你在切面里做了“未命中缓存就返回默认值”的逻辑返回了一个null调用方自动拆箱时直接抛NullPointerException。这种错误通常不会在切面里暴露而是出现在业务代码的某个字段赋值处排查起来特别费劲。泛型返回值同样要小心。目标方法声明返回ListUser切面里拿到的是Object直接返回给调用方没有问题因为运行时类型信息还是ArrayList。但如果你在切面里对返回值做了类型转换比如想通过反射调用某个方法由于泛型擦除拿不到User的Class反射参数就搞不定。实际经验是切面里尽量不碰返回值内部的反射操作保持返回值为原始引用。4.3 异常处理的两个极端吞异常与包装异常Around里对异常的处理有两种常见错误都会引发特别难查的问题。第一种是吞掉异常。有些缓存切面或日志切面为了“保证主流程不被琐碎逻辑影响”会把proceed()抛出的异常捕获后只打日志然后返回一个null。这会导致原本应该失败的调用变成“成功返回null”业务方拿到的结果不可靠后续数据错乱会一路传下去而且日志里没有任何异常抛出记录连排查线索都断了。如果确实需要降级返回默认值一定要把默认值选得足够安全并且打一条明确的error日志。第二种是重新包装异常时不考虑事务回滚规则。Spring默认事务回滚规则是RuntimeException和Error才回滚检查异常默认不回滚。假如切面里把原始异常包成了一个自定义Exception检查异常再抛出事务管理器认为这是可恢复情况事务不会回滚数据库里已经执行的写操作全部保留数据就错了。正确做法是切面捕获异常后如果业务要求回滚直接抛出原始异常或者抛出一个RuntimeException子类。还有一个细节在Around里不要盲目打印异常堆栈后“原样再抛出一次”如果上层框架还会打印一次日志里会出现重复堆栈排查时反而分不清哪一段是原始错误。4.4 切面顺序与性能误用很多人以为Around比Before加After性能更好真相恰恰相反。Around需要在代理链上构造完整的方法调用上下文还要在切面方法里维护状态本身工作量就更大。如果在切面里再做参数序列化、正则匹配、大量日志输出性能反而会拖慢业务方法几十毫秒。对大部分接口来说单次几十毫秒不重要但高并发下放大到整个集群成本就非常可观。切面顺序是另一个高频盲区。多个切面作用于同一个方法时执行顺序默认不固定跟Spring版本和Bean加载顺序都有关系。要想让顺序可控必须在切面类上加Order注解数值越小越早执行。想象一下项目里有日志切面、权限切面、缓存切面、事务切面四个同时作用于一个方法预期顺序通常是权限切面在最前缓存切面次之日志切面包住整个流程事务切面贴近目标方法。如果顺序错乱可能出现权限判断在事务开启之后、缓存在权限校验之前用户体验完全不同。我习惯的实践是在项目的aspect包下统一管理切面类每个切面类都在类注释里写清楚它的职责和Order数值例如“权限校验-1缓存-0日志-10重试-15”。这样团队其他人接手的成本会低很多不会因为找不到某个切面而抓瞎。最后再说一条实际体会。每次设计Around时我都会在心里过一遍三连问这个逻辑适合放进切面吗如果放进切面会不会出现多人排查时看不见它的问题有没有更简单的替代方案有一次为了给某个邮件发送方法加重试我把重试逻辑做成了切面结果三个月后同事排查邮件重复发送的问题翻遍业务代码找不到重试入口最后才意识到逻辑藏在切面里。这种“隐式副作用”非常危险。后来我养成了习惯所有会改变外部行为或影响数据结果的切面逻辑比如重试、缓存、幂等控制必须在切面类顶部和方法注解上留下非常明确的注释最好配上开关和监控指标。用Around是在给项目做减法但一定要让这个减法的过程可观察、可维护这一点比写出一段优雅的切面代码更重要。
RELATED

相关推荐

SMT AOI监测设备选型指南:从市场报告到产线工艺数据的完整链路

SMT AOI监测设备选型指南:从市场报告到产线工艺数据的完整链路

简介:这份报告系统梳理2021-2027年全球与中国SMT AOI监测设备市场的现状与未来趋势,覆盖产能、产量、销量、销售额、价格及主要厂商市场份额等核心指标,适合电子制造、半导体封装、投资研究等领域的从业者与分析师使用。报告按产品类型&#…

📅 2026/10/15 6:37:59
BoltDB 嵌入式键值存储完整实践指南:从事务模型到源码原理(FIND 项目集成视角)

BoltDB 嵌入式键值存储完整实践指南:从事务模型到源码原理(FIND 项目集成视角)

后端机器学习物联网 【免费下载链接】find High-precision indoor positioning framework for most wifi-enabled devices. 项目地址: https://gitcode.com/gh_mirrors/fi/find 点击查看 免费下载 Bolt 是一个用纯 Go 实现的嵌入式键值存储引擎,灵感源自…

📅 2026/10/15 6:37:59
总部与分公司组网怎么收费?SD-WAN费用构成与报价核对指

总部与分公司组网怎么收费?SD-WAN费用构成与报价核对指

总部准备连接多家异地分公司,收到的报价却很难直接比较:有的按站点收费,有的另列设备和实施费,还有的包含维护服务。即使月费接近,两份方案覆盖的线路、功能和工作也可能不同。总部与分公司组网的费用,需要…

📅 2026/10/15 6:37:59
MORE NEWS

更多资讯

📰

AnyPS5 主机扩展工具:局域网串流与输入映射实操指南

1. 项目缘起与核心定位拆解1.1 这个标题到底在说什么第一次看到"AnyPS5"这个命名,我的直觉是这是一个围绕PlayStation 5生态做扩展的工具型项目。命名结构里"Any"是前缀,通常意味着"任意、通用、跨平台"的能力延伸&#x…

📰

GEO报告分数怎么读?三步拆解指标定位优化优先级

上周有个朋友发来一张GEO报告截图,问我:“总分58,这到底算好还是差?下一步改什么?”这种问题我见过太多次了。拿到GEO报告有了分数,大多数人的第一反应是高兴,第二反应是懵——分数不是满分也不…

📰

GEO报告解读指南:从子项拆解到优先级判断的AI搜索优化方法

拿到GEO报告的那一刻,大多数人都是同样的状态:总分清清楚楚摆在页面上,看起来还行,也不算差,但下一步到底改什么,脑子里一片空白。我见过太多做内容运营、做SEO的朋友,花了大几千块跑完一轮GEO检…

📰

从Cursor杀回命令行:AI IDE与CLI工作流的平衡之道

说实话,我不是第一次动“从Cursor杀回命令行”的念头了。每次坐下来写代码,我都得先跟自己谈判一轮:今天到底开编辑器,还是用终端?这种纠结并不是因为哪边更强,而是因为我越来越清楚,工具选择的…

📰

Hugging Face开源数学推理RL环境:用形式化验证替代奖励模型

1. 项目概述:这不是一次简单的模型搬运,而是一次RL训练范式的迁移“Hugging Face 将 OpenAI 数学题转为开源 RL 环境”——这句话乍看像技术新闻标题,实则藏着一个正在悄然发生的行业拐点。它不是说“把GPT解题结果抄过来”,而是把…

📰

PS5实用优化手册:屏幕、网络、外设与存储的适配

看到“AnyPS5”这个题目,我先说个结论:它不是系统级破解,也不是什么魔改固件的黑话。我花了三周时间,把手里这台PS5从头到脚折腾了一遍,最后得出的理解是——所谓“Any”,指的是一台主机在任何日常条件不算…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬