前言

在软件开发的江湖中,有一项永恒的艺术——甩锅。当线上出现故障,第一件事往往不是解决问题,而是翻开日志,找到证据,证明“这不是我写的代码有问题”。然而,现实往往是:日志缺失、信息不全、调用链断裂,最终导致谁也说不清,问题成了悬案。

为了解决这个痛点,我写了一个牛逼的日志切面,它不仅能记录方法的入参、出参、执行耗时,还能自动串联分布式调用链、脱敏敏感数据、支持异步日志、采样控制……更重要的是,当问题发生时,你可以自信地甩出日志:“看,这是证据,问题不在我这!”

本文将用2万字从零开始,手把手带你实现一个企业级日志切面,涵盖设计思想、技术选型、完整代码、扩展功能、性能优化以及生产实践。读完本文,你不仅拥有一个强大的日志工具,更能深刻理解 AOP 的精髓。


1. 日志切面的意义

1.1 传统日志的痛点

  • 散乱无章:每个方法都手动写 logger.info,代码臃肿,维护困难。

  • 信息不全:经常忘了记录关键参数,出了问题无从查起。

  • 性能损耗:日志打印过多,影响接口响应时间。

  • 调用链断裂:分布式系统中,多个服务之间的日志无法关联。

  • 敏感数据泄露:不经意间打印了密码、身份证等敏感信息。

1.2 甩锅的艺术与日志的关系

甩锅的核心在于证据链完整。一个优秀的日志切面应该做到:

  • 谁调用了这个方法?(记录调用方 IP、用户身份)

  • 传入了什么参数?(记录入参,必要时脱敏)

  • 执行了多久?(记录耗时,判断性能瓶颈)

  • 返回了什么结果?(记录出参,但避免过大)

  • 如果抛异常,异常是什么?(记录异常堆栈)

  • 整个请求链路是否唯一?(生成 TraceId,串联所有日志)

有了这些,当别人指责你的模块出错时,你可以淡定地丢出日志:“你看,入参是空指针,调用方传了 null,我早就防住了。”


2. AOP 基础与选型

2.1 AOP 概念回顾

AOP(Aspect-Oriented Programming,面向切面编程)通过预编译方式和运行期动态代理实现程序功能的统一维护。利用 AOP 可以对业务逻辑的各个部分进行隔离,从而降低耦合度,提高代码的可重用性。

核心概念:

  • Aspect(切面):横切关注点的模块化,比如日志切面。

  • Join Point(连接点):程序执行过程中的某个点,如方法调用。

  • Advice(通知):在特定连接点执行的动作,如 @Before@AfterReturning

  • Pointcut(切点):匹配连接点的表达式,决定通知应该应用到哪些方法上。

  • Introduction(引入):为类添加新方法或属性。

  • Target Object(目标对象):被通知的对象。

  • AOP Proxy(代理):AOP 框架创建的对象,用来实现切面。

  • Weaving(织入):把切面应用到目标对象并创建新代理对象的过程。

2.2 Spring AOP vs AspectJ

特性Spring AOPAspectJ
实现方式动态代理(JDK或CGLIB)字节码编织(编译期/加载期)
性能相对较低(运行时生成代理)较高(直接修改字节码)
支持连接点仅方法级别字段、构造器、方法等
配置复杂度简单(Spring集成)较复杂
切点表达式支持AspectJ切点指示器完整AspectJ语法

对于日志切面,通常只需要拦截方法级别,且 Spring AOP 已足够,性能影响可通过优化减小。因此本文采用 Spring AOP

2.3 为什么选择注解驱动

通过自定义注解 @LogRecord 标记需要日志的方法,可以实现:

  • 灵活控制:只在关键方法上启用日志。

  • 可配置性:注解参数控制是否打印参数、是否脱敏等。

  • 低侵入性:业务代码无感知。


3. 日志切面的设计目标

一个“牛逼”的日志切面应当满足以下要求:

  1. 无侵入:业务代码不需要显式调用日志 API。

  2. 信息全面:记录方法签名、入参、出参、异常、耗时。

  3. 调用链追踪:自动生成或透传 TraceId,支持多线程传递。

  4. 敏感数据脱敏:对手机号、身份证、密码等自动替换。

  5. 日志级别可配置:可以按方法调整日志级别。

  6. 结果过大截断:防止打印大对象导致内存溢出。

  7. 异步日志:日志写入不阻塞业务线程。

  8. 采样控制:高并发下按比例采样,避免日志爆炸。

  9. 扩展性:支持自定义字段、输出格式等。


