项目中的 AOP 实践:日志、限流、权限与事务的统一落地
项目中的 AOP 实践:日志、限流、权限与事务的统一落地
面试指北 · 通用
AOP(Aspect-Oriented Programming,面向切面编程) 的核心思路,是把那些「跟业务本身没关系、但每个业务模块都要用」的逻辑抽出来统一处理,比如事务、日志、权限、限流、缓存、埋点等。这些逻辑在代码里往往是横着切进各个业务方法的,所以叫「横切关注点」。
把它们封装成切面之后,最直接的好处有三个:
- 减少重复代码:不用在每个方法里都写一遍
log.info(...)或权限判断; - 降低模块间耦合:业务代码只关心业务,非业务逻辑由切面统一负责;
- 提高可扩展性和可维护性:要改日志格式、要换限流算法,只改切面一处即可。
以我的项目为例,主要有下面这些地方用到了 AOP:
- 基于 AOP 实现统一的日志管理。
- 基于 Redisson + AOP 实现了接口防刷,一个注解即可限制接口在指定时间内单个用户的请求次数。
- 基于 Spring Security 提供的
@PreAuthorize实现权限控制,其底层也是基于 AOP。 - 基于
@Transactional注解实现事务管理。
实际面试中,你可以根据自己的项目去介绍,我这里只是列举了几个最常见的例子。
题意
这道题通常出现在「Spring 框架」或「项目介绍」环节,面试官想通过它判断三件事:
- 你是只会背概念,还是真的在项目里用过 AOP;
- 你是否理解 AOP 在 Spring 中是怎么落地的(动态代理、切点表达式、通知类型);
- 你是否踩过坑,知道 AOP 在什么情况下会失效。
所以回答的节奏建议是:一句话说清 AOP 是什么 → 列举项目里的 2~3 个真实落地场景 → 挑一个讲清「注解 + 切面 + 底层原理」→ 主动补充选型理由和失效场景。这样比干巴巴地背「切面、切点、连接点」的定义要有说服力得多。
结论
AOP 是一种把横切关注点从业务代码中剥离出来的编程范式;Spring AOP 通过运行时动态代理(JDK 动态代理或 CGLIB)在目标方法调用前后织入通知逻辑。项目中最典型的四类落地是统一日志、接口防刷/限流、方法级权限控制、声明式事务——它们表面上只是几个注解,背后都是「切面拦截 + 通知执行」这一套机制。
要点展开
先把术语对齐:切点、通知、切面、织入
面试时先把几个词说准,后面聊实现才不会乱:
| 术语 | 含义 | 在 Spring AOP 中的对应 |
|---|---|---|
| 连接点 JoinPoint | 程序执行中可以插入切面的点 | Spring AOP 只支持方法调用这一种连接点 |
| 切点 Pointcut | 用来匹配连接点的表达式 | @Pointcut("@annotation(com.example.annotation.Log)") |
| 通知 Advice | 匹配成功后要执行的逻辑 | @Before / @AfterReturning / @AfterThrowing / @After / @Around |
| 切面 Aspect | 切点 + 通知的组合 | @Aspect 标注的类 |
| 织入 Weaving | 把切面应用到目标对象、生成代理对象的过程 | Spring 在运行时通过动态代理完成 |
一个关键点:Spring AOP 是方法级别的,它不支持字段级别的拦截(那是 AspectJ 编译期/加载期织入的范畴)。
底层:动态代理决定了 AOP 的能力边界
理解了代理,很多「AOP 为什么失效」的问题就自然有答案了:
- 目标类实现了接口时,Spring 默认可以用 JDK 动态代理,生成一个实现同样接口的代理类;
- 没有接口时用 CGLIB,生成目标类的子类并覆写方法;
- Spring Boot 2.x 之后默认
spring.aop.proxy-target-class=true,基本统一走 CGLIB。
由此可以直接推出几条限制:
private、static、final方法无法被代理(CGLIB 靠继承覆写,JDK 代理靠接口实现);- 同一个类内部的方法互相调用(
this.method())不经过代理对象,切面不生效; - 只有被 Spring 容器管理的 Bean 才会被代理。
调用链路大致是:代理对象 → 通知链(Interceptor Chain)→ 目标方法。所谓「切面」,最终都被包装成了一个个 MethodInterceptor。
日志记录
利用 AOP 方式记录日志,只需要在 Controller 的方法上使用自定义 @Log 日志注解,就可以将用户操作记录到数据库。
@Log(description = "新增用户")
@PostMapping(value = "/users")
public ResponseEntity create(@Validated @RequestBody User resources){
checkLevel(resources);
return new ResponseEntity(userService.create(resources),HttpStatus.CREATED);
}
AOP 切面类 LogAspect 用来拦截带有 @Log 注解的方法并处理:
@Aspect
@Component
public class LogAspect {
private static final Logger logger = LoggerFactory.getLogger(LogAspect.class);
// 定义了一个切点/切入点(Pointcut),匹配所有带有 @Log 注解的方法
@Pointcut("@annotation(com.example.annotation.Log)") // 这里需要根据你的实际包名修改
public void logPointcut() {
}
// 定义了一个环绕通知(Around Advice),此环绕通知会应用到 logPointcut 定义的切点上,即所有带有 @Log 注解的方法
@Around("logPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
//...
}
}
我这里选择的是环绕通知(@Around),这是所有通知类型中可操作范围最大的一种:它可以直接拿到目标对象以及要执行的方法,所以可以在目标方法调用的前后「任意搞事」,甚至可以干脆不调用目标方法。
切面内部通常会做这几件事:取到方法签名上的 @Log 注解拿到 description,在 proceed() 前后统计耗时,记录操作人、请求 IP、请求参数、返回值或异常信息,最后异步落库。一个常见的写法是这样(用 @annotation(log) 绑定参数,省去一次反射取注解):
@Around("@annotation(log)")
public Object around(ProceedingJoinPoint joinPoint, Log log) throws Throwable {
long start = System.currentTimeMillis();
try {
return joinPoint.proceed(); // 放行目标方法
} finally {
long cost = System.currentTimeMillis() - start;
// 记录:description、方法全名、参数、耗时、异常信息
// 落库建议异步(线程池 / MQ),避免拖慢业务接口
}
}
注意这里用 try/finally:即使业务抛异常,日志也要记录,否则「失败的操作」恰恰是最需要排查的。
如果需要在目标方法执行前后都记录日志,并且可能需要控制目标方法的执行过程,建议选择 @Around 通知。
如果你只需要在目标方法执行前、执行后,或者抛出异常时记录日志,而不需要控制目标方法的执行过程,那么使用前置通知(@Before)、返回通知(@AfterReturning)、异常通知(@AfterThrowing)这些通知类型更合适。这些通知类型的侵入性较小,不会干涉业务逻辑的执行,使得代码更容易维护。
限流 / 接口防刷
利用 AOP 方式对接口进行限流,只需要在 Controller 的方法上使用自定义的 @RateLimit 限流注解即可。
/**
* 该接口 60 秒内最多只能访问 10 次,保存到 redis 的键名为 limit_test,
*/
@RateLimit(key = "test", period = 60, count = 10, name = "testLimit", prefix = "limit")
public int test() {
return ATOMIC_INTEGER.incrementAndGet();
}
AOP 切面类 RateLimitAspect 用来拦截带有 @RateLimit 注解的方法并处理:
@Slf4j
@Aspect
public class RateLimitAspect {
// 拦截所有带有 @RateLimit 注解的方法
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
//...
}
}
我这里使用的同样是环绕通知(@Around),好处是:不仅可以控制目标方法是否执行,还可以在目标方法执行后处理返回值或异常。同时,可以在一个地方集中处理所有限流相关的逻辑,使代码更加简洁(不再需要重复编写限流检查逻辑)和易于维护(业务逻辑与限流逻辑分离)。例如,限流检查不通过后,不调用目标方法,而是直接返回一个特定的值或抛出异常,从而避免在各个方法中重复编写限流逻辑。
那么,是否可以使用前置通知(@Before)来实现限流呢?
答案是可以的,但它有明显的局限性:如果限流条件不满足,你只能通过抛出异常的方式来阻止目标方法的执行。相比之下,@Around 里可以「不调用 proceed(),直接返回一个统一格式的响应」,控制更细,也更好和全局异常处理配合。
关于限流实现这里多说一句,我并没有自己写 Redis Lua 限流脚本,而是利用 Redisson 中的 RRateLimiter 来实现分布式限流,其底层实现就是基于 Lua 脚本 + 令牌桶算法。大致用法是:
RRateLimiter limiter = redissonClient.getRateLimiter(redisKey);
// 首次初始化配置(幂等),注意不要用 setRate,它会重置已有计数
limiter.trySetRate(RateType.OVERALL, rateLimit.count(), rateLimit.period(), RateIntervalUnit.SECONDS);
if (!limiter.tryAcquire()) {
// 未拿到令牌:直接返回「请求过于频繁」,不调用 proceed()
}
几个工程上的细节值得在面试里提一嘴:
- key 的设计:一般由
prefix + 注解 key + 用户标识(未登录时退化为 IP)拼成,做到「单个用户 + 单个接口」维度计数,而不是全局一个桶; - 限流与防刷的差别:防刷更偏「同一个人、同一个接口、短时间内不要重复点」,限流更偏「保护系统整体 QPS」,但底层算法可以共用令牌桶 / 滑动窗口;
- 失败处理:拿到
false后直接返回统一错误码,不要抛到业务层,也不用真的执行目标方法。
关于常见限流算法和方案的介绍可以阅读我写的这篇文章:服务限流详解。
权限控制
Spring Security 使用 AOP 进行方法拦截。在实际调用 update 方法之前,Spring 会检查当前用户的权限,只有用户权限满足对应的条件才能执行。
@Log(description = "修改菜单")
@PutMapping(value = "/menus")
// 用户拥有 `admin`、`menu:edit` 权限中的任意一个就能能访问`update`方法
@PreAuthorize("hasAnyRole('admin','menu:edit')")
public ResponseEntity update(@Validated @RequestBody Menu resources){
//...
}
这里的 @PreAuthorize 之所以能生效,是因为 Spring Security 在启动时通过 @EnableMethodSecurity(旧版本是 @EnableGlobalMethodSecurity(prePostEnabled = true))注册了一个 MethodSecurityInterceptor——它本质上就是一个 MethodInterceptor,也就是 AOP 通知。方法被调用时,拦截器会用 SpEL 解析表达式字符串,取出当前登录用户的权限集合做判断,不通过就抛 AccessDeniedException。
事务管理
Spring 的声明式事务管理通过 AOP 机制,在目标方法执行前后插入事务管理逻辑,实现了业务代码与事务管理代码的分离。开发者只需使用 @Transactional 注解标记需要事务管理的方法或类,Spring 即可在运行时自动为这些方法添加事务处理功能。这种方式简化了事务管理的实现,使代码更加简洁和易于维护。
// 所有的异常都回滚事务
@Transactional(rollbackFor = Exception.class)
public void createArticle() {
//...
}
// CustomException 异常不回滚事务
@Transactional(noRollbackFor = CustomException.class)
public void createArticle() {
//...
}
背后的实现是 TransactionInterceptor:它同样是 AOP 通知,在方法执行前开启事务(或加入已有事务),方法正常返回时提交,抛出符合回滚规则的异常时回滚。默认只对 RuntimeException 和 Error 回滚,所以业务里习惯性写上 rollbackFor = Exception.class。
易错点与对比
1. AOP 失效的常见场景
| 场景 | 原因 |
|---|---|
同类内部方法调用(this.foo()) |
走的是原始对象,没有经过代理,切面不生效 |
private / static / final 方法 |
代理无法覆写,切面不会织入 |
类或方法被 final 修饰 |
CGLIB 无法生成子类 |
Bean 不是由 Spring 管理的(new 出来的) |
没有被代理 |
| 切点表达式写错 / 包路径不对 | 压根没匹配上,属于最常见的低级错误 |
异常被切面或业务代码 catch 掉没抛出 |
@AfterThrowing、@Transactional 的回滚都会失效 |
2. 环绕通知的写法坑
@Around 里必须调用 joinPoint.proceed(),否则目标方法不会执行;同时不要吞掉异常——如果你在切面里 catch 住业务异常不往外抛,事务拦截器收不到异常,就不会回滚。日志切面建议用 try/finally 记录,而不是 try/catch 吞异常。
3. 多个切面/通知的执行顺序
同一个方法上同时有 @Log、@RateLimit、@PreAuthorize、@Transactional 时,谁先谁后取决于切面的 Order。建议对自定义切面显式加 @Order,让顺序可预期,比如「限流 → 权限 → 日志 → 事务」。否则可能出现「请求已经被限流拦掉了,日志却记录了一条成功记录」这类看起来莫名其妙的现象。
4. hasAnyRole 会自动加 ROLE_ 前缀
hasAnyRole('admin') 实际校验的是 ROLE_admin,而 menu:edit 这种带冒号的通常是权限标识(authority)。严格来说更准确的写法是 hasAnyAuthority('ROLE_admin','menu:edit')。这个概念在面试里容易被追问,值得留心。
5. 事务注解的常见失效原因
自调用、方法不是 public、异常被吞、数据库引擎不支持事务、在 @Transactional 方法里另起线程执行 SQL(线程不共享事务上下文)等。本质上和「AOP 失效」是同一类问题。
6. AOP 与 Filter、HandlerInterceptor 的对比
| 对比项 | Filter | HandlerInterceptor | AOP |
|---|---|---|---|
| 所在层次 | Servlet 容器 | Spring MVC | Spring 容器中的 Bean 方法 |
| 作用范围 | 所有请求 | 只拦 Controller 请求 | 任意被代理的 Bean 方法(Service 也能拦) |
| 能否拿到方法参数/注解 | 不能 | 能拿到 HandlerMethod,但拿不到参数 | 能拿到方法、参数、注解、返回值、异常 |
| 能否阻止方法执行 | 能(不往下走) | 能(preHandle 返回 false) |
能(@Around 不调用 proceed()) |
| 典型用途 | 编码、跨域、XSS 过滤 | 登录校验、请求日志 | 事务、限流、权限、业务日志 |
一句话总结:能给方法加注解就能生效的,用 AOP;只关心 HTTP 请求的,用 Filter / Interceptor。
小结
AOP 在项目里的价值,不在于「用了这个技术」,而在于它把日志、限流、权限、事务这些横切逻辑收敛到了一处,让业务代码保持干净。面试答题时抓住这条主线就够了:注解声明意图 → 切面拦截方法 → 环绕通知决定是否放行 → 动态代理负责织入。
再补一句「坑」在哪儿——自调用失效、异常被吞、多个切面顺序不确定——能主动讲出这些,基本就说明你是真写过,而不是只背过定义。