Spring AOP记录日志,生产环境的代码长什么样

0 阅读18分钟

网上的AOP日志教程,代码基本都长一样:定义一个注解,写一个切面,方法前后各打一行log.info。跑通了,教程结束。

这种代码到了生产环境几乎没用。线上排查问题的时候,你需要的不是「某个方法被调了」这种信息。你需要知道这次请求的链路ID、调用方IP、传入的业务参数、方法耗时、异常类型和错误码。教程里那种只打方法名的日志,在日志平台里搜出来跟没搜一样。

我从三个线上项目里提取了AOP日志的实现代码,整理出生产环境真正在用的写法。这三个项目分别是一个通用框架层的日志starter、一个带业务模块分类的中型项目、一个几十个微服务的大型项目。它们的AOP日志实现各有侧重,合在一起刚好覆盖了生产环境需要考虑的所有问题。

生产级AOP日志要解决什么

先看全局。生产环境的AOP日志需要解决三个层面的问题:

记什么,注解设计决定了日志携带哪些业务信息。是只打方法名,还是带上模块编码、事件类型、耗时阈值?

怎么记,切面实现决定了日志的生命周期管理和异常处理策略。正常返回和抛异常的日志怎么区分?业务异常和系统异常用什么级别?

怎么串,链路追踪决定了跨服务调用时日志能不能关联起来。一个请求从网关进来,经过3个微服务,最终写了数据库,你能不能用一个ID把这条链路上所有的日志串起来?

教程通常只讲第二层的皮毛,生产环境三层都要做到位。下面按这三个层面逐个展开。

注解设计

教程里的日志注解通常就一个空壳:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
}

这种注解只能告诉切面「这个方法需要记日志」,至于记什么、怎么分类,切面得自己去猜。

生产环境里的注解设计分化出了几种不同的思路。

带业务分类的注解

其中一个项目用的是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注解,用来处理参数或返回值太大的情况:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogIgnore {
    // 忽略入参
    boolean ignoreArgs() default false;
    // 忽略返回结果
    boolean ignoreReturn() default false;
}

有些接口的入参是一个很大的JSON对象,或者返回值是一个几千条记录的列表。把这些内容全序列化成字符串写进日志,IO开销会很大,甚至可能拖慢接口响应。LogIgnore就是用来解决这个问题的,在方法上标注一下,切面在采集参数的时候会跳过序列化。

带性能阈值的注解

另一个项目用了不同的思路。它的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;
}

这种设计适合高频调用的接口。比如一个正常情况下50ms返回的接口,你不想每次都打日志,但超过200ms的时候需要记录下来做排查。costTime就是这个阈值。

四种模式对比

四种注解设计模式放在一起对比:

模式代表注解适用场景核心特点
纯标记型@Log框架层自动记录所有Controller零配置,切面自动采集所有信息
业务分类型@ModuleLog需要按模块、事件检索和告警日志带业务语义,便于分类管理
性能监控型@TimeLogPrint高频接口,只关注慢调用支持耗时阈值,超标才记录
选择屏蔽型@LogIgnore参数或返回值数据量大按需关闭入参/出参的序列化

实际项目里这几种模式不是互斥的,经常组合使用。框架层用@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块里故意没有直接throw,而是把异常存到了成员变量里,等thenReturn()的时候再抛。这样做是为了确保不管目标方法是正常返回还是抛异常,日志记录的逻辑都一定能执行完。如果在catch里直接throw,而saveInterruptLog()本身又出了异常(比如JSON序列化报错),原始的业务异常就会被覆盖掉,排查问题的时候拿到的是日志框架的异常而不是业务异常。

框架层根据不同的使用场景,提供了多个LogBuilder子类:HttpLogBuilder处理HTTP请求、GenericLogBuilder处理通用方法调用、还有针对Dubbo、RocketMQ、XxlJob的专用子类。切面只负责选择合适的Builder,具体的日志格式和处理逻辑全在Builder里。这种分离让新增一种日志场景变得很简单:写一个新的Builder子类,再写一个对应的切面注册上去。