4. 环境准备与依赖

4.1 技术栈

  • JDK 8+

  • Spring Boot 2.x

  • Maven/Gradle

  • SLF4J + Logback(日志门面和实现)

  • Fastjson / Jackson(JSON 序列化)

  • Hutool(工具类库,可选)

4.2 Maven 依赖

xml

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>2.0.32</version>
    </dependency>
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.22</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

4.3 配置文件示例

application.yml 中可配置日志开关、采样率等:

yaml

log:
  aspect:
    enabled: true               # 全局开关
    trace:
      enable: true              # 是否开启 TraceId
      header-name: X-Trace-Id    # 从请求头获取 TraceId 的键
    sample-rate: 1.0             # 采样率 0~1,1 表示全量
    async: true                  # 是否异步打印
    max-result-length: 500       # 结果最大长度

5. 核心实现

5.1 自定义注解 @LogRecord

java

package com.example.log.aspect.annotation;

import java.lang.annotation.*;

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

    /**
     * 业务描述,例如:查询用户信息
     */
    String value() default "";

    /**
     * 是否打印入参,默认 true
     */
    boolean printParams() default true;

    /**
     * 是否打印出参,默认 true
     */
    boolean printResult() default true;

    /**
     * 是否打印异常,默认 true
     */
    boolean printException() default true;

    /**
     * 日志级别,默认 INFO
     */
    String level() default "INFO";

    /**
     * 是否脱敏,默认 false
     * 如果开启,将使用脱敏工具处理
     */
    boolean desensitize() default false;

    /**
     * 结果截断长度,默认 500,小于等于0表示不截断
     */
    int maxResultLength() default 500;
}

5.2 切面类 LogAspect

核心类,负责拦截所有被 @LogRecord 标记的方法。

java

package com.example.log.aspect;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.example.log.aspect.annotation.LogRecord;
import com.example.log.aspect.context.TraceContext;
import com.example.log.aspect.model.LogInfo;
import com.example.log.aspect.utils.DesensitizationUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.concurrent.CompletableFuture;

@Slf4j
@Aspect
@Component
@Order(1) // 优先级高,确保在事务等切面之前执行
public class LogAspect {

    @Value("${log.aspect.enabled:true}")
    private boolean enabled;

    @Value("${log.aspect.trace.enable:true}")
    private boolean traceEnable;

    @Value("${log.aspect.trace.header-name:X-Trace-Id}")
    private String traceHeader;

    @Value("${log.aspect.sample-rate:1.0}")
    private double sampleRate;

    @Value("${log.aspect.async:false}")
    private boolean async;

    @Value("${log.aspect.max-result-length:500}")
    private int defaultMaxResultLength;

    // 定义切点:所有标注 @LogRecord 的方法
    @Pointcut("@annotation(com.example.log.aspect.annotation.LogRecord)")
    public void logPointcut() {}

    @Around("logPointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        if (!enabled) {
            return joinPoint.proceed();
        }

        // 采样控制:生成随机数,小于采样率才执行
        if (sampleRate < 1.0 && Math.random() > sampleRate) {
            return joinPoint.proceed();
        }

        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        LogRecord logRecord = method.getAnnotation(LogRecord.class);

        // 构建日志基本信息
        LogInfo logInfo = new LogInfo();
        logInfo.setTraceId(getOrCreateTraceId());
        logInfo.setClassName(joinPoint.getTarget().getClass().getSimpleName());
        logInfo.setMethodName(method.getName());
        logInfo.setDescription(logRecord.value());
        logInfo.setStartTime(LocalDateTime.now());
        logInfo.setParams(joinPoint.getArgs());

