多个代理或多个切面同时存在时,执行顺序如何控制?
简化版
多个代理或切面同时存在时,本质会形成一条拦截器链,外层切面先进入、后退出。Spring 中通常用 @Order、Ordered 或配置顺序控制切面优先级;顺序会影响事务、权限、缓存、日志和异常处理的语义,不能只看代码是否执行。
详细版
代理增强不是简单地“都执行一遍”,而是按链式结构嵌套执行:
AdviceA before
AdviceB before
Target method
AdviceB after
AdviceA after
如果有权限、事务、缓存、日志 4 个切面,顺序不同会导致完全不同的行为。例如权限应该尽量在事务前拦截,避免无权限请求还开启事务;事务通常要包住数据库写入;日志或监控要看是记录整体耗时还是只记录业务方法耗时。
Spring 里可以通过:
@Order(1);- 实现
Ordered; - 配置 Advisor 顺序;
- 明确切点粒度;
- 避免多个切面互相吞异常;
来控制执行顺序。面试要强调:顺序控制不是为了美观,而是为了保证横切逻辑的语义正确。
完整版教学
一、多个切面为什么会形成调用链
代理模式的增强逻辑通常不是平铺执行,而是包裹式执行。每个切面都像一层包装,调用进入时按外到内执行 before,调用返回或抛异常时按内到外执行 after 或异常处理。
Client
-> Proxy/Advice A
-> Proxy/Advice B
-> Proxy/Advice C
-> Target
因此多个切面的顺序不只是列表顺序,而是嵌套边界。最外层切面能看到整个调用耗时,内层切面只能看到它内部的那段逻辑。这个差异会影响监控指标、事务范围、异常捕获和缓存命中统计。
记忆钩子:切面顺序不是排队执行,而是洋葱式嵌套,先进的后出。
二、用权限、事务、日志看顺序差异
假设一个下单方法同时有权限校验、事务和日志监控。比较合理的顺序通常是:日志在最外层记录整体耗时,权限在事务前拒绝非法请求,事务包住真正写库逻辑。
推荐顺序:
1. 日志/Trace before
2. 权限 before
3. 事务 begin
4. 目标方法写库
5. 事务 commit/rollback
6. 权限 after
7. 日志/Trace after
如果事务放在权限外面,无权限请求也可能开启事务。一次请求影响不大,但高并发下会造成无意义的连接占用。假设每秒 1000 个无权限请求,每个都先占用数据库连接 5 ms,就会产生 1000 * 5ms = 5000ms 的连接占用时间,等价于持续占用约 5 个连接。
顺序不是小细节,它会影响资源使用和失败语义。
三、Spring 中如何指定顺序
Spring AOP 中常用 @Order 或 Ordered 控制切面顺序。数值越小,优先级越高,通常越靠外层,进入时越早执行,退出时越晚执行。
@Aspect
@Order(1)
@Component
public class TraceAspect {
@Around("execution(* com.example..*(..))")
public Object trace(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed();
} finally {
System.out.println(System.nanoTime() - start);
}
}
}
@Aspect
@Order(2)
@Component
public class PermissionAspect {
@Around("@annotation(RequiresPermission)")
public Object check(ProceedingJoinPoint pjp) throws Throwable {
// check permission
return pjp.proceed();
}
}
如果 TraceAspect 的 order 更小,它会先进入、最后退出,记录的是包含权限和目标方法在内的整体耗时。
四、缓存和事务顺序为什么很敏感
缓存切面和事务切面顺序经常引发问题。比如查询方法上同时有缓存和事务,缓存命中时是否还要开启事务?如果缓存在事务外层,命中缓存可以直接返回,不开启事务;如果事务在缓存外层,即使缓存命中也可能先开启事务。
缓存在外:
Cache before
命中 -> 直接返回
未命中 -> Transaction -> DB -> 写缓存
事务在外:
Transaction begin
Cache before
命中 -> 返回
Transaction commit
对只读查询来说,缓存在外通常更省资源。对写操作和缓存淘汰,要考虑事务提交成功后再删缓存或更新缓存,避免事务回滚但缓存已经变化。
| 场景 | 顺序关注点 | 原因 |
|---|---|---|
| 读缓存 | 缓存尽量在事务外 | 命中时少开事务 |
| 写库删缓存 | 缓存动作要考虑事务提交 | 避免回滚后缓存被误删 |
| 审计日志 | 看是否要记录失败请求 | 决定放在外层还是内层 |
| 权限校验 | 通常早于事务 | 无权限请求尽早拒绝 |
五、异常处理切面会改变链路语义
多个切面中最危险的是异常处理。如果某个切面吞掉异常并返回默认值,外层事务切面可能认为调用成功,从而提交事务。这会导致原本应该回滚的数据被提交。
@Around("@annotation(Fallback)")
public Object fallback(ProceedingJoinPoint pjp) {
try {
return pjp.proceed();
} catch (Exception e) {
return null; // 危险:吞异常
}
}
如果这段 fallback 包在事务内部,目标方法抛出的异常被吞掉,事务边界可能看不到失败。面试里可以强调:异常切面必须明确哪些异常能降级、哪些必须继续抛出,尤其不能随便吞数据库写入异常。
危险链路:
Transaction begin
Fallback catches exception -> return null
Transaction commit
异常顺序和异常传播,往往比 before/after 顺序更影响数据一致性。
六、多个代理对象叠加时怎么理解
有些系统不仅有 Spring AOP,还可能有 RPC 代理、Mapper 代理、事务代理、监控代理。调用方看到的是一个对象,但内部可能经过多层代理。
Controller
-> Service AOP Proxy
-> Transaction Interceptor
-> MyBatis Mapper Proxy
-> SQL Executor
每一层代理解决不同问题。Service 代理负责事务和业务切面,Mapper 代理负责把接口方法转成 SQL 执行,RPC 代理负责网络调用。排查问题时要明确当前讨论的是哪一层代理,否则容易把 MyBatis 的 Mapper 代理和 Spring AOP 代理混为一谈。
七、如何测试切面顺序
切面顺序不能只靠“感觉配置对了”。可以写小型集成测试记录执行顺序,或在关键切面里用日志/Trace 标记进入和退出。
期望日志:
Trace before
Permission before
Tx begin
Target
Tx commit
Permission after
Trace after
测试里可以用一个共享列表记录事件:
events.add("trace-before");
Object result = pjp.proceed();
events.add("trace-after");
对于事务、缓存、异常降级这类敏感切面,最好覆盖成功、失败、缓存命中、缓存未命中 4 类路径。只测成功路径,很容易漏掉异常传播顺序问题。
八、常见误区与追问
- 误区:多个切面只要都执行,顺序不重要。 顺序会影响事务范围、缓存命中、异常传播和资源占用。
- 误区:@Order 数字越大越先执行。 Spring 中通常数值越小优先级越高,进入时越早,退出时越晚。
- 误区:日志切面永远应该最里面。 如果要记录整体耗时,日志或 Trace 往往应该在外层。
- 追问:权限和事务谁先执行? 通常权限先于事务,非法请求尽早拒绝,减少无意义事务资源占用。
- 追问:缓存和事务顺序怎么考虑? 读缓存可放事务外,写缓存或删缓存要考虑事务提交时机。
- 追问:异常被切面吞掉有什么风险? 外层事务可能看不到异常而提交,导致数据一致性问题。
- 追问:怎么验证切面顺序? 用集成测试、事件列表、Trace 日志验证 before/after/exception 路径。
九、加强记忆
切面顺序记住“外层先入后出”。权限尽量早拦,事务包住真正写库,缓存看命中和提交语义,日志看要统计整体还是局部;异常切面不能随意吞异常,因为它可能改变事务提交和回滚结果。