企业级接口日志模块:Spring 八股与 AOP 实战拆解
做外部系统对接的时候,最头疼的不是接口联调,而是出了问题之后的排查。
我们平台对接了好几个外部系统:构型管理平台推送任务单、消息推送系统同步预约数据、内网平台批量灌入场次信息。这些系统间调用每天几百次,但日志的可观测性几乎为零。排查问题的方式是:登录服务器翻 log 文件,在代码里搜 log.info("【CPDM同步】") 这样的硬编码片段,靠经验和运气定位。
后来我决定写一套统一的接口日志模块。写完之后回头看,发现实现它的每一步都踩在 Spring 八股上——注解的元信息怎么存储、AOP 怎么拦截方法、JDK 动态代理和 CGLIB 有什么区别、ThreadLocal 怎么做链路追踪、线程池的拒绝策略怎么选。这些面试里背过的知识点,在项目里一个个变成了真实的代码。
这篇文章就从这个日志模块出发,把 Spring 八股和实战串起来讲。
为什么手动写日志行不通
① 六个真实痛点
在没有统一日志模块之前,代码里的日志是这样的:
// 有些地方用 log.info,带【CPDM同步】前缀
log.info("【CPDM同步】更新任务单: cpdmOid={}, orderNo={}", request.getCpdmOid(), order.getOrderNo());
// 另一个地方前缀变成了【同步API】
log.info("【同步API】收到任务单推送, 批次ID={}, 来源={}, 数量={}", ...);
前缀不统一、日志级别选择随意、关键信息漏记。审计代码之后,我总结出六个具体痛点:
- 日志散落各处:每个开发者的日志前缀不同,搜索困难
- 没有入站/出站区分:看到 【CPDM同步】 时,无法判断是 CPDM 推送过来的(入站),还是平台主动调 CPDM 的(出站)
- 没有关联业务 ID:任务单数据异常,想追溯是从哪个系统推送过来的——需要数十分钟人工拼凑
- 没有耗时统计:哪个接口慢、慢到什么程度,完全不知道
- 没有失败重试依据:失败记录没有持久化,无法统计失败率、提供重试数据
- 没有调用链路追踪:CPDM 推送任务单 -> 平台写入 -> 发送通知 -> 蓝信同步,这些调用之间没有统一的链路 ID
② 根本问题
这些痛点的根源不是「日志写得不够多」,而是日志记录的方式本身有问题。手动在每个方法里写 log.info(),本质上是把「日志应该记录什么」的决策权交给了每个开发者。人不是机器,总会遗漏、总会不一致。
需要一种方案,让日志记录变成「声明式」的——在方法上标注一下,日志就自动记录,内容标准化、格式统一、零遗漏。
方案选型:为什么是 AOP + 注解
① 四种方案对比
我评估了四种方案:
+—————-+————+————-+————+————+
| 维度 | 手动日志 | AOP + 注解 | 拦截器 | Filter |
+—————-+————+————-+————+————+
| 代码侵入性 | 极高 | 零 | 低 | 低 |
| 方法级粒度 | 支持 | 支持 | 不支持 | 不支持 |
| 业务参数捕获 | 手动 | 自动 | 部分 | 不支持 |
| 返回值捕获 | 手动 | 自动 | 不支持 | 不支持 |
| 异常捕获 | 手动 | 自动 | 支持 | 支持 |
| 业务语义标注 | 手动 | 注解声明 | 不支持 | 不支持 |
+—————-+————+————-+————+————+
② 为什么 AOP + 注解胜出
拦截器(Interceptor)和 Filter 都是 URL 级别的拦截,拿不到方法参数和返回值。而接口日志需要记录的是「谁调了什么方法、传了什么参数、返回了什么结果、花了多少时间」——这些只有 AOP 的方法级拦截才能做到。
注解的优势在于「声明式」。开发者只需要在方法上加一行 @InboundLog(sourceSystem = "CPDM", interfaceName = "任务单推送"),切面自动完成参数捕获、耗时统计、异常记录、异步写入。零侵入、零遗漏、格式统一。
AOP 代理的工作方式:
外部请求
|
v
代理对象(Spring 生成)
|
v
切面逻辑(记录开始时间、捕获参数)
|
v
目标方法执行
|
v
切面逻辑(记录返回值、计算耗时、异常处理)
|
v
异步写入日志表
注解设计——@InboundLog 和 @OutboundLog
① 注解的本质是什么
面试问「注解的原理」,大部分人能说出「注解是一种元数据」。但注解在 JVM 层面到底是什么?
注解本质上是一个接口。 当你写 @interface InboundLog 时,编译器会生成一个继承 java.lang.annotation.Annotation 的接口。注解的属性(如 sourceSystem())就是接口的抽象方法。
// 你写的代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface InboundLog {
String sourceSystem();
String interfaceName();
}
// 编译器生成的等价代码(伪代码)
public interface InboundLog extends java.lang.annotation.Annotation {
String sourceSystem();
String interfaceName();
}
三个元注解的作用:
- @Target(ElementType.METHOD):这个注解只能标注在方法上,不能标在类或字段上
- @Retention(RetentionPolicy.RUNTIME):注解信息保留到运行时——这是关键,AOP 切面需要在运行时通过反射读取注解属性
- @Documented:生成 Javadoc 时包含注解信息
Retention 的三种策略是面试常考的点:
| SOURCE | 编译时丢弃 | @Override、@SuppressWarnings |
| CLASS | 编译后保留在 .class 文件,但 JVM 不加载 | 字节码增强工具(AspectJ 编译期织入) |
| RUNTIME | 运行时可通过反射读取 | 日志注解、事务注解、权限注解 |
日志注解必须是 RUNTIME——因为 Spring AOP 是在运行时通过代理拦截方法,再通过反射读取注解属性。如果设成 SOURCE,运行时根本拿不到注解信息,切面就是瞎的。
② 属性设计的三个取舍
定义注解属性时,每个字段的设计都有取舍。
sourceSystem 用 String 而非枚举: 外部系统是动态的,今天接了3个,明天可能接第4个。用枚举意味着每次新增系统都要改枚举类、重新编译部署。用 String 就灵活得多——传什么记什么。
interfaceName 用业务语义而非 URL: URL 可能因为重构而改变(比如 /api/cpdm/push 改成 /api/v2/cpdm/task),但业务含义不变(永远是「CPDM 任务单推送」)。注解里用业务语义,URL 变了不影响日志的可读性。
bizIdParam 用参数名而非 SpEL: SpEL(Spring Expression Language)功能强大,但在注解中执行任意表达式有安全风险。参数名更简单、更安全,也更容易理解。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface InboundLog {
/** 来源系统标识(必填) */
String sourceSystem();
/** 接口名称(必填) */
String interfaceName();
/** 业务类型(可选) */
String bizType() default "";
/** 业务ID参数名,支持嵌套如 "request.cpdmOid"(可选) */
String bizIdParam() default "";
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OutboundLog {
String targetSystem();
String interfaceName();
String bizType() default "";
boolean logParams() default true; // 控制是否记录请求参数
boolean logResponse() default true; // 控制是否记录响应体
}
入站和出站分成两个注解,而不是用一个注解加 direction 属性——因为两者的必填字段不同(入站要 sourceSystem,出站要 targetSystem),分开定义语义更清晰。
AOP 切面——Spring 八股的核心战场
① AOP 是什么,为什么能拦截方法
AOP(面向切面编程)的定义到处都能查到,但面试时能说清楚「AOP 为什么能拦截方法」的人不多。
AOP 的本质是代理模式。当 Spring 容器检测到一个 Bean 的方法被切面匹配时,它不会直接返回目标对象,而是返回一个「代理对象」。外部调用者拿到的是代理,代理在调用目标方法前后插入额外逻辑(比如日志记录)。
正常调用(没有 AOP):
Controller –> 目标对象.method() –> 返回结果
AOP 拦截后:
Controller –> 代理对象.method()
|
v
切面逻辑(记录开始时间)
|
v
目标对象.method() –> 返回结果
|
v
切面逻辑(记录耗时、写日志)
AOP 的四个核心概念,面试几乎必问:
- 切面(Aspect):横切关注点的模块化。在项目里就是 InboundLogAspect 这个类
- 切点(Pointcut):匹配哪些方法需要被拦截。@annotation(inboundLog) 表示「标注了 @InboundLog 的方法」
- 通知(Advice):拦截后执行什么逻辑。@Around 表示在方法执行前后都插入逻辑
- 连接点(JoinPoint):程序执行过程中的某个点。Spring AOP 里就是方法调用
② JDK 动态代理 vs CGLIB
这是面试中 AOP 方向被问得最多的问题:「Spring AOP 用的什么代理?两种代理有什么区别?」
JDK 动态代理基于接口。它在运行时动态生成一个实现了目标接口的代理类,通过 InvocationHandler 拦截方法调用,内部通过反射调用目标方法。
// JDK 动态代理的核心原理
UserService target = new UserServiceImpl();
InvocationHandler handler = new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("Before: " + method.getName());
Object result = method.invoke(target, args); // 通过反射调用目标方法
System.out.println("After: " + method.getName());
return result;
}
};
UserService proxy = (UserService) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
handler
);
CGLIB 代理基于继承。它在运行时动态生成目标类的子类,重写目标方法,在重写方法中插入拦截逻辑。
// CGLIB 生成的代理类(伪代码)
public class UserServiceImpl$$EnhancerByCGLIB extends UserServiceImpl {
private MethodInterceptor interceptor;
@Override
public User findById(Long id) {
return (User) interceptor.intercept(this,
UserServiceImpl.class.getMethod("findById", Long.class),
new Object[]{id},
methodProxy -> super.findById(id)); // 直接调用父类方法
}
}
两者的区别:
+——————+—————————+—————————+
| 维度 | JDK 动态代理 | CGLIB |
+——————+—————————+—————————+
| 实现方式 | 基于接口 | 基于继承 |
| 要求 | 目标类必须实现接口 | 目标类不能是 final |
| 方法调用 | 反射(有性能开销) | 直接调用父类方法(更快) |
| Spring Boot 默认 | 不是 | 是(2.x 起) |
+——————+—————————+—————————+
Spring Boot 2.x 默认使用 CGLIB,配置项是 spring.aop.proxy-target-class=true。原因很实际:CGLIB 不要求目标类实现接口,使用门槛更低。而且 CGLIB 直接调用父类方法,不经过反射,性能也更好。
③ @Around vs @Before + @AfterReturning
通知类型有五种(@Before、@After、@AfterReturning、@AfterThrowing、@Around),但做日志切面时,@Around 是最佳选择。
如果用 @Before + @AfterReturning 的方式,有个棘手的问题:怎么在两个方法之间共享开始时间?
// @Before + @AfterReturning 的方式
@Before
public void before(JoinPoint jp) {
// 需要把开始时间存到某个地方
// 这个地方必须是线程安全的——因为多个线程可能同时调用
startTimeHolder.set(System.currentTimeMillis()); // ThreadLocal
}
@AfterReturning(pointcut = "…", returning = "result")
public void afterReturning(JoinPoint jp, Object result) {
long costTime = System.currentTimeMillis() – startTimeHolder.get();
// 记录日志…
startTimeHolder.remove(); // 必须清理,否则内存泄漏
}
这引入了 ThreadLocal,有内存泄漏风险——如果 afterReturning 因为异常没有执行,remove() 就不会被调用。
@Around 就干净得多:用局部变量就行,不需要 ThreadLocal,异常用 try-catch 统一处理。
@Around("@annotation(inboundLog)")
public Object around(ProceedingJoinPoint joinPoint, InboundLog inboundLog) throws Throwable {
long startTime = System.currentTimeMillis(); // 局部变量,线程安全
SysInterfaceLog interfaceLog = new SysInterfaceLog();
interfaceLog.setSourceSystem(inboundLog.sourceSystem());
interfaceLog.setInterfaceName(inboundLog.interfaceName());
interfaceLog.setRequestUrl(getRequestUrl());
interfaceLog.setRequestMethod(getRequestMethod());
interfaceLog.setRequestParams(JSON.toJSONString(joinPoint.getArgs()));
try {
Object result = joinPoint.proceed();
interfaceLog.setCallStatus(0); // 成功
interfaceLog.setResponseBody(JSON.toJSONString(result));
return result;
} catch (Throwable e) {
interfaceLog.setCallStatus(1); // 失败
interfaceLog.setErrorMessage(e.getMessage());
throw e; // 必须继续抛出,不能吞异常
} finally {
interfaceLog.setCostTime(System.currentTimeMillis() – startTime);
interfaceLogService.asyncSaveLog(interfaceLog); // 异步保存
}
}
对比一下:
+——————-+—————————+—————————+
| 特性 | @Before + @AfterReturning | @Around |
+——————-+—————————+—————————+
| 变量共享 | 需要 ThreadLocal | 局部变量即可 |
| 内存泄漏风险 | 有 | 无 |
| 异常处理 | 需要 @AfterThrowing | try-catch 统一处理 |
| 控制目标方法执行 | 不可以 | 可以(决定是否 proceed) |
+——————-+—————————+—————————+
④ ProceedingJoinPoint——AOP 的「手术刀」
@Around 方法中的 ProceedingJoinPoint 参数是整个切面的核心对象,它提供了访问目标方法一切信息的能力:
+————————+——————————————+
| 方法 | 用途 |
+————————+——————————————+
| proceed() | 执行目标方法 |
| getSignature() | 获取方法签名(方法名、参数类型、返回类型) |
| getArgs() | 获取参数列表 |
| getTarget() | 获取目标对象 |
| getThis() | 获取代理对象 |
| proceed(Object[] args) | 修改参数后执行(很少用) |
+————————+——————————————+
这里有个容易混淆的点:getTarget() 和 getThis() 分别返回什么?
- getTarget():被代理的真实对象(你写的那个 @Service 类的实例)
- getThis():代理对象(Spring 生成的那个带切面逻辑的代理)
在项目中,通过 MethodSignature 获取方法签名,再拿到 Method 对象——这就是反射。
⑤ 反射——AOP 背后的「黑魔法」
面试问「AOP 的底层原理」,答完代理之后,追问通常是「那反射在其中扮演什么角色?」
反射是 Java 在运行时获取类信息(方法、字段、注解)并动态调用的能力。AOP 的每一步都依赖反射:
// 1. 获取被拦截的方法
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod(); // 反射获取 Method 对象
// 2. 读取注解属性
InboundLog annotation = AnnotationUtils.findAnnotation(method, InboundLog.class);
String sourceSystem = annotation.sourceSystem(); // 反射读取注解属性值
// 3. 执行目标方法
Object result = joinPoint.proceed(); // 内部就是 method.invoke(target, args)
项目中还有一处更深层的反射应用——解析嵌套参数路径。@InboundLog 的 bizIdParam 支持 request.cpdmOid 这样的嵌套路径,意思是「从第一个参数 request 对象中,取出 cpdmOid 字段的值」。
private String resolveBizId(ProceedingJoinPoint pjp, String bizIdParam) {
String[] parts = bizIdParam.split("\\\\.");
Object[] args = pjp.getArgs();
MethodSignature signature = (MethodSignature) pjp.getSignature();
String[] paramNames = signature.getParameterNames();
// 第一级:找到参数名匹配的参数
for (int i = 0; i < paramNames.length; i++) {
if (paramNames[i].equals(parts[0]) && args[i] != null) {
Object value = args[i];
// 后续级:逐级反射访问嵌套属性
for (int j = 1; j < parts.length && value != null; j++) {
Field field = value.getClass().getDeclaredField(parts[j]);
field.setAccessible(true); // 突破 private 访问限制
value = field.get(value);
}
return value != null ? value.toString() : null;
}
}
return null;
}
Field.setAccessible(true) 这行代码在面试中也常被问到:它突破了 Java 的访问控制检查,让 private 字段也能被外部读取。这是框架的常用手段,但也有安全隐患——所以在生产代码中,敏感字段应该做脱敏处理(比如 Authorization 头、密码字段)。
AOP 的经典陷阱——同类内部调用不生效
① 为什么 this.methodB() 不走代理
这个问题我踩过坑,也是面试中区分「背过 AOP」和「用过 AOP」的分水岭。
@Service
public class SyncService {
@InboundLog(sourceSystem = "CPDM", interfaceName = "任务单推送")
public void receiveTaskOrder(CpdmPushRequest request) {
// 处理业务…
this.notifyLanxin(); // 调用同类方法
}
@InboundLog(sourceSystem = "蓝信", interfaceName = "预约同步")
public void notifyLanxin() {
// 发送通知…
}
}
当外部调用 receiveTaskOrder() 时,AOP 能正常拦截(走的是代理对象)。但 receiveTaskOrder() 内部调用 notifyLanxin() 时,@InboundLog 不生效。
原因在于 this 是目标对象,不是代理对象:
外部调用(AOP 生效):
Controller –> 代理对象.receiveTaskOrder()
| 拦截成功,切面执行
v
目标对象.receiveTaskOrder()
同类内部调用(AOP 不生效):
目标对象.receiveTaskOrder()
–> this.notifyLanxin() // this = 目标对象,不是代理!
// 直接调用,完全绕过了代理
② 三种解决方案
// 方案1:通过 AopContext 获取代理对象
// 需要配置 @EnableAspectJAutoProxy(exposeProxy = true)
((SyncService) AopContext.currentProxy()).notifyLanxin();
// 方案2:注入自身
@Autowired
private SyncService self;
self.notifyLanxin();
// 方案3:从 ApplicationContext 获取
applicationContext.getBean(SyncService.class).notifyLanxin();
方案2最常用——注入自身看起来有点奇怪,但 Spring 的循环依赖机制(三级缓存)能保证注入的是代理对象。方案1需要额外配置,方案3依赖 ApplicationContext,各有取舍。
这个问题和后面 @Async 的坑是同源的——@Async 也是通过代理实现的,同类调用同样会绕过代理。记住这个根因,两个问题就都会答了。
异步日志写入——@Async 和线程池的八股
① 为什么日志要异步写
切面捕获到日志数据后,如果同步写数据库,一次 DB 写入可能需要几毫秒到几十毫秒。如果接口本身只要 50ms,日志写入就要 20ms,响应时间直接增加 40%。
@Async 的本质是:Spring 在方法调用时,通过代理将任务提交到线程池,当前线程立即返回。业务线程不等日志写完,用户感知不到日志操作的耗时。
② 线程池——面试高频中的高频
线程池是面试中出现频率最高的并发知识点之一。核心是四个参数:
@Bean("apiLogExecutor")
public Executor apiLogExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 核心线程数
executor.setMaxPoolSize(5); // 最大线程数
executor.setQueueCapacity(1000); // 队列容量
executor.setThreadNamePrefix("api-log-");
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
executor.initialize();
return executor;
}
线程池的工作流程:
请求到达
|
v
核心线程(2个)是否空闲?
+– 是 –> 交给核心线程执行
+– 否 –> 队列(容量1000)是否未满?
+– 是 –> 任务进入队列等待
+– 否 –> 当前线程数 < 最大线程数(5)?
+– 是 –> 创建新线程执行
+– 否 –> 执行拒绝策略
为什么 core=2、max=5、queue=1000?日志写入是 IO 密集型任务,2 个核心线程足够处理正常流量,队列 1000 能缓冲突发流量,max=5 是极端情况下的安全网。
③ 拒绝策略——为什么选 CallerRunsPolicy
Java 提供了四种内置拒绝策略:
| AbortPolicy | 抛出 RejectedExecutionException | 不能丢失任务,宁可报错 |
| CallerRunsPolicy | 由调用线程执行任务 | 不能丢失,宁可慢一点 |
| DiscardPolicy | 静默丢弃 | 可以丢失的任务 |
| DiscardOldestPolicy | 丢弃队列中最早的任务 | 新任务比旧任务更重要 |
选择 CallerRunsPolicy 的原因很实际:接口日志是排查问题的关键证据。 丢失一条日志,可能就无法定位故障根因。CallerRunsPolicy 在队列满时退化为同步执行——用少量性能换取数据可靠性。业务线程会慢一点,但日志不会丢。
④ @Async 的坑——异步方法同类调用失效
这和 AOP 的内部调用问题是同一个根因:@Async 也是通过代理实现的。如果在同一个类中调用 @Async 方法,调用的是目标对象的方法,不是代理对象的方法,异步不生效。
解决方案也一样:把 @Async 方法抽到独立的 Service 类中。
链路追踪——ThreadLocal 和 MDC 的八股
① ThreadLocal 是什么
ThreadLocal 是面试并发方向的高频考点。一句话:ThreadLocal 为每个线程维护独立的变量副本,实现线程隔离。
底层实现:每个 Thread 对象内部持有一个 ThreadLocalMap,key 是 ThreadLocal 实例本身(弱引用),value 是变量值。不同线程读写同一个 ThreadLocal,实际上读写的是各自 ThreadLocalMap 中的不同 entry,互不干扰。
ThreadLocal 有个经典面试追问:内存泄漏风险。
ThreadLocalMap 的 key 是弱引用——GC 时会被回收。但 value 是强引用,不会被回收。如果线程是线程池中的复用线程(生命周期很长),被回收 key 的 value 会一直驻留内存。
解决方案:用完之后一定要调 remove()。
② MDC——ThreadLocal 的实战应用
MDC(Mapped Diagnostic Context)是 SLF4J 提供的线程级上下文存储,底层就是 ThreadLocal<Map<String, String>>。
在项目中,我用 MDC 实现了 TraceId 链路追踪:
public class TraceIdFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String traceId = httpRequest.getHeader("X-Request-Id");
if (traceId == null || traceId.isEmpty()) {
traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
MDC.put("traceId", traceId);
try {
chain.doFilter(request, response);
} finally {
MDC.clear(); // 用完清理,防止内存泄漏
}
}
}
logback 配置中用 %X{traceId} 读取 MDC 中的值:
<property name="log.pattern"
value="%d{HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{20} – %msg%n" />
这样同一次请求的所有日志都带上同一个 traceId,排查问题时用 traceId 一搜,所有相关日志全部出来。
③ 跨线程传递——@Async 场景下 MDC 丢失
MDC 基于 ThreadLocal,而 @Async 会把任务提交到另一个线程。新线程的 MDC 是空的——traceId 丢了。
解决方案是用 TaskDecorator 包装 Runnable,在提交任务前拷贝父线程的 MDC 上下文:
public class MdcUtils {
public static Runnable wrap(Runnable delegate) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
if (contextMap != null) MDC.setContextMap(contextMap);
try {
delegate.run();
} finally {
MDC.clear();
}
};
}
}
// 线程池配置中集成
executor.setTaskDecorator(MdcUtils::wrap);
getCopyOfContextMap() 在提交任务时拷贝,setContextMap() 在新线程执行时恢复。这样异步写日志时,traceId 也能正确记录。
跨系统传递 traceId 则是另一层:在出站 HTTP 请求中,把 traceId 放到请求头 X-Request-Id 里,下游系统收到后写入自己的 MDC。这样从用户浏览器到 Nginx 到平台到外部系统,整条链路的 traceId 是一致的。
架构演进——从 @Async 到 RabbitMQ
① @Async 的五个瓶颈
用 @Async + 线程池做异步写入,看似已经「异步」了,但随着日志量增长,瓶颈逐渐暴露:
当前架构(有瓶颈):
请求 -> AOP切面 -> @Async线程池(2-5线程, 队列1000) -> 直接写DB
| |
| 单点瓶颈
队满 -> CallerRunsPolicy -> 阻塞业务线程
应用崩溃 -> 队列中日志丢失
引入MQ后(解耦):
请求 -> AOP切面 -> MQ生产者 -> [RabbitMQ] -> 消费者1 -> 写DB
-> 消费者2 -> 写ES
-> 消费者3 -> 告警判断
|
MQ不可用时
v
降级通道 -> @Async直接写DB
五个瓶颈:
- DB 单点瓶颈:@Async 本质还是同步写 DB,慢查询阻塞线程
- 背压阻塞:CallerRunsPolicy 队满时阻塞业务线程
- 日志丢失:应用重启时内存队列中的任务永久丢失
- 无多消费者:只能写 DB,无法同时写 ES、告警、统计
- 削峰不足:队列容量 1000,突发流量直接打满
② 引入 RabbitMQ
选型理由:日志场景吞吐量万级足够,Spring Boot 2.5 原生支持 spring-boot-starter-amqp,集成成本最低。RabbitMQ 的消息确认机制(Publisher Confirm + Consumer ACK)保证日志不丢失。未来量级暴增可平滑迁移到 Kafka。
引入 MQ 后,架构变成了:AOP 切面捕获日志 -> 发送到 MQ -> 多个消费者并行处理(写 DB、写 ES、触发告警)。核心改动只在「最后一公里」——切面中的 asyncSaveLog() 改为 mqProducer.send(),注解定义、数据库表结构、查询接口全部不变。
还有降级策略:MQ 不可用时,自动降级到 @Async 直接写 DB。保证系统可用性不依赖 MQ 的可用性。
③ 性能收益
引入 MQ 前后的对比:
+——————+——————+——————+——————+
| 指标 | 引入MQ前 | 引入MQ后 | 改善 |
+——————+——————+——————+——————+
| 业务接口影响 | CallerRunsPolicy | 完全解耦 | RT降低10-50ms |
| 日志丢失风险 | 应用重启时丢失 | MQ持久化 | 丢失率 -> 0 |
| 多消费者 | 不支持 | DB/ES/告警并行 | 架构质变 |
| 削峰能力 | 队列1000 | MQ百万级 | 1000x |
| 写入吞吐量 | ~500条/秒 | ~5000条/秒(批量) | 10x |
+——————+——————+——————+——————+
设计模式——AOP 背后的三种模式
整个日志模块的设计中,用到了三种设计模式。面试时如果被问到「项目中用过哪些设计模式」,这些都是很好的素材。
代理模式:AOP 的底层实现。JDK 动态代理和 CGLIB 都是代理模式的具体应用。代理模式的核心思想是:不直接访问目标对象,而是通过代理对象间接访问,代理可以在调用前后插入额外逻辑。
门面模式:出站日志的 ApiLog 类就是门面。它把复杂的日志构建逻辑(设置目标系统、接口名、URL、参数、捕获异常、计算耗时、异步写入)封装在一个简洁的 API 后面:
// 一行代码完成出站日志记录
CpdmResponse resp = ApiLog.execute("CPDM", "任务单推送", url, "POST",
JSON.toJSONString(task),
() -> restTemplate.postForObject(url, task, CpdmResponse.class));
Builder 模式:需要精细控制日志内容时,用 Builder 链式构建:
ApiLog.of("CPDM", "任务单推送")
.url("http://cpdm.internal/api/task/push")
.params(JSON.toJSONString(task))
.success(responseBody, 200)
.save();
还有一个容易被忽略的点:ApiLog 是静态工具类,但它的内部依赖 IOpsApiLogService(Spring Bean)。怎么在静态方法中访问 Spring Bean?通过 CommandLineRunner 在所有 Bean 初始化完成后注入:
@Component
public class ApiLogHelperInitializer implements CommandLineRunner {
@Autowired
private IOpsApiLogService apiLogService;
@Override
public void run(String... args) {
ApiLog.setLogService(apiLogService);
}
}
为什么用 CommandLineRunner 而不是 @PostConstruct?因为 @PostConstruct 只保证当前 Bean 初始化完成,不保证它依赖的 IOpsApiLogService 已经就绪。CommandLineRunner 在整个 Spring 容器初始化完成后才执行,所有 Bean 都已就绪。
写在最后
回头看这个日志模块的实现,最有价值的不是代码本身,而是发现了一个规律:八股不是孤立的知识点,而是解决真实问题时自然浮现的工具。
注解为什么必须是 RUNTIME?因为 AOP 在运行时通过反射读取。为什么用 CGLIB 而不是 JDK 动态代理?因为目标类不一定实现了接口。为什么选 CallerRunsPolicy?因为日志不能丢。为什么 @Async 同类调用不生效?因为代理对象和目标对象不是同一个。每一个「为什么」背后都有一个真实的业务约束。
面试的时候,把这些串起来讲,比孤立地答「AOP 的底层是动态代理」有说服力得多。面试官想听的不是你背了多少八股,而是你能不能在真实项目中识别出八股对应的场景,并做出合理的技术决策。
这个模块从最初的手动日志,到 AOP + 注解的声明式方案,再到异步写入和 MQ 集成,每一步都是被问题驱动的。八股就藏在这些解题过程里。
网硕互联帮助中心



评论前必须登录!
注册