        // 获取请求信息(如果是Web环境)
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes != null) {
            HttpServletRequest request = attributes.getRequest();
            logInfo.setRequestUrl(request.getRequestURL().toString());
            logInfo.setHttpMethod(request.getMethod());
            logInfo.setIp(getClientIp(request));
            logInfo.setUserAgent(request.getHeader("User-Agent"));
        }

        Object result = null;
        Throwable throwable = null;
        long startTime = System.currentTimeMillis();

        try {
            result = joinPoint.proceed();
            return result;
        } catch (Throwable t) {
            throwable = t;
            throw t;
        } finally {
            long cost = System.currentTimeMillis() - startTime;
            logInfo.setCostTime(cost);
            logInfo.setEndTime(LocalDateTime.now());

            if (throwable != null) {
                logInfo.setSuccess(false);
                logInfo.setException(throwable.toString());
                // 可获取堆栈第一行作为简要异常
                StackTraceElement[] stackTrace = throwable.getStackTrace();
                if (stackTrace.length > 0) {
                    logInfo.setExceptionLocation(stackTrace[0].toString());
                }
            } else {
                logInfo.setSuccess(true);
                logInfo.setResult(result);
            }

            // 异步或同步打印日志
            if (async) {
                CompletableFuture.runAsync(() -> printLog(logInfo, logRecord));
            } else {
                printLog(logInfo, logRecord);
            }
        }
    }

    /**
     * 打印日志,根据注解配置调整输出
     */
    private void printLog(LogInfo logInfo, LogRecord logRecord) {
        // 脱敏处理
        if (logRecord.desensitize()) {
            if (logInfo.getParams() != null) {
                Object[] desensitizedParams = Arrays.stream(logInfo.getParams())
                        .map(DesensitizationUtil::desensitize)
                        .toArray();
                logInfo.setParams(desensitizedParams);
            }
            if (logInfo.getResult() != null) {
                logInfo.setResult(DesensitizationUtil.desensitize(logInfo.getResult()));
            }
        }

        // 结果截断
        int maxLen = logRecord.maxResultLength() > 0 ? logRecord.maxResultLength() : defaultMaxResultLength;
        String resultStr = JSON.toJSONString(logInfo.getResult(), SerializerFeature.WriteMapNullValue);
        if (resultStr.length() > maxLen) {
            resultStr = resultStr.substring(0, maxLen) + "...(truncated)";
        }

        // 构建日志消息
        StringBuilder sb = new StringBuilder(256);
        sb.append("\n=== Log Aspect =========================");
        sb.append("\nTraceId      : ").append(logInfo.getTraceId());
        sb.append("\nDescription  : ").append(logInfo.getDescription());
        sb.append("\nClass Method : ").append(logInfo.getClassName()).append(".").append(logInfo.getMethodName());
        sb.append("\nRequestUrl   : ").append(logInfo.getRequestUrl());
        sb.append("\nHttpMethod   : ").append(logInfo.getHttpMethod());
        sb.append("\nClient IP    : ").append(logInfo.getIp());
        sb.append("\nUserAgent    : ").append(logInfo.getUserAgent());
        if (logRecord.printParams()) {
            sb.append("\nParams       : ").append(JSON.toJSONString(logInfo.getParams(), SerializerFeature.WriteMapNullValue));
        }
        sb.append("\nCostTime     : ").append(logInfo.getCostTime()).append(" ms");
        if (logRecord.printResult() && logInfo.isSuccess()) {
            sb.append("\nResult       : ").append(resultStr);
        }
        if (!logInfo.isSuccess() && logRecord.printException()) {
            sb.append("\nException    : ").append(logInfo.getException());
            sb.append("\n at          : ").append(logInfo.getExceptionLocation());
        }
        sb.append("\n=========================================");

        String logMsg = sb.toString();

        // 根据注解配置的日志级别打印
        switch (logRecord.level().toUpperCase()) {
            case "DEBUG":
                log.debug(logMsg);
                break;
            case "WARN":
                log.warn(logMsg);
                break;
            case "ERROR":
                log.error(logMsg);
                break;
            default:
                log.info(logMsg);
        }
    }

    /**
     * 获取或生成 TraceId
     */
    private String getOrCreateTraceId() {
        if (!traceEnable) {
            return null;
        }
        // 1. 从 MDC 获取(可能之前被设置)
        String traceId = TraceContext.getTraceId();
        if (traceId != null) {
            return traceId;
        }
        // 2. 从请求头获取
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes != null) {
            HttpServletRequest request = attributes.getRequest();
            traceId = request.getHeader(traceHeader);
        }
        // 3. 生成新的
        if (traceId == null || traceId.isEmpty()) {
            traceId = generateTraceId();
        }
        // 存入上下文
        TraceContext.setTraceId(traceId);
        return traceId;
    }

    private String generateTraceId() {
        return java.util.UUID.randomUUID().toString().replace("-", "");
    }

    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        // 多个代理的情况,取第一个
        if (ip != null && ip.contains(",")) {
            ip = ip.split(",")[0].trim();
        }
        return ip;
    }
}

5.3 日志信息封装类 LogInfo

java

package com.example.log.aspect.model;

