网上大部分的AOP日志教程,代码几乎都是一个模子刻出来的:定义一个注解,写一个切面,在方法前后各打一行 log.info。跑通Demo,教程就结束了。
但这类代码到了生产环境几乎毫无用处。线上排查问题时,你需要的远不是“某个方法被调用了”这种模糊信息。你需要的是这次请求的链路ID、调用方IP、传入的具体业务参数、方法执行耗时、异常类型和错误码。教程里那种只记录方法名的日志,在日志平台里搜出来跟没搜一样,根本无法定位问题。
本文从三个真实的线上项目中提炼了AOP日志的实现代码,整理出生产环境真正在用的写法。这三个项目分别是一个通用框架层的日志starter、一个带业务模块分类的中型项目、以及一个包含几十个微服务的大型项目。它们的AOP日志实现各有侧重,合在一起恰好覆盖了生产环境需要考虑的所有关键问题。
生产级AOP日志要解决什么?
先看全局。生产环境的AOP日志需要系统性地解决三个层面的问题:

记什么,这由注解设计决定,它定义了日志需要携带哪些业务信息。是只记录方法名,还是必须带上模块编码、事件类型、甚至耗时阈值?
怎么记,这由切面实现决定,它负责日志的生命周期管理和异常处理策略。正常返回和抛出异常时,日志记录有何不同?业务异常和系统异常应该使用什么日志级别?
怎么串,这关乎链路追踪,决定了在跨服务调用时,日志能否被有效关联。一个请求从网关进入,流经3个微服务,最终写入数据库,你是否能用一个唯一的ID把整条链路上的所有日志串联起来?
大多数教程通常只触及第二层的皮毛,而在实际生产中,这三个层面都必须做到位。下面我们就按照这三个层面逐一深入。
注解设计:从空壳到业务语义
教程中的日志注解通常只是一个空壳:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
}
这种注解只能告诉切面“这个方法需要记日志”,至于记什么、如何分类,切面得自己去猜测和硬编码。
在生产环境中,注解设计演化出了几种不同的、更精细的思路。
带业务分类的注解 (@ModuleLog)
其中一个项目使用了 ModuleLog 注解,强制要求标注业务模块和具体事件:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ModuleLog {
// 模块编码,从枚举取值
String moduleCode();
// 模块名称
String moduleName();
// 事件编码
String eventCode();
// 事件名称
String eventName();
}
使用时如下所示:
@GetMapping("/getOrderDetail")
@ModuleLog(
moduleCode = "order",
moduleName = "订单",
eventCode = "getOrderDetail",
eventName = "查询订单详情"
)
public Result<OrderDetailVO> getOrderDetail(...) {
}
moduleCode 和 moduleName 必须从一个统一的枚举中取值,不允许随意填写字符串。这样做的好处是日志带上了明确的业务语义。在日志平台中,你可以直接按模块进行筛选,也可以为特定模块配置告警规则。例如,当订单模块的异常率突然升高时,运维能第一时间收到通知,而无需从混杂的日志中人工筛查。
带屏蔽控制的注解 (@LogIgnore)
在框架层,还提供了一个 LogIgnore 注解,用于处理参数或返回值过大的情况,避免不必要的性能开销。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogIgnore {
// 忽略入参
boolean ignoreArgs() default false;
// 忽略返回结果
boolean ignoreReturn() default false;
}
有些接口的入参可能是一个庞大的JSON对象,或者返回值包含数千条记录。将这些内容全部序列化成字符串写入日志,会产生巨大的I/O开销,甚至可能拖慢接口响应速度。@LogIgnore 注解就是用来解决这个问题的,在方法上标注后,切面在采集参数时会跳过对应部分的序列化。
带性能阈值的注解 (@TimeLogPrint)
另一个项目采用了不同的思路。它的 TimeLogPrint 注解可以设置一个耗时阈值,只有执行时间超过此阈值的调用才会被记录。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TimeLogPrint {
// 打印名称,默认用方法名
String value() default "";
// 耗时阈值(毫秒),0表示总是记录
long costTime() default 0;
// 是否打印入参
boolean args() default true;
// 是否打印出参
boolean result() default true;
}
这种设计特别适合高频调用的接口。比如一个正常情况下50毫秒返回的接口,你并不想每次都记录日志,但当其耗时超过200毫秒时,则需要记录下来以便后续排查。costTime 参数正是用于设置这个阈值。
四种注解设计模式对比
将这四种注解设计模式放在一起对比,能更清晰地看出其适用场景:
| 模式 |
代表注解 |
适用场景 |
核心特点 |
| 纯标记型 |
@Log |
框架层自动记录所有Controller入口 |
零配置,切面自动采集所有可用信息 |
| 业务分类型 |
@ModuleLog |
需要按模块、事件进行日志检索和告警 |
日志自带业务语义,便于分类管理 |
| 性能监控型 |
@TimeLogPrint |
高频接口,只关注慢调用和性能瓶颈 |
支持耗时阈值,超标才记录,减少日志量 |
| 选择屏蔽型 |
@LogIgnore |
参数或返回值数据量特别大 |
可按需关闭入参/出参的序列化,避免I/O瓶颈 |
在实际项目中,这几种模式并非互斥,经常组合使用。框架层使用 @Log 做兜底,确保每个接口都有基础日志;业务层叠加 @ModuleLog,为日志加上业务分类;对于个别参数特别大的接口,可以再加一个 @LogIgnore 来屏蔽入参序列化。
切面实现:从割裂到统一管控
注解定义好后,切面才是真正干活的“执行者”。
为什么生产环境都选择 @Around?
@Before 和 @After 是两个独立的方法,它们之间无法直接共享状态。如果你想在 @Before 中记录开始时间,在 @After 中计算耗时,就必须将开始时间存储到 ThreadLocal 或成员变量中,引入了额外的复杂度。异常场景的处理则更加麻烦:@AfterThrowing 虽然能拿到异常对象,但它与 @AfterReturning 是互斥的,你无法在一个统一的地方根据“正常返回还是异常抛出”来执行不同的日志处理逻辑。
@Around 将整个方法执行的前、中、后都包裹在一个方法体内,开始时间、返回值、异常对象都是局部变量,天然共享。你可以根据异常类型灵活使用不同的日志级别,可以在 finally 块中进行资源清理,还可以决定是将原始异常重新抛出,还是转换后再抛出。
模板方法模式:职责分离的精髓
框架层的切面代码极其精简,核心逻辑往往只有一行:
@Aspect
public class RestControllerAspect {
@Pointcut("@within(org.springframework.web.bind.annotation.RestController)")
public void pointcut() {
}
@Around("pointcut()")
public Object around(ProceedingJoinPoint proceedingJoinPoint)
throws Throwable {
return new HttpLogBuilder(proceedingJoinPoint).save().thenReturn();
}
}
切面本身不包含任何具体的日志逻辑,所有工作都委托给了 HttpLogBuilder。HttpLogBuilder 继承自一个抽象的 LogBuilder 类,这里应用了经典的模板方法模式。
LogBuilder 定义了日志记录的完整生命周期骨架:
public abstract class LogBuilder {
private final ProceedingJoinPoint proceedingJoinPoint;
private Object result;
private long beginTime;
private long finishTime;
private Throwable throwable;
// 三个抽象方法,由子类实现具体逻辑
protected abstract void saveBeforeLog();
protected abstract void saveAfterLog();
protected abstract void saveInterruptLog();
public LogBuilder save() throws Throwable {
beginTime = System.currentTimeMillis();
saveBeforeLog();
try {
result = proceedingJoinPoint.proceed();
finishTime = System.currentTimeMillis();
saveAfterLog();
} catch (Throwable e) {
throwable = e;
finishTime = System.currentTimeMillis();
saveInterruptLog();
}
return this;
}
public Object thenReturn() throws Throwable {
if (null != throwable) {
throw throwable;
}
return result;
}
}
save() 方法是整个模板的骨架,其执行顺序固定为:记录开始时间 → 调用 saveBeforeLog() → 执行目标方法 → 正常则调用 saveAfterLog(),异常则调用 saveInterruptLog() → 记录结束时间。
这里有一个关键的设计细节值得注意:save() 和 thenReturn() 被拆分成两个独立的方法。save() 负责执行目标方法和记录日志,thenReturn() 则负责处理异常的重新抛出。在 save() 的 catch 块中,并没有直接抛出异常,而是将其存储到成员变量中,等待 thenReturn() 时再抛出。这样做是为了确保,无论目标方法是正常返回还是抛出异常,日志记录的逻辑都一定能执行完毕。如果在 catch 里直接 throw,而 saveInterruptLog() 本身又发生了异常(例如JSON序列化出错),那么原始的业务异常就会被覆盖,导致排查问题时看到的是日志框架的异常,而非真正的业务异常。
框架层根据不同的使用场景,提供了多个 LogBuilder 子类:HttpLogBuilder 处理HTTP请求、GenericLogBuilder 处理通用方法调用,还有针对 Dubbo、RocketMQ、XxlJob 的专用子类。切面只负责选择合适的 Builder,具体的日志格式和处理逻辑全在 Builder 内部。这种职责分离的设计,使得新增一种日志场景变得非常简单:只需编写一个新的 Builder 子类,再注册一个对应的切面即可。
HTTP日志的具体实现 (HttpLogBuilder)
HttpLogBuilder 是最常用的子类,专门处理HTTP请求的日志。它的三个方法实现了完整的请求日志采集:
public class HttpLogBuilder extends LogBuilder {
private static final Logger LOGGER =
LoggerFactory.getLogger(HttpLogBuilder.class);
@Override
protected void saveBeforeLog() {
LOGGER.info(
"url[{}] ip[{}] action[{}:{}] status[invoke] args[{}]",
HttpLogUtil.getUri(),
WebUtils.getIP(),
GenericLogUtil.getClassName(getProceedingJoinPoint()),
GenericLogUtil.getMethodName(getProceedingJoinPoint()),
GenericLogUtil.toJsonString(
GenericLogUtil.getArgs(getProceedingJoinPoint()))
);
}
@Override
protected void saveAfterLog() {
LOGGER.info(
"url[{}] code[{}] action[{}:{}] status[success] cost[{}] result[{}]",
HttpLogUtil.getUri(),
HttpLogUtil.getHttpStatus(),
GenericLogUtil.getClassName(getProceedingJoinPoint()),
GenericLogUtil.getMethodName(getProceedingJoinPoint()),
Math.max(getFinishTime() - getBeginTime(), 0),
GenericLogUtil.toJsonString(
GenericLogUtil.getReturn(
getProceedingJoinPoint(), getLogData()))
);
}
@Override
protected void saveInterruptLog() {
if (getThrowable() instanceof BusinessException) {
BusinessException exception =
(BusinessException) getThrowable();
LOGGER.warn(
"url[{}] action[{}:{}] status[fail] cost[{}] error[{}] message[{}]",
HttpLogUtil.getUri(),
GenericLogUtil.getClassName(getProceedingJoinPoint()),
GenericLogUtil.getMethodName(getProceedingJoinPoint()),
Math.max(getFinishTime() - getBeginTime(), 0),
exception.getCode(),
exception.getMessage()
);
return;
}
// 未知异常
LOGGER.warn(
"url[{}] action[{}:{}] status[fail] cost[{}] error[internal-error]",
HttpLogUtil.getUri(),
GenericLogUtil.getClassName(getProceedingJoinPoint()),
GenericLogUtil.getMethodName(getProceedingJoinPoint()),
Math.max(getFinishTime() - getBeginTime(), 0),
getThrowable()
);
}
}
这段代码体现了几个关键的生产级设计决策:
-
结构化日志格式:采用了 key[value] 的固定格式。实际输出类似:
url[/api/order/detail] ip[192.168.1.100] action[OrderController:getOrderDetail] status[invoke] args[{"id":123}]
url[/api/order/detail] code[200] action[OrderController:getOrderDetail] status[success] cost[45] result[{...}]
这种格式在 ELK Stack(如Kibana)或 Loki 等日志平台上,可以直接使用正则表达式提取字段并建立索引。你可以轻松写出这样的查询:status:fail AND url:/api/order/*,瞬间筛选出订单模块所有失败的请求。纯文本日志则无法实现这种精确定位。
-
异常分级处理:BusinessException 代表业务异常(如库存不足、权限不够),代码逻辑正常执行完毕,只是业务规则不满足,这类异常使用 WARN 级别记录即可。参数校验异常 InvalidParamException 同理。只有捕获到未预期的 Exception 才意味着代码存在真正的Bug,需要使用更高的 ERROR 级别进行告警。如果不做区分,告警系统会被大量的业务异常淹没,真正需要关注的系统异常反而被埋没。
-
生产环境返回值优化:saveAfterLog() 中调用的是 getLogData() 而非直接 getResult()。在 LogBuilder 中,这个方法包含一段逻辑:在生产环境下,它可能返回一个占位符,而不是完整的返回值对象。因为接口返回值经常是庞大的JSON对象,全量序列化到日志中I/O开销过高。通常只在非生产环境(如测试、开发)打印完整返回值,生产环境则只记录请求参数和耗时,以保证性能。
业务层的补充切面 (ModuleLogAspect)
框架层的 RestControllerAspect 会自动拦截所有 @RestController 方法,无需额外注解。业务层可以在此基础上,叠加自己的切面,实现更精细的日志记录。
ModuleLogAspect 就是一个典型的补充切面。它只关注异常场景(正常情况框架层已记录),并且会带上业务模块信息:
@Aspect
@Component
@Slf4j
public class ModuleLogAspect {
@Around("@annotation(moduleLog)")
public Object around(ProceedingJoinPoint pjp, ModuleLog moduleLog)
throws Throwable {
try {
return pjp.proceed();
} catch (InvalidParamException e) {
// 参数异常不额外记录,直接抛出
throw e;
} catch (BusinessException e) {
logException(pjp, moduleLog, e);
throw e;
} catch (Exception e) {
logException(pjp, moduleLog, e);
throw e;
}
}
private void logException(
ProceedingJoinPoint pjp,
ModuleLog moduleLog,
Throwable e) {
Object[] args = pjp.getArgs();
Object firstArg =
args != null && args.length > 0 ? args[0] : null;
StructuredLog.error(log)
.message(moduleLog.eventName() + "失败")
.exception(e)
.moduleCode(moduleLog.moduleCode())
.moduleName(moduleLog.moduleName())
.eventCode(moduleLog.eventCode())
.eventName(moduleLog.eventName())
.put("param", JSON.toJSONString(firstArg))
.log();
}
}
这个切面的设计思路与框架层不同:它不是全量记录的,而是聚焦于异常场景。对于 InvalidParamException 直接跳过(框架层已记录),仅对 BusinessException 和其他 Exception 进行额外记录,并附带模块编码和事件名称。这样,在日志平台中,你可以快速按模块筛选异常,例如:“找出订单模块最近一小时所有的异常日志”。

结构化日志:从纯文本到可查询字段
前面的 ModuleLogAspect 中使用了一个 StructuredLog 工具来输出日志。结构化日志是生产环境代码与教程示例之间差异最大的部分之一。
教程中的日志输出通常是这样的:
log.info("方法{}执行完成,参数:{},返回值:{}", methodName, args, result);
这种纯文本日志在控制台查看尚可,但一旦接入 ELK、Loki 等日志平台,你无法直接对“方法名”、“参数”、“返回值”这些信息进行字段级查询。只能进行低效的全文搜索,效率天差地别。
StructuredLog 采用 Builder 模式,构建了一个能够输出带业务字段的 JSON 日志工具:
@Slf4j
public class StructuredLog {
private final Map<String, Object> fields = new LinkedHashMap<>();
private final LogLevel level;
private final Logger logger;
private String message;
private Throwable throwable;
// 静态工厂方法,传入调用方的logger
public static StructuredLog info(Logger logger) {
return new StructuredLog(LogLevel.INFO, logger);
}
public static StructuredLog error(Logger logger) {
return new StructuredLog(LogLevel.ERROR, logger);
}
// 通用键值对
public StructuredLog put(String key, Object value) {
if (key != null && value != null) {
fields.put(key, value);
}
return this;
}
// 预设的业务字段快捷方法
public StructuredLog userId(String userId) {
return put("userId", userId);
}
public StructuredLog shopId(Integer shopId) {
return put("shopId", shopId);
}
public StructuredLog moduleCode(String moduleCode) {
return put("moduleCode", moduleCode);
}
public StructuredLog costTime(Long costTime) {
return put("costTime", costTime);
}
// 输出日志
public void log() {
String logContent = buildLogContent();
switch (level) {
case ERROR:
if (throwable != null) {
logger.error(logContent, throwable);
} else {
logger.error(logContent);
}
break;
// INFO、WARN、DEBUG同理
}
}
private String buildLogContent() {
StringBuilder sb = new StringBuilder();
if (message != null && !message.isEmpty()) {
sb.append(message).append(" || ");
}
// JSON格式输出,便于日志平台解析
sb.append(JSON.toJSONString(fields));
return sb.toString();
}
}
调用示例如下:
StructuredLog.error(log)
.message("查询订单详情失败")
.exception(e)
.moduleCode("order")
.moduleName("订单")
.eventCode("getOrderDetail")
.put("param", JSON.toJSONString(request))
.log();
输出的日志形如:
查询订单详情失败 || {"moduleCode":"order","moduleName":"订单","eventCode":"getOrderDetail","param":"{\"id\":123}"}
前半部分是可读的文本消息,后半部分是 JSON 格式的结构化字段,两者以 || 分隔。日志平台可以轻松地按 || 进行拆分,并将右侧的 JSON 直接解析成字段建立索引。随后,你就能在 Kibana 或 Grafana 中使用 moduleCode:order AND eventCode:getOrderDetail 这样的条件进行精确查询。
StructuredLog 的另一个设计亮点是:它为常见的业务字段预设了快捷方法(如 userId、shopId、moduleCode、costTime),同时保留了通用的 put() 方法。预设字段确保了团队内部日志字段命名的一致性,避免了“一人写 userId,另一人写 user_id”的混乱情况。这种一致性在进行日志聚合分析时至关重要。
日志不是写给自己看的,而是写给三个月后凌晨三点被叫起来排查问题的那个同事看的。 结构化的日志格式,加上统一的字段命名规范,能将问题排查效率提升一个数量级。
链路追踪:串联分布式调用的生命线
一个请求进入系统,可能调用3个微服务,执行2次数据库操作,发送1条消息。这些操作分散在不同的服务实例上,日志也记录在不同的日志文件中。如何将它们串联起来?答案就是链路ID (Trace ID)。
链路ID的生成与传递
框架层通过一个 HttpLogFilter 在请求入口处统一处理链路ID:
public class HttpLogFilter extends TraceIdHandler implements Filter {
@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain filterChain)
throws IOException, ServletException {
try {
if (request instanceof HttpServletRequest
&& response instanceof HttpServletResponse) {
// 从请求头获取链路ID
String traceId = ((HttpServletRequest) request)
.getHeader("X-Trace-Id");
// 没有则生成一个新的UUID
traceId = settingTraceId(traceId);
// 写入响应头
((HttpServletResponse) response)
.addHeader("X-Trace-Id", traceId);
}
filterChain.doFilter(request, response);
} finally {
// 请求结束,清理线程上下文变量
clearContext();
}
}
}
如果上游(例如网关或其他微服务)已经在请求头中携带了 X-Trace-Id,Filter 会直接沿用这个ID;如果没有(说明这是调用链路的起点),则生成一个新的UUID。生成之后,ID会被写入响应头,这样前端在收到响应后也能获取到该链路ID。当用户反馈问题时,只需提供这个ID,后端运维人员即可在日志平台中直接搜索该ID,查看完整的调用链路详情。
三层存储:兼容多日志框架
生成链路ID后,需要将其存入当前线程的上下文中,以便后续所有的日志输出都能自动附带此ID。这项工作由 TraceIdHandler 完成:
public class TraceIdHandler {
public String settingTraceId(String traceId) {
if (StringUtils.isBlank(traceId)) {
traceId = GenericLogUtil.generateTraceId();
}
// 存到自定义的上下文里(支持线程池传递)
LogContext.putTraceId(traceId);
// 存到SLF4J的MDC里
MDC.put("traceId", traceId);
// 存到Log4j2的ThreadContext里
ThreadContext.put("traceId", traceId);
return traceId;
}
public void clearContext() {
LogContext.clear();
ThreadContext.remove("traceId");
MDC.remove("traceId");
}
}
链路ID被存储了三份:LogContext、SLF4J MDC、Log4j2 ThreadContext。为什么要存三份?因为不同的日志框架会从不同的地方读取上下文变量。使用 Logback 的项目从 MDC 读取,使用 Log4j2 的项目从 ThreadContext 读取,而业务代码中需要主动获取链路ID时则从 LogContext 读取。三处都设置好,就能确保无论项目使用哪种日志框架,都能正确获取到链路ID。
在日志配置文件(如 logback-spring.xml)中,使用 %X{traceId} 引用这个变量,这样每一行日志输出都会自动附带链路ID,无需在业务代码中手动传递。
TransmittableThreadLocal:解决异步场景链路断裂
LogContext 的内部实现使用了 TransmittableThreadLocal:
public class LogContext {
private static final TransmittableThreadLocal<String> traceIdTL =
new TransmittableThreadLocal<>();
public static void putTraceId(String traceId) {
traceIdTL.set(traceId);
}
public static String getTraceId() {
return traceIdTL.get();
}
public static void clear() {
traceIdTL.remove();
}
}
这里选择 TransmittableThreadLocal 而非普通的 ThreadLocal,这个选择直接决定了链路ID在异步场景下能否正确传递。
普通的 ThreadLocal 只在当前线程内有效。一旦业务代码中使用了 @Async 或自定义线程池,新开启的子线程中的 ThreadLocal 将是空的,导致链路ID在此处“断裂”。这是生产环境中链路追踪失效最常见的原因之一。排查请求日志时,会发现前半段有 traceId,到了某个异步操作后突然消失,后面的日志全都无法关联。
TransmittableThreadLocal 是阿里开源的一个库(Maven坐标:com.alibaba:transmittable-thread-local),它能够在向线程池提交任务时,自动将父线程的 ThreadLocal 值拷贝到子线程中。配合 TtlExecutors.getTtlExecutorService() 对线程池进行包装,链路ID在 @Async、CompletableFuture、自定义线程池等异步场景下都能实现无缝传递。
MDC 和 ThreadContext 本身不具备这种跨线程传递的能力,因此才需要额外的 LogContext 使用 TransmittableThreadLocal 存储一份。即使在异步线程中 MDC 的值丢失了,也可以从 LogContext 中重新获取链路ID并设置回 MDC。

自动装配:开箱即用的日志能力
框架层的日志能力通过 Spring Boot 的自动装配机制提供给业务服务。业务服务只需在 pom.xml 中引入对应的 starter 依赖,无需编写任何配置代码,切面、过滤器、拦截器等组件便会全部自动生效。
LogAutoConfiguration 是自动装配的入口:
@Configuration
@EnableConfigurationProperties(LogProperties.class)
@ConditionalOnProperty(
prefix = "app.log",
name = "enable",
havingValue = "true",
matchIfMissing = true)
public class LogAutoConfiguration {
// Web应用才注册HTTP相关组件
@Bean
@ConditionalOnWebApplication
public RestControllerAspect restControllerAspect() {
return new RestControllerAspect();
}
// 过滤器注册,优先级设为最高
@Bean
@ConditionalOnWebApplication
public FilterRegistrationBean<HttpLogFilter> httpLogFilterRegistration() {
FilterRegistrationBean<HttpLogFilter> registration =
new FilterRegistrationBean<>();
registration.setFilter(new HttpLogFilter());
registration.addUrlPatterns("/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
return registration;
}
// 引了XxlJob依赖才注册定时任务切面
@Bean
@ConditionalOnMissingBean
@ConditionalOnClass(
name = "com.xxl.job.core.log.XxlJobFileAppender")
public XxlJobLogAspect xxlJobLogAspect() {
return new XxlJobLogAspect();
}
// 引了Dubbo依赖才注册RPC切面
@Bean
@ConditionalOnMissingBean
@ConditionalOnClass(
name = "org.apache.dubbo.config.ServiceConfig")
public RpcServiceAspect rpcServiceAspect() {
return new RpcServiceAspect();
}
// 引了RocketMQ依赖才注册消息队列切面
@Configuration
@ConditionalOnClass(
name = "com.example.framework.mq.RocketMQProducer")
protected static class RocketMqConfig {
@Bean
@ConditionalOnMissingBean
public RocketMqProducerAspect rocketMQTemplateAspect() {
return new RocketMqProducerAspect();
}
@Bean
@ConditionalOnMissingBean
public RocketMqListenerAspect rocketMqListenerAspect() {
return new RocketMqListenerAspect();
}
}
}
整个配置类的设计思想是按需装配。@ConditionalOnWebApplication 确保只有Web应用才会注册HTTP相关组件。@ConditionalOnClass 根据类路径进行判断:项目引入了Dubbo依赖就自动装配Dubbo日志切面,引入了RocketMQ依赖就装配消息队列日志切面,引入了XxlJob依赖就装配定时任务日志切面。没有引入相应依赖的服务,相关的切面根本不会被创建,不会产生任何额外开销。
HttpLogFilter 的注册优先级被设置为 Ordered.HIGHEST_PRECEDENCE,确保它是第一个执行的过滤器。这样,链路ID在所有后续的过滤器、拦截器和切面执行之前就已经设置完毕,后续所有的日志输出都能自动带上这个ID。
配合 META-INF/spring.factories 文件实现自动发现,业务服务引入依赖后,每个Controller方法的调用会自动产生日志,每个请求会自动生成并传递链路ID,异常也会自动按类型分级记录。如果需要添加业务分类,只需在方法上标注 @ModuleLog;如果需要屏蔽大参数,标注 @LogIgnore 即可。
生产级AOP日志落地检查清单
将生产级AOP日志需要考虑的要点整理成下表,可作为项目落地时的检查清单:
| 检查项 |
说明 |
| 注解是否携带业务语义 |
应包含模块编码、事件类型等,便于按业务维度检索和告警 |
| 切面是否采用@Around |
@Before+@After 无法共享状态,且难以统一处理异常分级 |
| 异常是否分级处理 |
业务异常用 WARN,系统异常用 ERROR,参数校验异常可考虑跳过 |
| 日志格式是否结构化 |
采用JSON或 key[value] 固定格式,便于日志平台解析成字段 |
| 字段命名是否统一 |
团队内 userId、shopId、traceId 等字段名应保持一致 |
| 链路ID是否用TransmittableThreadLocal |
普通 ThreadLocal 在线程池场景下会导致ID丢失 |
| 链路ID是否写入响应头 |
前端获取后可直接提供给后端,用于问题快速定位 |
| 大参数是否有屏蔽机制 |
通过 @LogIgnore 等注解防止大对象序列化拖慢接口响应 |
| 生产环境是否屏蔽返回值 |
返回值数据量通常很大,生产环境可只记录占位符或摘要 |
| 是否通过starter自动装配 |
业务方引入依赖即可,无需手动配置切面和过滤器,提升开发效率 |
总结
AOP日志在很多人印象中是一个入门级话题,面试时可能只需回答 @Around 加 ProceedingJoinPoint 就过关了。然而在实际生产环境中,它所承载的职责远比面试答案中描述的多得多。注解不再是一个空标记,而是日志的数据模型定义;切面不只是打印两行日志,它需要处理异常分级、性能控制、上下文传递;日志的输出格式直接决定了线上问题排查的效率。
从更宏观的视角看,AOP日志切面是整个系统可观测性 (Observability) 体系的基础设施。日志、指标、链路追踪是可观测性的三大支柱,一个设计良好的AOP切面能同时为这三者提供高质量的数据:切面记录的耗时可以转化为接口响应时间的监控指标;链路ID可以在分布式追踪系统中关联完整的调用链;结构化的异常日志可以直接触发告警。写好这一层,相当于为每个服务入口自动安装了观测探针,业务开发者无需额外操心,服务上线即自带完善的可观测能力。
希望这篇从真实项目中提炼的实践方案,能帮助你构建出真正适用于生产环境的AOP日志体系。更多关于 Java 后端架构、分布式系统 和 DevOps 的深度讨论,欢迎在 云栈社区 交流探讨。