HTTP日志的具体实现

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[{...}]

这种格式在日志平台里可以直接用正则提取字段做索引。你可以在Kibana里写一条查询:status:fail AND url:/api/order/*,瞬间筛出订单模块所有失败的请求。纯文本的日志做不到这种精度。

异常分级处理也值得看一下。BusinessException是业务异常,代码逻辑正常走完了,只是业务规则不满足(比如库存不足、权限不够),这种用warn级别就够了。参数校验异常InvalidParamException同理。只有捕获到未预期的Exception才意味着代码出了真正的Bug,需要用更高的告警等级。如果不做这个区分,你的告警系统会被大量的业务异常淹没,真正需要关注的系统异常反而被埋在里面。

saveAfterLog里调用了getLogData()而不是直接getResult()。这个方法在LogBuilder里有一段逻辑:生产环境下返回一个占位符而不是真实的返回值。接口返回值经常是很大的JSON对象,全序列化到日志里IO开销太高,只有非生产环境才打印完整返回值,生产环境只记录请求参数和耗时。

业务层的补充切面

框架层的RestControllerAspect会自动拦截所有Controller方法,不需要加注解。业务层在这个基础上可以叠加自己的切面,做更精细的日志记录。

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。

链路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就直接沿用;如果没有(说明这是链路的起点),就生成一个UUID。生成之后写入响应头,前端拿到响应后也能拿到链路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读。三个都设置了,不管项目用哪个日志框架都能取到值。

在日志配置文件里用%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。

配合spring.factories做自动发现,业务服务引入依赖后,每个Controller方法的调用自动有日志,每个请求自动有链路ID,异常自动按类型分级。需要加业务分类的,在方法上标一个@ModuleLog;需要屏蔽大参数的,标一个@LogIgnore。

落地检查清单

把生产级AOP日志需要考虑的点整理成一张表:

检查项说明
注解是否携带业务语义模块编码、事件类型、操作类型,便于按业务维度检索和告警
切面是否用@Around@Before+@After无法共享状态,无法统一处理异常分级
异常是否分级处理业务异常warn,系统异常error,参数校验异常可跳过
日志格式是否结构化JSON或key[value]固定格式,能被日志平台解析成字段
字段命名是否统一团队内userId、shopId、traceId等字段名保持一致
链路ID是否用TransmittableThreadLocal普通ThreadLocal在线程池场景下会丢失
链路ID是否写入响应头前端拿到链路ID可以直接发给后端排查
大参数是否有屏蔽机制@LogIgnore防止日志序列化拖慢接口响应
生产环境是否屏蔽返回值返回值数据量通常很大,生产环境可以只记占位符
是否通过starter自动装配业务方引入依赖即可,不需要手动配置切面和过滤器

小结

AOP日志在很多人印象里是一个入门级的话题,面试的时候答个@Around加ProceedingJoinPoint就过了。实际上在生产环境里,它承载的东西比面试答案里讲的多得多。注解不只是一个空标记,它是日志的数据模型定义;切面不只是打两行log,它要处理异常分级、性能控制、上下文传递;日志输出格式直接决定了线上排查的效率。

从更大的视角看,AOP日志切面是整个可观测性体系的基础设施。日志、指标、链路追踪是可观测性的三个维度,一个设计到位的AOP切面能同时为这三个维度提供数据:切面记录的耗时可以转化为接口响应时间的指标,链路ID可以在分布式追踪系统里关联完整的调用链,异常日志可以触发告警。写好这一层,相当于给每个服务入口自动装了一个观测点,业务开发者不需要额外操心,服务上线就自带完整的可观测能力。

希望这篇内容可以帮到你。

我最近在知乎写了一个秒杀专栏(应付6000万会员级别的)和开了星球,有兴趣的可以订阅和加入,一起交流。

  • 知乎账号:SamDeepThinking
  • 星球:老码头的技术浮生录