import lombok.Data;
import java.time.LocalDateTime;

@Data
public class LogInfo {
    private String traceId;           // 链路追踪ID
    private String className;          // 类名
    private String methodName;         // 方法名
    private String description;        // 业务描述
    private LocalDateTime startTime;   // 开始时间
    private LocalDateTime endTime;     // 结束时间
    private long costTime;             // 耗时(ms)
    private Object[] params;           // 入参
    private Object result;             // 出参
    private boolean success;           // 是否成功
    private String exception;          // 异常信息
    private String exceptionLocation;  // 异常位置
    private String requestUrl;         // 请求URL
    private String httpMethod;         // HTTP方法
    private String ip;                 // 客户端IP
    private String userAgent;          // User-Agent
}

5.4 脱敏工具类 DesensitizationUtil

这里使用 Hutool 的脱敏工具,也可以自定义规则。

java

package com.example.log.aspect.utils;

import cn.hutool.core.util.DesensitizedUtil;
import cn.hutool.core.util.StrUtil;

public class DesensitizationUtil {

    /**
     * 对对象进行脱敏处理,目前仅处理字符串字段,可根据需要扩展
     */
    public static Object desensitize(Object obj) {
        if (obj == null) {
            return null;
        }
        // 简单处理:如果是字符串,尝试识别敏感信息
        if (obj instanceof String) {
            String str = (String) obj;
            // 手机号脱敏
            if (isMobile(str)) {
                return DesensitizedUtil.mobilePhone(str);
            }
            // 身份证脱敏
            if (isIdCard(str)) {
                return DesensitizedUtil.idCardNum(str, 4, 2);
            }
            // 密码脱敏(全部替换为*)
            if (isPassword(str)) {
                return StrUtil.repeat('*', str.length());
            }
            // 其他字符串暂不处理
            return str;
        }
        // 对于集合或复杂对象,此处简化,实际可递归处理
        return obj;
    }

    private static boolean isMobile(String str) {
        return str != null && str.matches("^1[3-9]\\d{9}$");
    }

    private static boolean isIdCard(String str) {
        return str != null && (str.matches("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$") ||
                str.matches("^[1-9]\\d{5}\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}$"));
    }

    private static boolean isPassword(String str) {
        // 简单认为长度大于8且包含字母数字的就是密码(可根据业务调整)
        return str != null && str.length() >= 6 && str.matches(".*[a-zA-Z].*") && str.matches(".*\\d.*");
    }
}

5.5 链路追踪上下文 TraceContext

使用 ThreadLocal 存储 TraceId,确保同一线程内传递。

java

package com.example.log.aspect.context;

public class TraceContext {
    private static final ThreadLocal<String> TRACE_ID_HOLDER = new ThreadLocal<>();

    public static void setTraceId(String traceId) {
        TRACE_ID_HOLDER.set(traceId);
    }

    public static String getTraceId() {
        return TRACE_ID_HOLDER.get();
    }

    public static void clear() {
        TRACE_ID_HOLDER.remove();
    }
}

5.6 过滤器(可选)用于前置设置 TraceId

为了在进入切面之前就设置好 TraceId,可以增加一个 Filter:

java

package com.example.log.aspect.filter;

import com.example.log.aspect.context.TraceContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;

@Slf4j
@Component
@Order(1)
public class TraceFilter implements Filter {

    @Value("${log.aspect.trace.enable:true}")
    private boolean traceEnable;

    @Value("${log.aspect.trace.header-name:X-Trace-Id}")
    private String traceHeader;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        if (!traceEnable) {
            chain.doFilter(request, response);
            return;
        }

        HttpServletRequest req = (HttpServletRequest) request;
        String traceId = req.getHeader(traceHeader);
        if (!StringUtils.hasText(traceId)) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        TraceContext.setTraceId(traceId);
        try {
            chain.doFilter(request, response);
        } finally {
            // 请求结束后清理,避免内存泄漏
            TraceContext.clear();
        }
    }
}

6. 功能详解

6.1 入参出参记录

通过 ProceedingJoinPoint.getArgs() 获取方法参数数组,通过 joinPoint.proceed() 的返回值获取结果。需要注意:

  • 参数可能是 null,处理时避免 NPE。

  • 如果参数是 HttpServletRequest/Response、MultipartFile 等不宜序列化的对象,应跳过或特殊处理。

  • 出参可能很大(如分页结果),需要截断。

6.2 异常记录与堆栈精简

