AOP 实战四件套:日志、权限、限流与缓存切面

bee2026-10-0880 分钟0 次阅读
四个可直接抄进项目的切面:注解驱动的操作日志、权限校验、简易令牌桶限流、缓存加速——每个都给完整代码与踩坑说明。
1 / 111
小节
〇、30 秒看懂
2 / 111

前两篇讲的是「切点怎么写」「代理怎么造」,这一篇只干一件事:把原理落成三个你明天就能抄进项目的真实切面——操作日志、耗时统计、权限校验。它们的共同套路只有一句:用一个自定义注解声明「哪个方法要这个能力」,再用一个切面实现「这个能力怎么做」。业务代码只多一行注解,规则集中在一个类里,改一次全项目生效。

3 / 111

这一篇你会反复遇到那五个词,这里一次性把它们钉在生活场景上。连接点(join point)= 一次真实发生的方法调用,像快递架上那个正在被扫描的包裹;切点(pointcut)= 决定哪些包裹进哪条滑道的分拣规则;通知(advice)= 滑道旁工人多做的那道工序,像给手机套壳时按下去的那层按键保护;切面(aspect)= 「规则 + 工序」打包成的那台机器;织入(weaving)= 把这台机器接到传送带上的动作,好比过安检——旅客(业务代码)本身没变,但每个人出门前都必须先经过同一个闸机。

4 / 111
类比

洋葱讲的是「多个切面叠在一起时的顺序」。一次调用像一颗洋葱:最外层的皮最先被刀碰到,却最后一个被剥下来;最里层的皮最后进场,却第一个被剥掉,而洋葱芯就是目标方法。所以 @Order(1) 的日志切面先进后出,@Order(2) 的耗时切面后进先出,日志那条「总耗时」永远比耗时切面自己算出来的大一点点——差的就是外层壳的开销。谁离芯越近,谁越早结束,这句话能推出全部顺序题。

5 / 111
类比

机场过安检讲的是「权限切面为什么要拦在门外」。@Before 型权限校验就是闸机:证件不对(缺权限码)当场拒绝,人根本进不了候机楼,也就不会产生后续成本。位置必须摆对——太靠外(排在日志切面之前)会被拒绝的旅客也记了一条「已放行」;太靠里(排在事务之后)就变成「已经办了登机牌、开了座位、启动了引擎」才说证件不行,白烧一次连接。这就是第六节那张 @Order 表的由来。

6 / 111
架构图
图 · 本篇地图:四种通知该用在什么场景
图 · 本篇地图:四种通知该用在什么场景
7 / 111

学完这一节,你要能回答三个问题:

8 / 111
  1. 我要记录返回值和耗时,为什么只能用 @Around?只想「不合格就挡掉」,为什么反而不该用它?
  2. 三个切面同时命中一个方法,@Order 该怎么排?排错会引发哪一种「功能正常、语义错了」的事故?
  3. 环绕通知里 try/catch 到底该怎么写,才不会顺手把事务的回滚能力吃掉?
9 / 111
小节
一、通用套路:注解 + 切面
10 / 111

前面几节把原理讲透了,这一节直接上菜。四个切面在结构上共享同一套套路:用一个自定义注解声明「哪些方法需要这个能力」,再用一个切面实现「这个能力到底怎么做」。

11 / 111

为什么这是最优雅的组合?因为它把「意图」和「实现」彻底分开了:

12 / 111
  • 业务代码只多一个注解,语义自解释:@OpLog("删除用户") 一眼就知道这是在记操作日志
  • 切面代码只写一次,贴在注解上,所有需要它的方法自动获得能力
  • 两者通过注解耦合,谁都不认识谁——换一套实现,业务代码一个字都不用改
13 / 111

先看注解长什么样。自定义注解的核心是「作用目标」和「生命周期」两个元注解:

14 / 111
代码对照
代码java
package com.example.anno;import java.lang.annotation.*;@Target(ElementType.METHOD)          // 贴在方法上@Retention(RetentionPolicy.RUNTIME)  // 运行期可读,切面才拿得到@Documentedpublic @interface OpLog {    String value() default "";       // 操作描述,如「删除用户」    boolean recordArgs() default true;   // 是否记录入参}
解读
  • @Target(METHOD) 限定只能贴方法,防止误用到类或字段上
  • @Retention(RUNTIME) 是硬性要求:切面在运行期通过反射读注解,写成 CLASS 就读不到了
  • value() 给了默认值,于是 @OpLog 和 @OpLog("删除用户") 都是合法写法

提示:四个切面的注解可以放进同一个 anno 包里统一管理。命名上用动词短语(OpLog / RequirePerm),一眼能读出「这个注解要求什么」,比 LogAnnotation 这类名字更清楚。

15 / 111
小节
二、切面一:操作日志 @OpLog
16 / 111

需求:记录「谁在什么时候、调用了什么方法、参数是什么、返回了什么、耗时多少」。这正好是 @Around 的主场——只有它能同时拿到入参和返回值。

