如果你写过几个稍微像样点的 Spring Boot 项目,大概率见过这种场景:一个 Controller 里十几个接口,每个接口都要校验登录状态,方法里要统计耗时日志,出异常了还得统一记一条 error 日志。第一版还好,写多了就会发现这些代码跟业务逻辑缠在一起,改一次权限规则要动十几个方法,光复制粘贴都嫌烦。
Spring AOP 解决的就是这个问题。它可以把一类跟业务无关但又必须做的事(日志、鉴权、限流、事务、性能监控)抽出来,统一加在所有需要的地方。你不用改原来的方法,切面会自动在目标方法执行前后插入逻辑。这就是 AOP——面向切面编程。这篇博文我会从最基础的概念讲起,带你在 Spring Boot 里跑通第一个切面,再做一个基于注解的接口限流实战,最后说清楚它背后的动态代理原理和最容易踩的坑,适合刚接触 Spring AOP 的开发者,也适合想把这部分知识用在项目里的同学。
1. 从一段“脏乱差”的业务代码说起
1.1 业务代码里最让人烦躁的几种重复逻辑
随便打开一个真实项目的 Service 实现类,通常都能看到类似的代码结构。
public Order createOrder(OrderDTO dto) { // 记录开始时间 long start = System.currentTimeMillis(); try { // 权限校验 this.checkPermission("order:create"); // 参数校验逻辑 if (dto.getAmount() == null) { throw new IllegalArgumentException("金额不能为空"); } // 核心业务 Order order = new Order(); order.setUserId(dto.getUserId()); order.setAmount(dto.getAmount()); orderMapper.insert(order); // 操作日志 log.info("创建订单成功,orderId={}", order.getId()); return order; } catch (Exception e) { // 异常日志 log.error("创建订单失败,userId={}", dto.getUserId(), e); throw e; } finally { // 耗时统计 long cost = System.currentTimeMillis() - start; log.info("创建订单耗时:{}ms", cost); } }这只是单个方法。如果项目里有二十个、五十个这样的方法,每个方法都粘一段权限校验、日志、耗时统计,代码的重复率会高到让人崩溃。更要命的是,有一天产品经理说“所有写操作都要加操作人记录”,你得一个方法一个方法去改,漏改一个就出线上事故。
这种散落在各个业务方法中、跟核心业务无关的代码,在 AOP 的语境里叫“横切关注点”。横切这个词很形象——它横着切过了很多个业务模块,每个模块里都有它的身影。AOP 的思路就是把这些横切关注点集中到一个地方统一管理,让业务方法回归业务的纯粹性。
1.2 AOP 解决问题的思路:召之即来挥之即去
AOP 本质上是一种编程范式,它的目标是把这些重复的横切逻辑抽取出来,在你不需要的时候完全不出现,在你需要的时候自动插入到目标方法周围。
用生活里的场景来类比:商场里的每个店铺都有收银台,收银台除了收钱,还要做三件事——验钞、开发票、记录流水。如果不统一处理,每个店员都要自己记三遍流程,漏掉一步就要出问题。后来商场引入了统一的收银系统,店员只需要操作收银系统,验钞、开发票、记流水这些事由系统在背后自动完成。AOP 就扮演了这套“收银系统”的角色。
在 Spring 体系里,AOP 的实现非常成熟,你只要定义好“在哪些方法上做增强”(切点)以及“增强什么逻辑”(通知),Spring 会在运行时帮你生成一个代理对象,目标方法执行时自动插入增强逻辑。这个机制,就是 Spring AOP 的核心。
1.3 使用 AOP 后的代码长什么样
还是刚才那个创建订单的方法,引入 AOP 之后,业务代码会变成这样:
public Order createOrder(OrderDTO dto) { // 参数合法性的基础校验 if (dto.getAmount() == null) { throw new IllegalArgumentException("金额不能为空"); } // 核心业务,只做它该做的事 Order order = new Order(); order.setUserId(dto.getUserId()); order.setAmount(dto.getAmount()); orderMapper.insert(order); return order; }权限校验、操作日志、异常记录、耗时统计,全都被切面接管了。方法里只剩两段内容:参数基础校验和核心业务逻辑。代码的阅读成本、维护成本显著下降,出问题的概率也随之变小。
看到这里,你应该能理解为什么 AOP 在 Spring 中的地位这么高了。Spring 自己的事务管理、Spring Security 的鉴权、Spring Cache 的缓存逻辑,底层全部基于 AOP 实现。搞懂了 AOP,你就等于拿到了打开 Spring 框架深层机制的一把钥匙。
2. AOP 的四个核心术语,用大白话讲清楚
很多人一上来就背概念:切面、切点、通知、连接点……背完就忘,因为概念和实际代码对应不上。这里我换一种讲法,用一套“快递包裹安检”的类比把这些术语串起来。
2.1 切面、切点、通知、连接点的真实身份
假设你经营一家快递中转站,所有包裹在发出前都要经过安检。这个“安检流程”就是通知(Advice),因为它是你希望在某个时刻额外执行的一段逻辑。“哪些包裹要安检”就是切点(Pointcut)。在 Spring AOP 里,切点通常是一个表达式,用来匹配哪些方法需要被增强。比如“所有 com.example.service 包下以 Order 结尾的类的所有 public 方法”,这就是一个切点表达式描述。
每个包裹过安检时,安检员会执行完整套安检流程,这个具体的“执行时刻”就是连接点(JoinPoint)。每一个被切点匹配到的方法,都是一个连接点。理论上程序运行中任何一个方法都可以是连接点,但 Spring AOP 只支持方法级别的连接点,因为 Spring AOP 基于动态代理实现,代理只能拦截方法调用。
“切面”(Aspect)则是这一切的集合:你有一个类,里面定义了切点表达式,也定义了通知方法,这个类上标注了@Aspect注解,它就是切面。切面 = 切点 + 通知。
2.2 五种通知类型,各自在什么时候执行
Spring AOP 提供了五种通知注解,对应不同的执行时机。我整理了一张表,后面写代码时照着这份对应关系来:
| 注解 | 执行时机 | 典型用途 |
|---|---|---|
@Before | 目标方法执行前 | 权限校验、参数前置检查 |
@After | 目标方法执行后(无论正常还是异常) | 释放资源、清理上下文 |
@AfterReturning | 目标方法正常返回后 | 记录成功日志、组装返回结果 |
@AfterThrowing | 目标方法抛出异常后 | 异常通知、告警 |
@Around | 环绕目标方法执行(最强大) | 耗时统计、限流、事务、分布式锁 |
@Around是五种通知里最灵活的一个,因为它可以完全控制目标方法什么时候执行、要不要执行、返回值如何修改。但它也是最容易写错的一个,后面实战环节我会展示它的正确写法。
2.3 切点表达式的基本语法
切点表达式是 AOP 的门面,你至少要能读懂、能写最常用的几种。最常见的写法是execution表达式:
@Pointcut("execution(public * com.example.service.*.*(..))") public void servicePointcut() {}这段表达式的意思是:匹配com.example.service包下所有类的所有 public 方法,参数任意。逐个拆解:
- 第一个
*:返回值类型任意。 com.example.service.*:指定包下的所有类。- 第二个
*:所有方法名。 (..):参数类型和个数任意。两个点表示任意个数的参数。
除了execution,还有几种常用的切点指示符:
within(com.example.service..*):匹配包及其子包中的所有方法。@annotation(com.example.annotation.RateLimit):匹配标注了指定注解的方法。这个在实战中非常有用,我下文做接口限流时就会用到。bean(orderServiceImpl):匹配指定名称的 Bean。
切点是 AOP 里最需要经验的环节,表达式写宽了会误拦截不该管的方法,写窄了该增强的又漏掉。我的建议是:新手期优先使用@annotation和execution组合的方式,通过自定义注解来精确控制需要增强的范围,而不是直接用通配符扫一个大包,这样可控性高很多。
3. 跑通第一个 Spring Boot AOP Demo:从零到能看见切面在工作
光看概念总是虚的。这一节我带你完整跑通一个 Spring Boot AOP Demo,整个过程大概十分钟,你就能在自己的项目里实际看到切面是怎么“插入”到业务方法中去的。
3.1 在 pom.xml 中引入 AOP 依赖
如果你从spring-boot-starter-web起步,AOP 依赖需要单独引入。在pom.xml里加上:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>这个 starter 会自动引入 Spring AOP 以及 AspectJ 的相关支持(Spring Boot 2.7+ 默认的 aspectjweaver 版本已足够日常使用)。引入之后不需要额外配置切面开关,Spring Boot 的自动配置会自动识别标注了@Aspect的类并注册为切面。
提示:Spring Boot 3.x 基于 Spring Framework 6,底层用 AspectJ 的注解模型实现,使用方式和 2.x 完全一致,不用纠结版本问题。
3.2 定义一个最简业务 Service
我们模拟一个用户查询接口,后续所有切面都在这个 Service 上验证效果。
@Service public class UserService { public User getUserById(Long id) { // 模拟业务耗时 try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return new User(id, "张三" + id, 25); } public void deleteUser(Long id) { if (id <= 0) { throw new IllegalArgumentException("用户id不合法"); } } }注意deleteUser方法里我故意留了一个抛异常的分支,后面验证异常通知时用得上。
3.3 定义第一个切面类
现在我们定义一个最基础的切面:在UserService的所有方法执行前打印一行日志。
@Aspect @Component public class LogAspect { private static final Logger log = LoggerFactory.getLogger(LogAspect.class); // 定义切点:匹配 UserService 中的所有方法 @Pointcut("execution(public * com.example.demo.service.UserService.*(..))") public void userServicePointcut() {} // 前置通知 @Before("userServicePointcut()") public void beforeAdvice(JoinPoint joinPoint) { String methodName = joinPoint.getSignature().getName(); Object[] args = joinPoint.getArgs(); log.info("【前置通知】方法 {} 即将执行,参数:{}", methodName, Arrays.toString(args)); } // 后置通知 @AfterReturning(pointcut = "userServicePointcut()", returning = "result") public void afterReturningAdvice(JoinPoint joinPoint, Object result) { String methodName = joinPoint.getSignature().getName(); log.info("【返回通知】方法 {} 执行完成,返回值:{}", methodName, result); } // 异常通知 @AfterThrowing(pointcut = "userServicePointcut()", throwing = "ex") public void afterThrowingAdvice(JoinPoint joinPoint, Exception ex) { String methodName = joinPoint.getSignature().getName(); log.info("【异常通知】方法 {} 抛出异常,异常信息:{}", methodName, ex.getMessage()); } // 环绕通知 @Around("userServicePointcut()") public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); try { Object result = joinPoint.proceed(); log.info("【环绕通知】方法 {} 执行成功,耗时 {} ms", joinPoint.getSignature().getName(), System.currentTimeMillis() - start); return result; } catch (Throwable e) { log.info("【环绕通知】方法 {} 抛出异常,耗时 {} ms", joinPoint.getSignature().getName(), System.currentTimeMillis() - start); throw e; } } }这里有个非常重要的细节:@Around通知方法必须接收一个ProceedingJoinPoint类型的参数,并且要手动调用proceed()方法,目标方法才会执行。如果你在环绕通知里忘了调proceed(),目标方法就会被“吞掉”,业务逻辑直接不执行了,而且 Spring 不会报任何错。我第一次写 AOP 时就踩过这个坑,排了半天才发现是环绕通知少调了一行。
多个通知同时生效时,执行顺序是:@Around开始(proceed 之前的部分)→@Before→ 目标方法 →@AfterReturning或@AfterThrowing→@Around结束(proceed 之后的部分)。
3.4 编写测试接口验证切面
写一个最简单的 Controller 来验证效果:
@RestController @RequestMapping("/user") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{id}") public User getUser(@PathVariable Long id) { return userService.getUserById(id); } @DeleteMapping("/{id}") public String deleteUser(@PathVariable Long id) { userService.deleteUser(id); return "ok"; } }启动项目,访问/user/1,控制台会输出类似下面的日志:
【前置通知】方法 getUserById 即将执行,参数:[1] 【环绕通知】方法 getUserById 执行成功,耗时 102 ms 【返回通知】方法 getUserById 执行完成,返回值:User(id=1, name=张三1, age=25)访问/user/0触发异常,输出会变为:
【前置通知】方法 deleteUser 即将执行,参数:[0] 【环绕通知】方法 deleteUser 抛出异常,耗时 1 ms 【异常通知】方法 deleteUser 抛出异常,异常信息:用户id不合法从输出顺序可以看到通知的执行顺序完全符合上一小节说的规则。到这里,你的第一个 Spring AOP 切面已经成功跑通了。这段代码里隐藏了大量你可以实际去调整的细节:比如调整切点表达式、增加多个切面观察它们的执行顺序,强烈建议动手改一改再运行,比只看文章效果好得多。
4. 高价值的实战:用自定义注解+AOP 给接口加限流
理解了基础用法后,我们做一个实际项目中高频使用的功能:基于注解的接口限流。这个场景最常见的需求是对某些敏感接口(如短信验证码发送、支付接口)做调用频率限制,防止被刷。用 AOP + 自定义注解实现,业务代码保持零侵入,非常优雅。
4.1 自定义注解的设计
先定义一个@RateLimit注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { // 限流时间窗口,单位秒 int windowSeconds() default 60; // 窗口内允许的最大请求次数 int maxCount() default 10; // 限流提示信息 String message() default "请求过于频繁,请稍后再试"; }简单解释一下这两个元注解:@Target(ElementType.METHOD)表示这个注解只能标在方法上;@Retention(RetentionPolicy.RUNTIME)表示注解在运行时保留,这样 Spring AOP 才能在运行时通过反射读取注解的值。这两个缺一不可,少了 RUNTIME 会导致切面在运行时读不到注解信息。
4.2 限流切面的实现
核心思路:使用ConcurrentHashMap保存每个方法签名对应的访问记录,记录里保存时间窗口的起始时间和窗口内的访问次数。每次请求进来时检查当前时间是否超出窗口,如果超出则重置窗口,否则判断当前窗口内的访问次数是否超过上限。
@Aspect @Component public class RateLimitAspect { private static final Logger log = LoggerFactory.getLogger(RateLimitAspect.class); // 方法签名 -> 访问记录 private final Map<String, AccessRecord> recordMap = new ConcurrentHashMap<>(); @Around("@annotation(rateLimit)") public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String methodKey = buildMethodKey(joinPoint); AccessRecord record = recordMap.computeIfAbsent(methodKey, k -> new AccessRecord()); synchronized (record) { long now = System.currentTimeMillis(); // 当前时间超出窗口起始时间 + 窗口长度,说明窗口已过期,重置 if (now - record.windowStart >= (long) rateLimit.windowSeconds() * 1000) { record.windowStart = now; record.count = 0; } if (record.count >= rateLimit.maxCount()) { log.warn("接口限流触发:{},{}", methodKey, rateLimit.message()); throw new RuntimeException(rateLimit.message()); } record.count++; } return joinPoint.proceed(); } private String buildMethodKey(ProceedingJoinPoint joinPoint) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); return signature.getDeclaringTypeName() + "#" + signature.getName(); } private static class AccessRecord { private long windowStart = System.currentTimeMillis(); private int count; } }这个实现里有几个值得注意的细节:
第一,使用synchronized (record)对同一个方法签名的访问做串行化,避免并发场景下计数不准。对于每个接口方法,锁的粒度只限于该方法本身的计数器,多个不同接口之间不互相阻塞,性能损耗很小。
第二,computeIfAbsent保证每个方法签名只创建一个记录对象,避免不必要的对象创建。
第三,我在计数完成后把结果加一,然后才放行目标方法。这么做的意思是,即使目标方法抛异常,这次请求也已经消费了一次调用次数,可以防止有人用异常请求绕过限流。
4.3 在业务方法上使用限流注解
使用方式非常简洁:
@RestController @RequestMapping("/sms") public class SmsController { @RateLimit(windowSeconds = 60, maxCount = 5, message = "短信发送太频繁,请一分钟后再试") @PostMapping("/send") public String sendSms(@RequestParam String phone) { // 真正的短信发送逻辑 return "发送成功"; } }这样/sms/send接口在 60 秒内最多只能被调用 5 次,超过之后直接抛出异常,由全局异常处理器统一转换为友好的提示返回给前端。
这里展示的是一个完整可用的限流方案,但它只是一个基础版本,适合中小流量的项目使用。如果你的接口流量较大、又有多实例部署,就需要考虑用 Redis 做分布式限流了。思路是一样的:把计数逻辑从本地内存迁移到 Redis 的 INCR + EXPIRE 命令,AOP 切面的骨架完全不用改,这就是注解 + AOP 模式的灵活之处。
4.4 为什么用 @annotation 切点而不是 execution
我在实现里使用的是@annotation(rateLimit)切点,而不是execution表达式。这里解释一下设计原因:
如果用execution(public * com.example.demo.controller..*.*(..)),那么所有 Controller 方法都会被切面拦截。但很多接口不需要限流,切面必须在运行时反射检查每个方法有没有标注@RateLimit注解,没有就放行。这样做两个问题:一是所有接口的调用都会多一次反射检查,浪费性能;二是容易误拦截,未来新增的 Controller 方法也可能被意外增强。
而@annotation(rateLimit)只匹配标注了指定注解的方法,Spring 在匹配阶段就把无关方法排除了,只有标注了@RateLimit的方法才会进入切面逻辑。同时,注解的值还能直接作为切面方法的参数注入进来,省去了反射读取注解的步骤。所以注解 + AOP 这种组合是现代 Spring 项目中最推崇的落地方式,也是 Spring Security、Spring Cache 等框架内部使用的方案。
5. 揭开 AOP 底牌:动态代理是如何让“外挂”生效的
学习 AOP 绝对不能只停留在会用注解,不理解底层就很难解释清楚一些玄学问题,比如:为什么同类的内部方法调用不走切面?为什么循环依赖下 AOP 处理会不一样?这些都绕不开动态代理这个核心机制。
5.1 代理模式是 AOP 的基石
AOP 能实现“不修改原代码就增强方法”,靠的是代理模式。它的本质是创建一个目标对象的代理,外部调用的其实是代理对象,代理对象在调用目标方法之前和之后插入增强逻辑。
Spring AOP 默认在运行期为目标对象动态生成代理,有两种生成方式:
| 维度 | JDK 动态代理 | CGLIB 动态代理 |
|---|---|---|
| 原理 | 通过接口生成实现类 | 通过继承目标类生成子类 |
| 要求 | 目标类必须实现接口 | 目标类不能是 final,方法不能是 final |
| 性能 | 生成代理快,调用稍慢 | 生成代理慢,调用较快 |
| Spring 默认策略 | 早期版本默认 | Spring Boot 2.x 起默认 |
JDK 动态代理通过Proxy.newProxyInstance生成一个实现目标接口的代理类,调用代理方法时,InvocationHandler.invoke方法会被触发。CGLIB 则在字节码层面生成目标类的子类,子类覆写目标方法并插入增强逻辑。
Spring 之所以从默认 JDK 动态代理改为 CGLIB,最重要的原因是太多项目里 Bean 只写了实现类、没抽接口,JDK 动态代理面对这种没有接口的类无能为力。Spring Boot 2.x 开始默认将spring.aop.proxy-target-class设置为 true,也就是无接口也强制使用 CGLIB,省去了一些让人困惑的“为什么没有切面生效”的问题。
5.2 代理对象的实际存在感
用一段代码来感受代理对象的存在。给之前的UserService增加一个测试方法,打印它的运行类型:
@GetMapping("/proxy-type") public String proxyType() { return userService.getClass().getName(); }如果你使用的是 Spring Boot 2.x 及以上版本,返回的结果大概率是:
com.example.demo.service.UserService$$EnhancerBySpringCGLIB$$xxxxx看到EnhancerBySpringCGLIB这几个字符,就证明这个 Bean 在容器中已经被替换成了 CGLIB 生成的代理对象。你注入的UserService表面上是个 Service,实际运行时是个代理子类。
5.3 AOP 与循环依赖:三级缓存里的特殊情况
在 Spring 中,AOP 和循环依赖(比如 A 依赖 B、B 又依赖 A)放在一起时,处理逻辑非常微妙,也是面试高频考点。要理解这个,得知道 Spring 解决循环依赖用到的三级缓存:
- 一级缓存:存储已完全初始化的单例 Bean。
- 二级缓存:存储早期暴露的 Bean,但还没完成属性填充。
- 三级缓存:存储
ObjectFactory工厂,它可以在必要时提前生成代理对象。
为什么需要第三级缓存而不是直接用二级缓存存未代理的早期 Bean?根本原因就是 AOP:如果 Bean A 和 Bean B 互相依赖,且 A 需要被 AOP 代理,那么在 B 注入 A 的时候,A 还没走到初始化最后的代理生成阶段。如果没有三级缓存,B 拿到的就是未经代理的原始 A 对象,AOP 就直接失效了。
三级缓存的处理流程是:A 实例化后,把“获取 A 引用的工厂”(ObjectFactory)放入三级缓存。当 B 需要引用 A 时,从三级缓存拿到工厂,此时提前执行工厂,工厂会判断 A 是否需要代理。如果需要,就生成 A 的代理对象,放入二级缓存并返回给 B。等到 A 真正走完初始化流程时,如果发现二级缓存里已经有了提前生成的代理对象,就直接用代理对象完成最终的单例暴露,保证容器中全局只有一个 A 的代理对象。
这个机制的精妙之处在于:AOP 的代理生成被推迟到“有人真正需要 A 的引用”时才触发,如果整个初始化流程没人提前引用 A,就不需要提前生成代理,避免无谓的性能开销。这也是为什么“三级缓存”而非“二级缓存”的原因——二级缓存无法在最早阶段判断是否需要代理。
5.4 结合源码简单看通知的调用链
如果你有时间,可以跟着JdkDynamicAopProxy.invoke的源码走一遍调用链。它整体的执行顺序是:
- 从
AdvisedSupport中拿到配置好的切面列表。 - 创建一个
MethodInvocation,内部封装目标方法、参数、拦截器链(也就是各个通知)。 - 调用
proceed()依次执行拦截器链中的每个通知。 - 最后一个拦截器执行完,才真正调用目标方法。
所以从某种意义上说,AOP 的执行可以理解为一个洋葱模型:每个通知包一层,最里层才是真正的业务代码。这也是为什么面试里经常有人问“多个切面时执行顺序怎么保证”,答案就是通过@Order注解指定切面的优先级,数值越小越靠外、越先执行。
6. 最容易踩的 AOP 坑:失效场景与排查思路
AOP 用起来不难,但真正让人头疼的是那些“该生效却不生效”的情况。沉默失败是最可怕的,因为业务代码没有报错,但日志、权限、限流全都静默失效。下面这类问题我在项目里排查过很多次,也帮同事定位过不少,总结了几个最常见的坑,你对照自查基本能解决大部分问题。
6.1 同类内部方法调用导致切面失效
这是最常见的问题,没有之一。考虑下面的代码:
@Service public class OrderService { public void createOrder(OrderDTO dto) { this.checkStock(dto); // 内部直接调用 orderMapper.insert(dto); } @RateLimit(windowSeconds = 60, maxCount = 10) public void checkStock(OrderDTO dto) { // 校验库存 } }你期待调用createOrder时,checkStock的限流会生效。但实际上不会。原因从 5.1 节的代理原理就能推出来:外部注入到其他类中的是 OrderService 的代理对象,但this.checkStock()里的this指向的是目标原始对象,不是代理对象,所以 Spring 根本没有机会拦截这次调用。
解决方案有三种:
- 把需要增强的方法抽到另一个 Service 类中,通过注入代理对象调用。
- 从
ApplicationContext中获取代理对象再调用。 - 在类内部通过
AopContext.currentProxy()获取当前代理(需要配置@EnableAspectJAutoProxy(exposeProxy = true)),实现相对优雅但侵入性强。
其中最简单也最推荐的是第一个方案:把一个类拆成两个职责更单一的类,既解决了 AOP 失效问题,也让代码结构更清晰。
6.2 切点表达式写错导致静默失效
切点表达式写错不会报错,只会默默不匹配任何方法。新手最容易犯的错误是路径写错。比如你写的包是com.example.demo.service,但 Service 类实际在com.example.demo.service.impl,用com.example.demo.service.*做匹配时,*只匹配一层包,不会匹配到impl子包下的类。解决办法是用com.example.demo.service..*,两个点代表包含所有子包。
表达式匹配没问题,但切面还是没生效,还有一种可能:切面类没有被 Spring 管理。如果你把切面类定义在了@SpringBootApplication默认扫描不到的位置,或者忘了加@Component注解,那么@Aspect就只是一个标注,Spring 不会实例化它,切面自然形同虚设。
排查技巧:在切面类的@Before方法里临时加一行输出,如果调用目标方法时没有任何日志,先查 Spring 容器里有没有这个切面 Bean(Idea 的 actuator 接口或者断点都可以确认),再确认切点表达式能否匹配。
6.3 事务注解和 AOP 同时使用时的顺序问题
@Transactional本身也是通过 AOP 实现的。如果你的项目里既有自定义切面又有事务需求,它们两个的执行顺序是有讲究的。默认情况下,事务切面的优先级更高,包裹在自定义切面内部(也就是事务先开启、自定义切面后执行,事务提交在最后一个切面执行完之后)。
这在某些业务场景下会造成困惑。比如你在@AfterReturning通知里发了一个消息,由于自定义切面在事务内部,消息发送时事务可能还没提交,如果此时消费方查数据库,会看到数据不存在。
解决办法是给自定义切面加@Order注解调整优先级。@Order(1)比事务的默认顺序更靠前,会在事务开启前就执行;@Order(Ordered.LOWEST_PRECEDENCE)则在最内层执行。具体选择取决于你的业务需求:比如做耗时统计的切面放在最外层,做业务数据校验的切面放在贴近业务方法的位置。
注意:
@Order的数值越小优先级越高,多个切面同时作用于一个方法时,优先级高的切面先进入、后退出。类似洋葱模型的穿层顺序。
6.4 目标类是 final 或方法为 final 导致无法代理
如果你用了 CGLIB 动态代理,目标类不能是 final 类,目标方法不能是 final 方法。因为 CGLIB 靠继承目标类并重写方法来实现增强,这两个修饰符会直接阻止代理生成或方法覆写。
这个坑在接老项目改造时特别常见。有些老代码为了避免继承滥用,把类标记成了 final,引入 AOP 后项目还能启动,但切面就是不生效。遇到这种情况,先检查目标类和方法有没有 final 修饰,有的话去掉即可。
另外顺带提醒:private方法也不会被 AOP 拦截,因为代理类无法覆写 private 方法,这个问题在内部调用场景下和 6.1 类似,但表现略有不同——即使你从外部调用了私有方法所在的公有方法,私有方法内部的逻辑也不会被增强。
6.5 一个实用的排查链路
如果你遇到“AOP 没生效”的问题,我一般按下面这套链路排查,效率很高:
- 确认切面类是否被 Spring 管理:检查是否标注了
@Component或注册成了 Bean。没有 Bean 一切都是空谈。 - 确认切点表达式是否匹配目标方法:在
@Before或@Around中临时打日志,调用目标方法后看日志有没有输出。没有输出就是表达式匹配不上。 - 确认是外部调用还是内部调用:看目标方法是不是被
this.方式调用。内部调用走的是原始对象,切面不会触发。 - 确认目标类是否可被代理:检查类和方法有没有 final 修饰。
- 确认切面类自身的注解属性:
@Aspect注解要保留在类上,方法上如果是@Around,确保参数里有ProceedingJoinPoint并调用了proceed()。
大多数情况下,问题出在前三步。尤其是第一步,很多人把@Aspect放在 Controller 上,Controller 本身也被代理了,却忘了给切面类加@Component,导致切面类完全没被实例化。
7. 写在最后的几句实用提醒
这篇文章从一个具体的代码痛点切入,把 Spring AOP 从概念、Demo、实战到原理再到踩坑全部走了一遍。最后分享几条实操体会。
第一,AOP 用得好是利器,用不好是灾难。不要在业务代码里过度依赖 AOP,把所有逻辑都塞进切面里。我见过一些项目,整个方法调用链上五六个自定义切面,出了问题根本看不清执行顺序。合理的 AOP 使用边界是:横跨大量方法的通用逻辑,比如日志、限流、鉴权、幂等、分布式锁。单个方法专属的特殊逻辑,老老实实写在方法里。
第二,面向切面编程要求你养成一个习惯:在给方法加注解(比如@RateLimit、@LogRecord)之前,先想清楚这个方法的调用来源。如果它会被同类内部this调用,那么注解大概率不会生效,这是最容易忽视的隐患。
第三,建议新手做一次手动写动态代理的实验:不借助 Spring,直接使用Proxy.newProxyInstance写一个 JDK 动态代理,再使用 CGLIB 写一个子类代理,亲手调用一下,体会代理对象的调用过程。这个实验做完,AOP 的原理你就会理解得非常透彻,排查问题也不再虚。
第四,调试 AOP 问题不要瞎试,善用断点。在代理类的invoke或者切面方法的入口打断点,观察args、joinPoint.getSignature()就能快速确认切面是否被触发、参数是否正确。Spring Boot 的 actuator 提供的 Bean 信息也能帮你确认目标对象是否已经被代理。
Spring AOP 属于那种“知道的人觉得简单,不知道的人觉得神秘”的技术。一旦你把代理机制想通了,再看 Spring Security 的过滤器链、Spring Cache 的注解缓存、事务传播行为,都会有一种豁然开朗的感觉。希望这篇入门文章能帮你打开这扇门。