在 catch 中获取异常对象,记录异常类名和消息。为了不过度打印,可以只记录堆栈第一行(表示异常发生位置)。完整的堆栈可以通过日志框架配置输出,但切面中不宜全量打印。

6.3 耗时统计与性能分析

记录方法执行前后时间差,对于慢方法可以单独告警。可以在日志中增加阈值判断,超过阈值打印“慢方法”标记。

6.4 TraceId 生成与传递

TraceId 是分布式链路追踪的核心。我们通过 Filter 在请求入口生成或接收上游传递的 TraceId,存入 ThreadLocal。在切面中从 ThreadLocal 获取,确保同一线程内所有日志都带同一个 TraceId。

对于异步线程(如 @Async),需要手动传递 TraceId,后面会讲到解决方案。

6.5 敏感数据脱敏

在打印之前,根据注解 desensitize 配置,调用脱敏工具对字符串类型的敏感字段进行替换。实际生产中可能需要更复杂的规则,如根据字段名匹配(身份证、手机号等)。

6.6 日志级别动态控制

通过在注解中指定 level,可以按方法设定日志级别。但要注意:日志级别是由日志框架最终决定的,如果全局日志级别高于方法指定的级别,可能仍然不会打印。可以结合 MDC 动态调整。

6.7 结果截断与防内存溢出

对大结果进行截断,避免一次性打印几十 MB 的 JSON,导致内存溢出或 I/O 阻塞。截断长度可配置。

6.8 异步日志

使用 CompletableFuture.runAsync 将日志打印任务提交到线程池,避免阻塞主线程。需要合理配置线程池大小,避免线程耗尽。

6.9 采样控制

高并发下,全量日志可能压垮磁盘。通过采样率(0~1)控制日志输出比例。注意:采样应在切面最前面判断,避免不必要的参数序列化等开销。


7. 扩展功能

7.1 支持多线程传递 TraceId

对于使用 @Async 或手动创建线程的情况,ThreadLocal 无法传递。解决方案:

  1. 装饰器模式:使用自定义的 Runnable 或 Callable,在执行前设置 TraceId,执行后清除。

  2. TransmittableThreadLocal:阿里开源的 TTl 组件,可以解决线程池等场景的传递问题。

示例:使用 TTl 改造 TraceContext

java

import com.alibaba.ttl.TransmittableThreadLocal;

public class TraceContext {
    private static final TransmittableThreadLocal<String> TRACE_ID_HOLDER = new TransmittableThreadLocal<>();
    // 方法同上
}

然后配合线程池包装:TtlExecutors.getTtlExecutorService(threadPoolExecutor)

7.2 支持 RPC 调用传递 TraceId

在微服务架构中,服务间调用需要传递 TraceId。例如使用 Feign 时,可以通过拦截器在请求头中设置 TraceId。

Feign 拦截器示例:

java

@Component
public class FeignTraceInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        String traceId = TraceContext.getTraceId();
        if (traceId != null) {
            template.header("X-Trace-Id", traceId);
        }
    }
}

服务端通过 Filter 接收并设置即可。

7.3 自定义日志输出格式与目的地

目前日志直接通过 SLF4J 输出,可以扩展为输出到数据库、Kafka、ES 等。只需要将 LogInfo 序列化后发送到对应 sink。

7.4 支持 SpEL 表达式动态获取描述

注解中的 value 目前是静态字符串,可以扩展为 SpEL 表达式,动态获取方法参数的值拼接到描述中。

java

@LogRecord(value = "查询用户信息,userId = #{#userId}", printParams = false)
public User getUser(Long userId) { ... }

需要在切面中解析 SpEL。

7.5 方法执行时间阈值告警

对于超过指定时间的方法,可以打印告警日志,甚至发送监控指标。


8. 性能优化与注意事项

8.1 避免重复序列化

在切面中,我们两次调用了 JSON.toJSONString(一次用于参数,一次用于结果),如果参数本身已经是 JSON 字符串,可以优化。更好的做法是使用 lazy 求值,例如使用 Supplier,只有在日志级别开启时才序列化。

8.2 使用日志占位符

SLF4J 的占位符 {} 支持延迟参数求值,但我们这里构建了完整消息,无法利用。可以改为使用 log.info("logMsg: {}", () -> buildLogMsg(logInfo)),这样只有在日志级别开启时才构建消息。

8.3 线程池隔离