17 / 111
架构图
图 1 · 四类切面落地图
图 1 · 四类切面落地图
18 / 111
代码对照
代码java
package com.example.aspect;import com.example.anno.OpLog;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.*;import org.aspectj.lang.reflect.MethodSignature;import org.slf4j.*;import org.springframework.stereotype.Component;import org.springframework.web.context.request.*;import jakarta.servlet.http.HttpServletRequest;import java.util.Arrays;import java.util.UUID;@Aspect@Componentpublic class OpLogAspect {    private static final Logger log = LoggerFactory.getLogger(OpLogAspect.class);    @Around("@annotation(opLog)")    public Object around(ProceedingJoinPoint pjp, OpLog opLog) throws Throwable {        // 生成 traceId,串联这一次请求的所有日志        String traceId = UUID.randomUUID().toString().substring(0, 8);        MDC.put("traceId", traceId);        long start = System.nanoTime();        String op = opLog.value().isEmpty()                ? ((MethodSignature) pjp.getSignature()).getMethod().getName()                : opLog.value();        try {            Object result = pjp.proceed();            if (opLog.recordArgs()) {                log.info("[{}] 操作={} 参数={} 返回={} 耗时={}ms 操作人={}",                        traceId, op, Arrays.toString(pjp.getArgs()), result,                        (System.nanoTime() - start) / 1_000_000, currentUser());            }            return result;        } finally {            MDC.clear();   // 线程复用场景下必须清理,否则 traceId 串味        }    }    private String currentUser() {        ServletRequestAttributes attrs =                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();        if (attrs == null) {            return "system";   // 异步 / 定时任务里没有请求上下文        }        HttpServletRequest req = attrs.getRequest();        Object user = req.getSession().getAttribute("LOGIN_USER");        return user == null ? "anonymous" : user.toString();    }}
解读
  • @annotation(opLog) 把方法上的 @OpLog 实例绑定到 OpLog opLog 形参——切点直接限定「只拦带这个注解的方法」
  • pjp.getArgs() 拿到入参数组,proceed() 的返回值就是出参,前后时间戳差就是耗时,一次全拿到
  • 用 MDC 打上 traceId,同一次调用的所有日志都会带上它,排查时一条 grep 就能串起全链路
  • finally 里 MDC.clear() 不能省:Web 容器复用线程,不清就会把上一个请求的 traceId 带到下一个

要点:日志里不要把密码、身份证这类敏感字段原样打出来。Arrays.toString(pjp.getArgs()) 会无差别输出所有参数,生产上建议按字段白名单或脱敏后再记录。

19 / 111
小节
三、切面二:权限校验 @RequirePerm
20 / 111

需求:调用方法前先看当前用户有没有权限,没有就抛业务异常。这类「不满足条件就拦住」的场景,@Before 就够用——它拿不到返回值,但也不需要。

21 / 111
java
package com.example.anno;import java.lang.annotation.*;@Target({ElementType.METHOD, ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface RequirePerm {    String value();   // 需要的权限码,如 "user:delete"}
22 / 111
java
package com.example.aspect;import com.example.anno.RequirePerm;import com.example.exception.ForbiddenException;import org.aspectj.lang.JoinPoint;import org.aspectj.lang.annotation.*;import org.springframework.stereotype.Component;import java.util.Set;@Aspect@Componentpublic class PermAspect {    // 假设权限从登录态里取,这里用静态方法示意    @Before("@annotation(requirePerm)")    public void check(JoinPoint jp, RequirePerm requirePerm) {        Set<String> owned = CurrentUser.permissions();        if (!owned.contains(requirePerm.value())) {            throw new ForbiddenException("缺少权限: " + requirePerm.value());        }    }}
23 / 111
代码对照
代码java
package com.example.exception;public class ForbiddenException extends RuntimeException {    public ForbiddenException(String message) {        super(message);    }}
解读
  • @Before 在任何一步业务逻辑之前执行,抛出异常即「拦在门外」,目标方法根本不会被执行
  • @Target 同时允许方法和类:贴在类上是「这个类所有方法都要这个权限」,切点用 @annotation 只识别方法级,类级要用 @within
  • 用自定义的 ForbiddenException 而不是随便抛 RuntimeException,才能让全局异常处理器把「403」和「500」区分开

说明:权限校验切面应该排在日志切面之内、事务切面之外。如果它排在最外层,被拒绝的请求也会被记一条「成功」日志;如果排在事务之内,就会在已经开启事务后才失败,白白浪费一次连接。

24 / 111
小节
四、切面三:接口限流 @RateLimit
25 / 111

需求:限制某个接口的调用频率。下面用 ConcurrentHashMap + 时间窗口实现一个简易计数器版本,便于理解原理:

26 / 111
java
package com.example.anno;import java.lang.annotation.*;@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface RateLimit {    int limit() default 10;      // 窗口内允许的次数    int seconds() default 1;     // 窗口长度(秒)}
27 / 111
代码对照
代码java
package com.example.aspect;import com.example.anno.RateLimit;import com.example.exception.TooManyRequestsException;import org.aspectj.lang.annotation.*;import org.springframework.stereotype.Component;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;@Aspect@Componentpublic class RateLimitAspect {    // key = 方法标识 + 调用方,value = 当前窗口的计数与起点    private final Map<String, Window> windows = new ConcurrentHashMap<>();    @Around("@annotation(rateLimit)")    public Object limit(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {        String key = pjp.getSignature().toShortString() + "|" + CurrentUser.id();        long now = System.currentTimeMillis();        long span = rateLimit.seconds() * 1000L;        Window w = windows.compute(key, (k, old) -> {            if (old == null || now - old.start >= span) {                return new Window(now, 1);   // 新窗口,计数从 1 开始            }            old.count++;            return old;        });        if (w.count > rateLimit.limit()) {            throw new TooManyRequestsException("请求过于频繁,请稍后再试");        }        return pjp.proceed();    }    private static final class Window {        final long start;        int count;        Window(long start, int count) {            this.start = start;            this.count = count;        }    }}
解读
  • compute 是原子的,把「判断是否新窗口 + 计数」合成一步,避免并发下的竞态
  • 每个方法 + 每个用户的组合占一个 Window,窗口过期后自动重置计数
  • 超限直接抛异常,交给全局异常处理器返回 429
28 / 111

计数这件事讲三遍不如点一遍。六帧动画走完「一次被拒的调用」,注意第 ② 帧——判新旧和加一是同一次原子操作,写成两步就是在给并发留口子:

29 / 111
原理动画
动图 · 固定窗口限流是怎么数数的
动图 · 固定窗口限流是怎么数数的
30 / 111

limit 是这个切面唯一的数值旋钮,而它必须和 seconds 一起读才有意义。把它从 0 拖到 200,看每一档实际会发生什么:

31 / 111
参数调节台
调节台窗口里放多少次才算「没在限流」
@RateLimit.limit(配合 seconds)
10次/窗口当前 0 – 200
按人头算的正常业务额度
  • 典型对象:下单、发验证码、提现这类有副作用的接口
  • key 里带用户 ID,所以限的是「一个人」而不是「一台机器」
  • 超出即抛 TooManyRequestsException,由全局异常处理器转成 429
  • 这一档要配告警:被拒次数突增通常说明有人在做号池
下游压力25%
误伤正常用户10%
先问「这个接口允许一个人多快」,再往回推 limit 与 seconds——数字是结论,不是起点。
32 / 111
坑

这个内存版限流只在单机内有效,而且 windows 会无限增长——键是按「方法 + 用户」来的,用户一多内存就涨。生产环境请换成 Redis + Lua:用 INCR 配 EXPIRE 做固定窗口,或用 Lua 脚本实现令牌桶 / 滑动窗口,这样多实例共享同一份计数、也能自然过期。切面的写法可以照搬,只把存储换成 Redis 即可。

33 / 111
小节
五、切面四:缓存 @MyCache
34 / 111

需求:给查询方法加缓存,理解 Spring Cache 到底在做什么。手写一个 @MyCache 切面,key 生成、命中 / 未命中的流程立刻就清楚了:

35 / 111
java
package com.example.anno;import java.lang.annotation.*;@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface MyCache {    String key();      // 支持 SpEL,如 "#id"    int ttl() default 60;   // 过期秒数}
36 / 111
代码对照
代码java
package com.example.aspect;import com.example.anno.MyCache;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.*;import org.aspectj.lang.reflect.MethodSignature;import org.springframework.core.DefaultParameterNameDiscoverer;import org.springframework.expression.*;import org.springframework.expression.spel.standard.SpelExpressionParser;import org.springframework.expression.spel.support.StandardEvaluationContext;import org.springframework.stereotype.Component;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;@Aspect@Componentpublic class MyCacheAspect {    private final ExpressionParser parser = new SpelExpressionParser();    private final DefaultParameterNameDiscoverer names = new DefaultParameterNameDiscoverer();    private final Map<String, Entry> store = new ConcurrentHashMap<>();    @Around("@annotation(myCache)")    public Object cache(ProceedingJoinPoint pjp, MyCache myCache) throws Throwable {        MethodSignature sig = (MethodSignature) pjp.getSignature();        String key = sig.toShortString() + "#" + resolveKey(myCache.key(), sig, pjp.getArgs());        Entry hit = store.get(key);        long now = System.currentTimeMillis();        if (hit != null && now - hit.time < myCache.ttl() * 1000L) {            return hit.value;   // 命中:直接返回,目标方法不执行        }        Object value = pjp.proceed();   // 未命中:执行目标方法        store.put(key, new Entry(value, now));        return value;    }    private String resolveKey(String spEL, MethodSignature sig, Object[] args) {        StandardEvaluationContext ctx = new StandardEvaluationContext();        String[] params = names.getParameterNames(sig.getMethod());        for (int i = 0; params != null && i < params.length; i++) {            ctx.setVariable(params[i], args[i]);        }        return String.valueOf(parser.parseExpression(spEL).getValue(ctx));    }    private record Entry(Object value, long time) {}}
解读
  • key 的生成是缓存的灵魂:这里用「方法签名 + SpEL 求值结果」拼出唯一键,@MyCache(key = "#id") 就把 id 参数算进 key
  • 命中直接返回,目标方法完全不执行;未命中才 proceed(),并把结果写回缓存
  • 这与 Spring Cache 的思路一致:@Cacheable 的 key 也支持 SpEL,底层同样是「查缓存 → 未命中调方法 → 写缓存」

提示:真实项目直接使用 Spring 的 @Cacheable + RedisCacheManager 即可,不必自己造。手写这个切面的价值在于理解「为什么缓存的 key 设计错了会导致脏数据」——key 少了参数,不同入参就会互相串数据。

37 / 111

resolveKey 那七行就是 Spring Cache 里 key = "#root.methodName + '#' + #id" 这类表达式的真实运行方式:把形参名逐个 setVariable 进上下文,然后交给 SpEL 求值。切到 collection 能看到 #users.![id](投影)与 #users.?[status=='VIP'](筛选)怎么算,切到 fail 能看到最经典的三种写错分别抛什么——这三条也正是 @Cacheable 里最常见的报错:

38 / 111
内核实验
TeaVM缓存 key 里的 #id 是怎么被算出来的未启动
literal 看 #root / #this / #参数名 的分工,collection 看投影与筛选,fail 看写错的三种真实报错
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
39 / 111
小节
六、多切面顺序:@Order 决定嵌套层级
40 / 111

四个切面可能同时命中一个方法。谁先进谁先出,由 @Order 决定:数字越小越外层。下面是一套推荐的顺序:

41 / 111
对照表
切面@Order位置理由
操作日志1最外层无论后面成功失败都要记录
限流2次外层早拒绝,省掉后续所有开销
权限3中间层拒绝时不必开启事务
事务—最内层(贴近目标)只包住真正的业务逻辑
42 / 111
原理动画
动图 · 多切面嵌套顺序
动图 · 多切面嵌套顺序
43 / 111
代码对照
代码java
@Aspect@Order(1)@Componentpublic class OpLogAspect { /* 最外层:日志 */ }@Aspect@Order(2)@Componentpublic class RateLimitAspect { /* 次外层:限流 */ }@Aspect@Order(3)@Componentpublic class PermAspect { /* 中间层:权限 */ }
解读
  • 进入顺序:日志 → 限流 → 权限 → 事务 → 目标方法
  • 返回顺序正好相反:目标方法 → 事务 → 权限 → 限流 → 日志
  • 把「越早能拒绝、越省资源」的切面放得越靠外,是排序的核心原则
44 / 111
小节
七、切面与事务的风险:环绕通知吞异常
45 / 111

这是切面把事务搞坏的最经典姿势。

46 / 111
java
// 错误写法:@Around 里 try/catch 吞掉了异常@Around("@annotation(opLog)")public Object wrong(ProceedingJoinPoint pjp, OpLog opLog) throws Throwable {    try {        return pjp.proceed();    } catch (Exception e) {        log.error("方法执行失败", e);        return null;          // 异常被吃掉,事务切面永远看不到它    }}
47 / 111
代码对照
代码java
// 正确写法:记录后原样抛出,让事务拿到异常才能回滚@Around("@annotation(opLog)")public Object right(ProceedingJoinPoint pjp, OpLog opLog) throws Throwable {    try {        return pjp.proceed();    } catch (Throwable e) {        log.error("方法执行失败", e);        throw e;              // 关键:继续向上抛,事务边界才能感知失败    }}
解读
  • 上面的「错误写法」里,异常被 catch 吞掉并返回了 null,外层事务切面看到的是「正常返回」,自然不会回滚
  • 「正确写法」把异常原样 throw 出去,事务切面才能执行回滚
  • 一句话:环绕通知要做的是「记录并转发」,不是「消化异常」。真要吞异常,就必须自己承担事务一致性

坑:@Around 的 catch 里如果写的是 catch (Exception e),会漏掉 Error 和部分 Throwable。既然环绕通知处在链路上,捕获范围就该足够宽,或者干脆只 log 不动异常。

48 / 111

这个坑的成因只有一句话:吞异常的那一层,是不是站在事务边界的里面。同一张图把两种摆法并排放在一起——左边会写出脏数据,右边什么都不会坏:

49 / 111
架构图
图 · 吞异常的切面在事务内 vs 在事务外
图 · 吞异常的切面在事务内 vs 在事务外
50 / 111

想彻底记住它,就得跟着一次「抛异常」逐层走。下面是单步台,右边同步刷新此刻的事务状态;连点下一步,盯住第 ⑥ 步那句 return null——它就是脏数据诞生的地方:

51 / 111
单步调试台
单步台一次被吃掉的异常是怎么把事务骗过去的1 / 7
按 ①→⑧ 点下一步,重点看第 6 步之后事务状态为什么还是 ACTIVE——它压根没收到异常
被调试的代码
1orderService.createWithLog(dto); // ① 调用打到代理,先进事务
2[事务 @Order(0)] tx.begin(); // ② 事务在最外层,边界比日志宽
3[日志 @Order(100)] try { // ③ 日志切面贴着目标方法
4 Object r = pjp.proceed(); // ④ 再往里走,才是目标
5 [target] orderRepository.save(order); // ⑤ 这里抛 DataIntegrityViolationException
6} catch (Exception e) { log.error(「执行失败」, e); return null; } // ⑥ 异常在这一层被吃掉
7// 控制权回到事务切面:proceed() 正常返回了一个 null,没有任何异常
8[事务 @Order(0)] tx.commit(); // ⑦ 它没看见失败,于是提交
9return null; // ⑧ 调用方以为「成功了但没数据」
此刻的变量
orderServiceOrderServiceImpl$$SpringCGLIB$$0
切面事务(外层) + 日志(内层)
调用栈
1OrderController.create
2proxy.createWithLog
1先确认两个前提:调用经过了代理(否则两个切面都不生效),以及这里的排布是「事务在外、日志在内」。第六节推荐表把日志放最外层是另一种排布,最后一步会说它错在哪。
52 / 111
小节
八、坑:RequestContextHolder 要有空值保护
53 / 111
坑

切面里想拿当前请求,最常见的是 RequestContextHolder.getRequestAttributes()。但这个方法在异步线程、定时任务、MQ 消费、单元测试里统统返回 null——因为请求上下文默认存在 ThreadLocal 里,换线程就丢了。直接强转再 getRequest() 会当场 NullPointerException。所以每个从上下文取请求的切面,都必须先判空:拿不到就降级(比如记录为 system),而不是崩掉。同理,SecurityContextHolder 在异步线程里也拿不到登录态,需要用 DelegatingSecurityContextRunnable 之类的包装器传递。

54 / 111
小节
九、动手体验:通知链与自调用陷阱
55 / 111

下面这个演示先展示正常的通知链,再切到「自调用」,可以直观看到——一旦方法被同类内部调用,所有通知会全部失效,因为调用根本没经过代理:

56 / 111
内核实验
TeaVM亲手验证通知链与自调用陷阱未启动
先看正常链路,再切到「自调用」看通知如何全部失效
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
57 / 111

第七节那段单步台讲的是「异常往哪走」,这一支是它的对照实验:同一段代码,error 模式下 @AfterThrowing 有没有把异常原样放出去、@Around 的 catch 会不会截断它。看完这一支再回头看第七节的 throw e,就知道那一行为什么是纪律而不是风格:

58 / 111
内核实验
TeaVM异常路径上,谁接住了它未启动
对照 @AfterThrowing 与 @Around 两种写法:一个记录后继续上抛,一个把链条掐断
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
59 / 111
小节
十、决策:限流放网关还是放切面
60 / 111
决策
决策你要给一批高流量接口加限流。团队讨论后有两个方案——放在网关(Spring Cloud Gateway),还是放在应用内的 `@RateLimit` 切面?怎么选?
61 / 111
小节
十一、上手体验:把三个切面的顺序与织入跑通
62 / 111

这一节的四个实验分别对应本篇的四条主线:顺序(aoporder)、在哪拦(pointcut)、织入前后差什么(weave)、日志这条链本身怎么落地(logchain)。

63 / 111

第一个实验是第六节那张 @Order 表的动态版。四个分支建议全点一遍:先看 @Order 生效 建立「小的在外层」的直觉,再看 嵌套执行时序 把完整链条一步步列出来,然后看 顺序颠倒的坑 明白忘写 @Order 为什么等于把顺序交给运气,最后看 @Transactional 与自定义切面——它解释了一个真实事故:消息已经发出去、数据还没提交:

64 / 111
内核实验
TeaVM洋葱到底谁在外面:多切面嵌套顺序未启动
四个分支全点一遍,重点记第⑪步那条完整链条
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
65 / 111

第二个实验解决「这三个切面各该拦谁」。操作日志用 @annotation(opLog) 精确到方法、权限用 @within 覆盖整类、耗时统计用 execution( com.example.service...*(..)) 扫全包——三种写法命中范围完全不同,切到对应分支逐一对照:

66 / 111
内核实验
TeaVM三个切面三种切点:命中范围差多少未启动
再切到 execution 各段,看扫全包的表达式是哪几段在起作用
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
67 / 111

第三个实验回答「加了切面到底赚了什么、付了什么」。切到「没有切面时」你会看到调用栈只有一层但方法体被 log/try-catch/计时搅浑;切到「织入切面后」同样一次调用多了三层横切,而业务代码一行未改;「通知链内部」则展示 proceed() 是怎么一层层把控制权交下去又还回来的:

68 / 111
内核实验
TeaVM织入前后 + 通知链内部未启动
务必看 chain 分支的第⑥⑦步:忘调 proceed() 的症状就是第十五节第二档要你亲手复现的
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
69 / 111

第四个实验补上实战里最容易被忽略的一环:日志切面打印出来的那行字,究竟是怎么变成文件里的一行、又怎么带上 traceId 的。切到「输出格式怎么拼」能看到 %X{traceId} 的位置,切到「级别过滤」能理解为什么生产环境把 INFO 降成 WARN 就能让审计日志瞬间消失:

70 / 111
内核实验
TeaVM日志切面的下游:这行日志去了哪儿未启动
重点看 pattern 与 level 两支,它们决定了第二档里「为什么我加了日志却看不见」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
71 / 111
原理动画
动图 · 洋葱式嵌套执行时序
动图 · 洋葱式嵌套执行时序
72 / 111

四支实验跑完,本篇的四个配方就只剩下「遇到需求该抄哪一招」这一件事。这一局左边是真实需求(或真实事故),右边是它对应的写法:

73 / 111
配对闯关
闯关需求 ↔ 该用哪一招已配对 0/6 · 配错 0
六个需求都来自本篇前四节的场景,右边是各自的正确写法;配错会告诉你为什么那一招在这里不管用
先点左边一个
74 / 111

实验做到这里,可以换成命令行自己敲。这台控制台连着浏览器里的同一个容器,回显全部由内核算出来——boot 起容器,beans 看谁被套壳,剩下的按本篇主线一条一条敲:

75 / 111
内核控制台
76 / 111
小节
十二、沙盘:三个切面的 @Order 怎么排
77 / 111

左边选一套顺序方案,右边立刻给出「进入序列 / 退出序列 / 会不会出事」。同一批切面,仅仅换个数字,就会出现「被拒绝的请求也记了一条成功日志」「事务里白锁了行」这类功能正常、语义错了的事故:

78 / 111
沙盘
沙盘@Order 排列沙盘:日志 / 限流 / 权限 / 事务
运行结果
enter LOG(1) -> RATE(2) -> AUTHZ(3) -> TX -> target
exit TX -> AUTHZ -> RATE -> LOG
[LOG] op=下单 args=[42] result=Order(id=42) took=86ms user=zhang
推荐位:日志在最外层,所以它记录的是「包含限流+鉴权+事务」的完整用户视角耗时。
79 / 111
说明

沙盘里 no-order 那一格值得多说一句。@Order 不写不代表「默认最外层」或「默认最先」,而是取 Ordered.LOWEST_PRECEDENCE(也就是 Integer.MAX_VALUE);多个切面同为最大值时,Spring 只能按收集到的先后排,而这个先后来自组件扫描遍历、@Configuration 声明顺序和自动配置排序结果——三者都可能随环境与构建变化。唯一的解法是每个切面都显式写数字,并且留出间隔(10、20、30),以后插新切面不必整体重排。

80 / 111
小节
十三、常见报错速查
81 / 111

以下片段都可整段粘进搜索框;这张表专门收「实战切面才会遇到」的那几类:

82 / 111
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
接口返回 200 但 data 为 null、数据库没新增行、下游 MQ 没收到消息,且全程无任何异常@Around 里忘了调 pjp.proceed():通知链在这一层断了,目标方法一次都没执行在 @Around 第一行打断点确认 proceed() 是否被调到;写法定死:方法体里必须有 return pjp.proceed();,其余逻辑围绕它包 try/finally#13 @AspectJ 细节第八节 · 本篇第十一节 weave 实验 chain 分支
BeanCurrentlyInCreationException: Error creating bean with name 'permAspect': Requested bean is currently in creation: Is there an unresolvable circular reference?切面里 @Autowired 注入了某个 Service,而那个 Service 又要被这个切面代理,于是「切面 → Service → 切面的代理决策」形成环首选把业务查询下沉成一个不依赖被切 Bean 的小组件;确实要用 Service 就在字段上加 @Lazy(配合 ObjectProvider<T> 更好),让注入发生在真正需要时#10 循环依赖 · #14 AOP 内核第十一站
日志顺序在不同机器 / 不同次启动之间飘移(@Order 没写导致顺序随机)未标注 @Order 也未实现 Ordered 的切面一律取 Ordered.LOWEST_PRECEDENCE,相对顺序退化为 Advisor 收集顺序每个切面显式写 @Order(n) 并留大间隔(10、20、30);在集成测试里断言日志序列,而不是单测本篇第十二节沙盘 no-order · #13 第六节
事务「不生效」:数据写了进去,异常也已经抛出,但没有任何回滚排在事务外层的 @Around 在 catch 里把异常吃掉(return null),外层事务边界看到的是正常返回环绕通知只「记录并原样转发」:catch (Throwable e) { log.error(...); throw e; };确需吞掉时,自己承担一致性本篇第七节 · #31 事务内幕
NullPointerException 出现在切面里 (ServletRequestAttributes) RequestContextHolder.getRequestAttributes() 之后异步线程、定时任务、MQ 消费、单元测试里没有请求上下文,该方法返回 null;上下文存在 ThreadLocal,换线程即丢先判空再取,拿不到就降级为 system;跨线程传递登录态用 DelegatingSecurityContextRunnable 之类包装器本篇第八节 · #40 异步与定时
java.lang.ClassCastException: class com.sun.proxy.$Proxy42 cannot be cast to class com.example.service.UserServiceImpl你在切面里把 pjp.getTarget() 强转成实现类,但容器给的是 JDK 代理通过接口调用;或开 spring.aop.proxy-target-class=true(Boot 默认已 true);只想读字段就改用反射工具 AopUtils.getTargetClass()#14 AOP 内核第五节 · #12 动态代理
IllegalArgumentException: error at anonymous pointcut expression around this切点字符串写坏:@annotation(opLog) 里的变量名与形参不一致、括号不成对,或注解写成简单名逐段核对括号与全限定名;注解绑定用形参名原样拷贝#13 @AspectJ 细节第十三节
缓存切面命中后仍然把方法执行了一遍(或反过来:明明该刷新却返回旧值)key 生成漏了参数,不同入参互相串数据;或 TTL 判断写在 proceed() 之后key 必须包含「方法签名 + 全部影响结果的参数」;命中分支要在任何副作用之前直接 return本篇第五节 · #32 Redis 与缓存
敏感字段(密码、身份证)被原样打进操作日志Arrays.toString(pjp.getArgs()) 无差别输出所有参数按字段白名单序列化,或对指定字段脱敏;@OpLog(recordArgs = false) 也要保留为逃生开关本篇第二节要点
83 / 111
坑

@Around 的 catch 如果写成 catch (Exception e),会漏掉 Error 与其他 Throwable;但如果为了「不漏」而在 catch 里 return null,就把上面第一、第四行两个坑同时踩了。安全写法只有两种:要么根本不 catch、只在 finally 里收尾;要么 catch 宽到 Throwable 并且一定 throw e。

84 / 111

上面第二档「注入自身代理」的修法,如果忘了开开关,会给你一段非常直的栈——报错里连修法都写出来了,问题是新手读不出它在讲什么。练一次:

85 / 111
报错急救
报错急救IllegalStateException: Cannot find current proxy
用 AopContext.currentProxy() 却忘了 exposeProxy

为了绕开同类自调用失效,你把 this.createTwice() 改成 ((OrderService) AopContext.currentProxy()).createTwice(),本地编译通过,线上第一次调用就炸。

java.lang.IllegalStateException: Cannot find current proxy: Set 'exposeProxy' property on Advised to 'true' to make it available, and ensure that AopContext.currentProxy() is invoked in the same thread as the AOP invocation containing the self-reference
at org.springframework.aop.framework.AopContext.currentProxy(AopContext.java:95)
at com.example.order.service.OrderService.createTwice(OrderService.java:61)
at com.example.order.service.OrderService$$SpringCGLIB$$0.createTwice(<generated>)
at com.example.order.controller.OrderController.submit(OrderController.java:28)
at java.base/java.lang.Thread.run(Thread.java:840)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
86 / 111
小节
十四、随堂自测
87 / 111
随堂自测
随堂自测你的 `PermAspect` 里 `@Autowired` 注入了 `UserService` 用来查权限,而 `UserService` 又被同一个切面命中。启动时报 `BeanCurrentlyInCreationException`。最合理的修法是?
先自己选一个,选中立刻告诉你对不对
88 / 111
随堂自测
随堂自测三个切面都没有写 `@Order`。关于它们的执行顺序,下列哪一句是对的?
先自己选一个,选中立刻告诉你对不对
89 / 111
小节
十五、动手练习
90 / 111
小节
第一档 · 照做
91 / 111

目标:做一个能直接抄进项目的耗时统计切面,跑通「注解 → 切点绑定 → 分级日志 → 汇总」四步,并亲眼看到它与操作日志切面的嵌套顺序。以下代码放进一个 Spring Boot 项目(需 spring-boot-starter-aop)即可运行。

92 / 111
java
package com.example.anno;import java.lang.annotation.*;@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)   // 必须是 RUNTIME,否则切面反射读不到@Documentedpublic @interface Cost {    /** 业务动作名,用于日志与汇总,缺省用方法名 */    String value() default "";    long warnMs() default 200;    long errorMs() default 1000;}
93 / 111
java
package com.example.aspect;import com.example.anno.Cost;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.core.annotation.Order;import org.springframework.stereotype.Component;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.LongAdder;@Aspect@Component@Order(2)                    // 与 OpLogAspect(@Order(1)) 组成洋葱的两圈皮public class CostAspect {    private static final Logger log = LoggerFactory.getLogger(CostAspect.class);    private final Map<String, LongAdder> calls = new ConcurrentHashMap<>();    private final Map<String, LongAdder> totalMs = new ConcurrentHashMap<>();    @Around("@annotation(cost)")        // 变量名 cost 必须与下面的形参名完全一致    public Object around(ProceedingJoinPoint pjp, Cost cost) throws Throwable {        String action = cost.value().isEmpty() ? pjp.getSignature().getName() : cost.value();        long start = System.nanoTime();        calls.computeIfAbsent(action, k -> new LongAdder()).increment();        try {            return pjp.proceed();       // 必填项:不调它,目标方法一次都不会执行        } finally {            long ms = (System.nanoTime() - start) / 1_000_000;            totalMs.computeIfAbsent(action, k -> new LongAdder()).add(ms);            String line = "[COST] {} 耗时={}ms 阈值(warn={}, error={})";            if (ms >= cost.errorMs()) {                log.error(line, action, ms, cost.warnMs(), cost.errorMs());            } else if (ms >= cost.warnMs()) {                log.warn(line, action, ms, cost.warnMs(), cost.errorMs());            } else {                log.info(line, action, ms, cost.warnMs(), cost.errorMs());            }        }    }    /** 供练习与排查使用:打印累计值 */    public String summary() {        StringBuilder sb = new StringBuilder("[SUMMARY] ");        calls.forEach((k, v) -> sb.append(k).append("=").append(v.sum())                .append("次/").append(totalMs.get(k).sum()).append("ms "));        return sb.toString();    }}
94 / 111
java
package com.example.service;import com.example.anno.Cost;import org.springframework.stereotype.Service;@Servicepublic class OrderService {    @Cost("下单")    public Long create(String sku) throws InterruptedException {        Thread.sleep(50);                 // 模拟正常:INFO        return 42L;    }    @Cost(value = "对账", warnMs = 20)    public String reconcile() throws InterruptedException {        Thread.sleep(300);                // 超过 warnMs:WARN        return "done";    }    @Cost(value = "报表", errorMs = 100)    public String report() throws InterruptedException {        Thread.sleep(150);                // 超过 errorMs:ERROR        return "big-report";    }}
95 / 111
java
// 启动并依次调用三个方法@SpringBootApplicationpublic class CostDemoApplication {    public static void main(String[] args) throws Exception {        try (ConfigurableApplicationContext ctx =                     SpringApplication.run(CostDemoApplication.class, args)) {            OrderService svc = ctx.getBean(OrderService.class);            svc.create("A-1");            svc.reconcile();            svc.report();            System.out.println(ctx.getBean(com.example.aspect.CostAspect.class).summary());        }    }}
96 / 111

预期日志(时间戳与前缀省略):

97 / 111
代码对照
代码text
[COST] 下单 耗时=51ms 阈值(warn=200, error=1000)[COST] 对账 耗时=301ms 阈值(warn=20, error=1000)[COST] 报表 耗时=151ms 阈值(warn=200, error=100)[SUMMARY] 下单=1次/51ms 对账=1次/301ms 报表=1次/151ms
解读
  • 三行的级别分别是 INFO / WARN / ERROR,用 IDE 的控制台颜色一眼可辨
  • @Order(2):如果你再把第二节的 OpLogAspect 标成 @Order(1) 并贴在同一个方法上,日志顺序必然是 [AROUND-IN(OpLog)] → [COST-IN?] …… → [COST] → [AROUND-OUT(OpLog)],即洋葱的两圈皮
  • @annotation(cost) 的变量名与形参 Cost cost 完全一致——把它改成 Cost c 就会当场报绑定失败,这正是第十四节第一题的另一半答案
98 / 111
小节
第二档 · 变体
99 / 111
  1. 把 CostAspect 的 @Order(2) 删掉,同时给 OpLogAspect 也只留 @Component。你会观察到:连续重启三次,进入与退出顺序可能变化;一旦日志被排到耗时切面内层,[COST] 的数值会比实际用户感受小一截——这就是第十二节沙盘 no-order 那格的现场版。
  2. 把 around 里的 return pjp.proceed(); 换成 return 999L;。你会观察到:OrderService#create 里的 Thread.sleep 不再发生(耗时瞬间变成 0ms),返回值变成 999,而程序没有任何异常。顺手体会缓存切面为什么「故意这么做」是正确的。
  3. 给 create 加一个 @Transactional,并在 report() 里抛 IllegalStateException,同时把 CostAspect 的 @Order 改成 -1(比事务更外层)。你会观察到:[COST] 统计到的耗时包含了提交/回滚时间;再把 finally 改成 catch (Exception e) { log...; return null; },脏数据竟然落进了库——因为异常没能传到事务边界。这条就是本节点的铁律。
  4. 把 logging.level.com.example.aspect=warn 设进配置文件。你会观察到:INFO 那行彻底消失,只剩 WARN 与 ERROR。回到第十一节的 logchain 实验看「级别过滤」,就知道这不是切面没跑,而是被门挡在了外面。
100 / 111
小节
第三档 · 造一个
101 / 111

把本篇三个切面合成一套最小可上线的操作审计套件:@OpLog(操作日志)+ @Cost(耗时)+ @RequirePerm(权限),并让它们互不打架。

102 / 111
  • 三个切面各自显式 @Order:日志 10、权限 20、耗时 30(间隔留大,方便以后插队)
  • 统一 traceId:由最外层日志切面写入 MDC,另外两个切面只读不写,finally 里由最外层负责 MDC.remove("traceId")(不要用 clear(),会误伤别人的键)
  • 权限切面禁止依赖任何会被自己代理的 Bean:把「查权限」抽成 PermissionReader 接口,用一个不命中切点的实现类
  • 提供一个 /actuator 之外的简易端点或启动时打印:三个切面各自的命中次数与被拦截次数
  • 敏感字段脱敏:@OpLog(recordArgs = false) 之外再加一个 String[] maskFields(),对被标记的字段打印 ***
103 / 111

验收清单:① 一次正常调用产生三条带同一 traceId 的日志,顺序符合洋葱模型;② 一次越权调用不产生任何数据库写入,且审计日志里有「谁被拒了」的记录;③ 关掉任意一个切面(去掉它的 @Component),另两个行为与日志完全不变,证明彼此无耦合;④ 写一个集成测试断言这三行日志的顺序,删掉某个 @Order 后该测试必须失败——这样顺序回归才能被 CI 拦住。

104 / 111
小节
十六、要点自查
105 / 111
自检

不看上文,说出「注解 + 切面」这套套路为什么能把意图与实现分开,以及 @Retention(RUNTIME) 在这里为什么是硬性要求。

106 / 111
自检

三个切面同时命中一个方法时,推荐的 @Order 排法是什么?理由分别一句话。

107 / 111
自检

@Around 里正确的异常处理姿势只有两种,分别是什么?为什么第三种(catch 后 return null)会连带弄坏事务?

108 / 111
自检

切面里 @Autowired 注入一个 Service 会带来什么风险?两种解法分别是什么?

109 / 111
自检

从 RequestContextHolder 取请求为什么要判空?哪些线程里它一定是 null?

110 / 111
口诀

想改行为用 Around,只想观察用其余;越能早拒绝,越往外层放;Around 必须 proceed,异常原样往上抛。

111 / 111
总结

这一节给了四个可直接复用的切面——操作日志用 @Around(记录操作人、参数、返回值、耗时,用 MDC 串 traceId)、权限校验用 @Before(无权限抛业务异常)、接口限流用 @Around(ConcurrentHashMap + 时间窗口,生产换 Redis + Lua)、缓存用 @Around(SpEL 生成 key,命中即返回)。多切面用 @Order 排队,数字越小越外层;推荐的顺序是「日志 > 限流 > 权限 > 事务」。最后记住两条铁律:环绕通知记录后必须原样抛异常,否则事务不会回滚;从 RequestContextHolder 取请求必须判空,异步与定时任务里它一定是 null。