做了几年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()的返回值传出去。有几个边界情况特别容易出问题。
如果目标方法返回void,proceed()返回null,切面方法返回null是没问题的,调用方本来就不接收返回值。但如果你写了一个通用缓存切面,被缓存的方法恰好返回void,proceed()得到的null会被当缓存值存进去,下一次所有调用都命中缓存,方法体一次都不执行,这种错误极其隐蔽,因为代码不报错。
如果目标方法返回基本类型int,目标方法真正返回后,调用方拿到的实际是包装类型Integer,切面返回Object是没问题的。但如果你在切面里做了“未命中缓存就返回默认值”的逻辑,返回了一个null,调用方自动拆箱时直接抛NullPointerException。这种错误通常不会在切面里暴露,而是出现在业务代码的某个字段赋值处,排查起来特别费劲。
泛型返回值同样要小心。目标方法声明返回List<User>,切面里拿到的是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是在给项目做减法,但一定要让这个减法的过程可观察、可维护,这一点比写出一段优雅的切面代码更重要。