异步日志应使用独立的线程池,与业务线程池隔离,避免业务线程等待。线程池大小根据日志量和 I/O 能力合理配置。

8.4 不要记录文件流等大对象

在获取参数时,应判断类型,跳过 MultipartFileInputStreamOutputStream 等,防止内存溢出或无法序列化。

8.5 注意循环依赖

如果切面中注入了其他 Bean,而这些 Bean 的方法又被切面拦截,可能产生循环依赖。可通过 @Lazy 或调整 @Order 解决。

8.6 测试覆盖率

确保切面逻辑有单元测试,模拟正常执行、异常、异步等场景。


9. 生产实践案例

9.1 在 Controller 层使用

java

@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

    @Autowired
    private UserService userService;

    @LogRecord(value = "查询用户详情", desensitize = true, maxResultLength = 200)
    @GetMapping("/{id}")
    public Result<User> getUser(@PathVariable Long id) {
        return Result.success(userService.getById(id));
    }
}

日志输出示例:

text

=== Log Aspect =========================
TraceId      : 1a2b3c4d5e6f7g8h9i0j
Description  : 查询用户详情
Class Method : UserController.getUser
RequestUrl   : http://localhost:8080/user/123
HttpMethod   : GET
Client IP    : 192.168.1.100
UserAgent    : Mozilla/5.0 ...
Params       : [123]
CostTime     : 35 ms
Result       : {"id":123,"name":"张三","phone":"138****1234","idCard":"123***********89"}
=========================================

9.2 在 Service 层使用

java

@Service
public class OrderServiceImpl implements OrderService {

    @LogRecord(value = "创建订单", printResult = false)
    @Override
    public Order createOrder(OrderCreateDTO dto) {
        // 业务逻辑
    }
}

9.3 异常场景日志

当方法抛出异常时,日志会记录异常信息,方便定位。

text

=== Log Aspect =========================
TraceId      : 1a2b3c4d5e6f7g8h9i0j
Description  : 更新库存
Class Method : StockServiceImpl.updateStock
Params       : [{"productId":1001,"quantity":-5}]
CostTime     : 12 ms
Exception    : java.lang.IllegalArgumentException: 库存不足
 at          : com.example.service.StockServiceImpl.updateStock(StockServiceImpl.java:45)
=========================================

9.4 异步日志+采样

在高峰期,设置 sample-rate: 0.1 只记录 10% 的请求,同时开启异步,对业务几乎没有影响。


10. 常见问题与解决方案

10.1 为什么我的切面没有生效?

  • 检查 Spring Boot 启动类是否加了 @EnableAspectJAutoProxy(Spring Boot 会自动配置,但确认一下)。

  • 确认注解 @LogRecord 放在了 public 方法上(Spring AOP 默认只代理 public 方法)。

  • 检查切面类是否被 Spring 管理(是否有 @Component 或 @Bean)。

10.2 如何排除某些方法?

可以在切点表达式中使用 !@annotation 排除,或者在切面中根据方法名、类名判断。

10.3 日志打印导致接口变慢怎么办?

  • 开启异步日志。

  • 调整采样率。

  • 减少不必要的字段打印。

  • 使用更快的序列化库(如 fastjson 或 jackson 的 afterburner 模块)。

10.4 脱敏规则不满足业务需求?

可以自定义脱敏策略,通过注解参数指定脱敏类型,或使用策略模式注入。

10.5 如何将日志输出到专门的文件?

使用 Logback 的过滤器,根据 MDC 中的标记将日志路由到不同 appender。

10.6 多线程中 TraceId 丢失?

检查是否使用了线程池,使用 TTl 或手动传递。如果使用了 @Async,可以配置自定义的 TaskExecutor 装饰器。


11. 总结与展望

本文从甩锅的痛点出发,详细讲解了一个功能完备的日志切面的设计与实现。我们不仅实现了基础的方法入参出参记录、异常捕获、耗时统计,还加入了链路追踪、敏感数据脱敏、异步日志、采样控制等企业级特性。通过自定义注解,业务代码几乎零侵入,却能获得强大的日志能力。

未来,我们可以进一步扩展:

  • 集成分布式追踪系统:如 SkyWalking、Zipkin,将 TraceId 与日志关联。

  • 日志自动上报:将关键日志发送到 ELK 或 Loki,方便集中检索。

  • 动态配置:通过 Apollo 或 Nacos 实时调整日志级别、采样率。

  • 机器学习的异常检测:基于日志数据训练模型,提前发现潜在问题。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