Skip to content
第 49 / 250 章后端⏱ 10 分钟阅读

第 49 章:日志体系

学习目标

  • 理解 SLF4J + Logback 的关系
  • 掌握 logback-spring.xml 完整配置
  • 学会 MDC 链路追踪
  • 掌握日志规范与脱敏

一、日志框架关系

java
// ✅ 代码里只用 SLF4J 接口,换实现不用改代码
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

private static final Logger log = LoggerFactory.getLogger(UserService.class);

// ✅ Lombok 简化
@Slf4j
public class UserService {
    public void doWork() {
        log.info("处理中");
    }
}

⚠️ 绝不要直接依赖 Logback 的 API(如 ch.qos.logback.*)。那样就绑死了实现,失去了门面模式的意义。

二、日志级别

TRACE < DEBUG < INFO < WARN < ERROR
级别用途生产环境
TRACE极详细的追踪关闭
DEBUG调试信息、SQL、参数关闭
INFO关键业务节点(下单成功、支付回调)开启
WARN可恢复的异常、降级、重试开启
ERROR需要人工介入的错误开启

判断标准ERROR 意味着「需要有人半夜爬起来处理」。用户输入错误、业务校验失败一律用 WARNINFO,否则告警会被淹没。

三、完整的 logback-spring.xml

xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- ① 读取 application.yml 中的配置 -->
    <springProperty scope="context" name="APP_NAME"
                    source="spring.application.name" defaultValue="app"/>
    <springProperty scope="context" name="LOG_PATH"
                    source="logging.file.path" defaultValue="logs"/>

    <!-- ② 日志格式:含 traceId 便于链路追踪 -->
    <property name="CONSOLE_PATTERN"
              value="%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) [%X{traceId}] - %msg%n"/>
    <property name="FILE_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{50} [%X{traceId}] - %msg%n"/>

    <!-- ③ 控制台输出 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ④ 全量日志文件:按天 + 按大小滚动 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/${APP_NAME}.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/${APP_NAME}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>       <!-- 单文件最大 -->
            <maxHistory>30</maxHistory>            <!-- 保留 30 天 -->
            <totalSizeCap>10GB</totalSizeCap>      <!-- 总大小上限,防打满磁盘 -->
        </rollingPolicy>
        <encoder>
            <pattern>${FILE_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ⑤ 错误日志单独一个文件:便于快速定位问题 -->
    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/${APP_NAME}-error.log</file>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>          <!-- 只要 ERROR -->
        </filter>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/${APP_NAME}-error-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
            <maxHistory>90</maxHistory>            <!-- 错误日志保留更久 -->
        </rollingPolicy>
        <encoder>
            <pattern>${FILE_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ⑥ 异步写入:日志不阻塞业务线程 -->
    <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
        <queueSize>2048</queueSize>                <!-- 队列大小 -->
        <discardingThreshold>0</discardingThreshold>  <!-- 0 = 队列满也不丢日志 -->
        <neverBlock>false</neverBlock>             <!-- false = 队列满时阻塞(不丢日志) -->
        <includeCallerData>false</includeCallerData>  <!-- ⚠️ true 会严重影响性能 -->
        <appender-ref ref="FILE"/>
    </appender>

    <!-- ⑦ 分环境配置 -->
    <springProfile name="dev">
        <logger name="com.taskflow" level="DEBUG"/>
        <logger name="com.taskflow.mapper" level="DEBUG"/>   <!-- 打 SQL -->
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>

    <springProfile name="prod">
        <logger name="com.taskflow" level="INFO"/>
        <root level="WARN">
            <appender-ref ref="ASYNC_FILE"/>
            <appender-ref ref="ERROR_FILE"/>
        </root>
    </springProfile>

</configuration>

⑥ 为什么必须异步? 同步写日志是磁盘 IO,一次约 0.1~1ms。一个接口打 10 条日志就是 10ms。QPS 高时日志会成为瓶颈。异步后业务线程只是往内存队列丢一下,微秒级。

discardingThreshold=0 很关键。默认值是 queueSize/5,意思是队列剩余不足 20% 时丢弃所有 INFO 及以下的日志。生产排查问题时发现日志断了却找不到原因,往往就是这个。

四、MDC 链路追踪

问题:并发请求下,日志是交错的,无法分辨哪几条属于同一个请求。

10:00:01 INFO 开始查询用户
10:00:01 INFO 开始创建订单        ← 这是另一个请求的
10:00:02 INFO 用户查询完成

解决:给每个请求分配唯一 traceId。

java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {

    public static final String TRACE_ID = "traceId";
    private static final String HEADER = "X-Trace-Id";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {
        try {
            // ① 优先用上游传来的 traceId(微服务链路打通的关键)
            String traceId = request.getHeader(HEADER);
            if (!StringUtils.hasText(traceId)) {
                traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
            }

            MDC.put(TRACE_ID, traceId);                    // ② 放入 MDC
            response.setHeader(HEADER, traceId);           // ③ 返回给前端,便于报障时提供

            chain.doFilter(request, response);

        } finally {
            MDC.clear();                                    // ④ 必须清理!
        }
    }
}

④ 为什么必须 MDC.clear() MDC 底层是 ThreadLocal。Tomcat 的线程是复用的,不清理的话下一个请求会继承上一个请求的 traceId,日志就串了。

效果

10:00:01 INFO [a1b2c3d4] 开始查询用户
10:00:01 INFO [e5f6g7h8] 开始创建订单
10:00:02 INFO [a1b2c3d4] 用户查询完成

现在 grep a1b2c3d4 app.log 就能拿到这个请求的完整日志。

异步线程中传递 MDC

java
// ❌ 子线程拿不到 MDC(ThreadLocal 不跨线程)
pool.execute(() -> log.info("异步任务"));    // traceId 为空

// ✅ 手动传递
Map<String, String> context = MDC.getCopyOfContextMap();
pool.execute(() -> {
    if (context != null) MDC.setContextMap(context);
    try {
        log.info("异步任务");
    } finally {
        MDC.clear();
    }
});

// ✅ 更优雅:装饰线程池
public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> context = MDC.getCopyOfContextMap();
        return () -> {
            try {
                if (context != null) MDC.setContextMap(context);
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}

// 注册到线程池
executor.setTaskDecorator(new MdcTaskDecorator());

五、日志编写规范

java
// ① 用占位符,不要字符串拼接
log.info("用户 {} 下单成功,订单号 {}", userId, orderNo);     // ✅
log.info("用户 " + userId + " 下单成功");                     // ❌ 无论级别是否开启都会执行拼接

// ② 打异常必须传异常对象作为最后一个参数
log.error("订单创建失败, orderNo={}", orderNo, e);            // ✅ 有完整堆栈
log.error("订单创建失败: " + e.getMessage());                 // ❌ 没堆栈,等于没打

// ③ 不要用 e.printStackTrace()
e.printStackTrace();                                          // ❌ 输出到 stderr,不进日志文件

// ④ 大对象日志用 isDebugEnabled 包一层
if (log.isDebugEnabled()) {
    log.debug("完整数据: {}", JsonUtils.toJson(hugeObject));   // ✅ 避免无谓的序列化
}

// ⑤ 循环内不要打日志
for (Order order : orders) {
    log.info("处理订单 {}", order.getId());                    // ❌ 1 万条订单刷 1 万行
}
log.info("批量处理 {} 条订单完成,失败 {} 条", total, failed);  // ✅ 聚合后打一条

// ⑥ 日志要有上下文,能定位问题
log.error("查询失败");                                         // ❌ 什么都查不到
log.error("查询用户失败, userId={}, deptId={}", userId, deptId, e);  // ✅

六、敏感信息脱敏

java
public final class MaskUtils {

    /** 手机号:138****8888 */
    public static String phone(String phone) {
        if (phone == null || phone.length() != 11) return phone;
        return phone.substring(0, 3) + "****" + phone.substring(7);
    }

    /** 身份证:110***********1234 */
    public static String idCard(String id) {
        if (id == null || id.length() < 8) return id;
        return id.substring(0, 3) + "*".repeat(id.length() - 7) + id.substring(id.length() - 4);
    }

    /** 银行卡:**** **** **** 1234 */
    public static String bankCard(String card) {
        if (card == null || card.length() < 4) return card;
        return "**** **** **** " + card.substring(card.length() - 4);
    }

    private MaskUtils() { }
}

自动脱敏(Jackson 序列化时)

java
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = MaskSerializer.class)
public @interface Mask {
    MaskType value();
}

@Data
public class UserVO {
    @Mask(MaskType.PHONE)
    private String phone;          // 序列化时自动变成 138****8888
}

法规要求:《个人信息保护法》《GDPR》都要求日志中不得留存明文个人敏感信息。这不是「最好做」而是「必须做」。

绝不能进日志的:密码、身份证号、银行卡号、CVV、Token、API Secret、完整手机号、家庭住址。

七、日志采集与查询

输出 JSON 格式便于采集

xml
<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>
xml
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>${LOG_PATH}/${APP_NAME}.json</file>
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>traceId</includeMdcKeyName>
        <customFields>{"app":"${APP_NAME}","env":"prod"}</customFields>
    </encoder>
</appender>
方案特点
ELKElasticsearch + Logstash + Kibana,功能全但吃资源
EFK用 Fluentd 替代 Logstash,更轻量
Loki + GrafanaGrafana 出品,只索引标签不索引全文,成本低很多
云日志服务阿里云 SLS / AWS CloudWatch,省运维

中小团队推荐 Loki:ELK 存 1TB 日志需要的机器成本可能是 Loki 的 5-10 倍。

八、本章小结

要点关键
SLF4J只依赖门面,用 @Slf4j
ERROR只用于「需要人工介入」的错误
异步AsyncAppender + discardingThreshold=0
滚动按天+大小,设 totalSizeCap 防打满磁盘
错误日志单独文件,保留更久
MDCtraceId 串联请求,必须 clear
占位符log.info("{}", x),不要拼接
异常最后一个参数传异常对象
脱敏法规要求,不是可选项

动手练习

练习 1:基础题

配置 logback-spring.xml,实现:控制台彩色输出、文件按天滚动保留 7 天、ERROR 单独文件。

练习 2:进阶题

实现 TraceIdFilter,并写一个装饰过的线程池,验证异步任务中 traceId 能正确传递。


下一章第 50 章:统一响应封装

本站基于 VitePress 构建 · 由 Codebook 团队维护