Skip to content

第49课:日志框架

🎯 学习目标

  • 理解日志不是 System.out.println 的替代品,而是线上系统的可观测性基础。
  • 掌握 SLF4J、Logback、Log4j2、JUL 之间的关系,知道项目里应该依赖哪一层。
  • 能在 Spring Boot 项目中配置日志级别、日志格式、文件滚动和异步日志。
  • 能写出可检索、可定位、低开销的业务日志。
  • 能识别日志泄露、重复打印、吞异常、字符串拼接等常见问题。

📖 一、为什么需要日志框架

Java 程序在本地运行时,System.out.println() 看起来足够;但一旦进入企业项目,它很快暴露问题:

问题System.out.println日志框架
级别控制无法按 DEBUG/INFO/ERROR 控制支持按包、类、环境动态控制
输出位置通常只能到控制台控制台、文件、远程采集系统
格式统一手写,容易不一致统一时间、线程、级别、类名、traceId
性能字符串常被提前拼接支持参数化、异步、缓冲
运维检索信息零散可被 ELK、Loki、Cloudflare Logs 等采集

日志的核心价值不是“打印信息”,而是回答线上问题:

text
谁在什么时间调用了哪个接口?
传入了什么关键业务参数?
系统在哪一步失败?
失败是业务错误、参数错误、网络错误,还是依赖服务错误?
一次请求跨多个服务时,如何串起来看完整链路?

因此,日志设计属于系统设计的一部分。


📖 二、Java 日志体系

Java 日志生态容易混乱,因为它分为“门面”和“实现”。

1. 日志门面

门面只定义 API,不负责真正写日志。业务代码应该依赖门面。

门面说明
SLF4J企业项目最常用,推荐业务代码直接使用
Commons LoggingSpring 早期常见,现在通常由 Spring 内部兼容
JULJDK 自带 java.util.logging,能力较弱

2. 日志实现

实现负责真正输出日志。

实现说明
LogbackSpring Boot 默认实现,配置简单,生态稳定
Log4j2性能强,异步日志能力突出
JULJDK 自带实现,企业项目很少直接选它

3. 推荐组合

大多数 Spring Boot 项目直接使用:

text
业务代码:SLF4J API
底层实现:Logback
Spring Boot starter:spring-boot-starter-logging

业务代码不要直接依赖 Logback API,否则以后切换 Log4j2 会很麻烦。


📖 三、基本用法

1. 创建 Logger

java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    public void createOrder(Long userId, Long productId) {
        log.info("create order start, userId={}, productId={}", userId, productId);

        try {
            // 业务逻辑
            log.info("create order success, userId={}, productId={}", userId, productId);
        } catch (Exception e) {
            log.error("create order failed, userId={}, productId={}", userId, productId, e);
            throw e;
        }
    }
}

注意最后一行:

java
log.error("create order failed, userId={}, productId={}", userId, productId, e);

异常对象 e 必须作为最后一个参数传入,这样日志框架会输出完整堆栈。

2. 参数化日志

推荐:

java
log.info("user login success, userId={}, ip={}", userId, ip);

不推荐:

java
log.info("user login success, userId=" + userId + ", ip=" + ip);

原因是字符串拼接会提前执行,即使当前日志级别不会输出这条日志,拼接成本也已经产生。


📖 四、日志级别

常见级别从低到高:

级别使用场景
TRACE极细粒度调试,通常只在框架或临时排查中使用
DEBUG开发和测试环境排查细节
INFO关键业务流程,生产环境默认可见
WARN可恢复异常、降级、重试、参数边界问题
ERROR当前操作失败,需要开发或运维关注

推荐标准

text
DEBUG:开发排查细节,例如 SQL 参数、分支判断。
INFO :业务关键节点,例如下单成功、支付回调、任务开始结束。
WARN :系统还能继续运行,但出现了异常信号。
ERROR:请求失败、任务失败、数据不一致、依赖不可用。

不要把所有日志都打成 INFO,否则生产环境会被无用日志淹没。


📖 五、Spring Boot 日志配置

1. application.yml 基础配置

yaml
logging:
  level:
    root: INFO
    com.example.demo: DEBUG
    org.springframework.web: INFO
  file:
    name: logs/app.log
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n"
    file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n"

这适合简单项目。更复杂的滚动策略、异步日志、脱敏逻辑,建议使用 logback-spring.xml

2. logback-spring.xml 示例

xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="app"/>

    <property name="LOG_PATH" value="logs"/>
    <property name="LOG_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%X{traceId:-}] %logger{36} - %msg%n"/>

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_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>
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy
                    class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <maxHistory>30</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
        </rollingPolicy>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>

这里的 %X{traceId:-} 来自 MDC,用于把一次请求的链路 ID 打入每条日志。


📖 六、MDC 与 traceId

MDC 是 Mapped Diagnostic Context,可以为当前线程绑定上下文信息。

1. 请求过滤器示例

java
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;

import java.io.IOException;
import java.util.UUID;

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-Trace-Id");
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }

        try {
            MDC.put("traceId", traceId);
            chain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

2. 为什么必须 clear

Tomcat 线程池会复用线程。如果请求结束后不清理 MDC,下一个请求可能继承上一个请求的 traceId,导致链路串线。


⚠️ 七、常见陷阱

1. 吞异常

错误示例:

java
try {
    paymentClient.pay(orderId);
} catch (Exception e) {
    log.error("pay failed");
}

问题是堆栈丢失,无法定位。

正确示例:

java
try {
    paymentClient.pay(orderId);
} catch (Exception e) {
    log.error("pay failed, orderId={}", orderId, e);
    throw e;
}

2. 重复打印异常

Controller、Service、DAO 每层都 log.error,会导致同一个异常刷屏三次。通常只在边界层或真正处理异常的位置打印。

3. 打印敏感信息

不要直接打印:

text
身份证号
手机号完整值
银行卡号
密码
Token
Cookie
Authorization Header

生产日志一旦被采集到集中系统,泄露范围会扩大。

4. 日志级别滥用

业务失败不一定是 ERROR。例如用户密码错误、库存不足、参数校验失败,通常属于正常业务分支,适合 INFOWARN


🆚 八、Java vs C 对比

维度C 常见做法Java 企业项目
输出方式printf、文件写入、syslogSLF4J + Logback/Log4j2
上下文手动拼接线程、请求信息MDC 自动注入 traceId
日志级别需要自行封装框架内置级别
文件滚动手写或依赖系统工具框架配置滚动策略
异步输出需要自行实现队列框架支持异步 appender

Java 日志框架把“输出、格式、级别、滚动、采集”都标准化了。业务代码只需要表达事件本身。


💡 九、最佳实践

  • 业务代码只依赖 SLF4J,不直接依赖 Logback 或 Log4j2 API。
  • 日志必须带关键业务 ID,例如 userIdorderIdrequestId
  • 生产默认 INFO,排查问题时临时打开某个包的 DEBUG
  • 异常日志必须带异常对象,不能只打印 e.getMessage()
  • 高频循环里少打日志,必要时采样或聚合。
  • 不在日志里输出敏感数据,必须输出时先脱敏。
  • 接口入口、外部依赖调用、异步任务开始结束、关键状态变更都应该有日志。
  • 同一个异常只在一个边界位置打印,避免重复刷屏。

🎓 小结

日志框架是企业 Java 项目的基础设施。一个合格的日志系统至少要做到:统一格式、正确级别、包含业务上下文、可按 traceId 检索、不会泄露敏感信息、不会拖垮性能。

掌握日志后,后续学习 JUnit、JSON、Validation、Swagger 和 Spring Boot 项目时,才能把“程序能跑”提升到“线上可排查、可维护”。


🧭 十、项目落地清单

新建 Spring Boot 项目时,日志部分至少完成这些配置:

text
1. 统一使用 SLF4J API。
2. 确认底层实现是 Logback 或 Log4j2,避免多个实现冲突。
3. 配置控制台日志格式,开发环境方便阅读。
4. 配置文件日志滚动,生产环境避免单文件无限增长。
5. 日志格式包含时间、级别、线程、logger、traceId。
6. 接入全局 traceId 过滤器或链路追踪系统。
7. 为不同包设置合理级别,例如业务 INFO、SQL DEBUG 仅测试环境开启。
8. 统一异常打印位置,避免重复堆栈。
9. 建立敏感字段脱敏规则。
10. 确认日志采集系统能按 traceId、orderId、userId 检索。

示例日志应该长这样:

text
2026-06-30 21:10:12.123 INFO  [http-nio-8080-exec-1] [traceId=8f1a...] c.e.OrderService - create order success, orderId=1001, userId=20

不应该长这样:

text
success
error
用户请求了接口
java.lang.NullPointerException

日志必须能帮助一个没有上下文的人定位问题。


🔍 十一、自测问题

学习完本节后,应该能回答:

text
SLF4J 和 Logback 分别是什么?
为什么业务代码不应该直接依赖 Logback API?
DEBUG、INFO、WARN、ERROR 的边界是什么?
为什么异常对象要作为 log.error 的最后一个参数?
为什么日志里需要 traceId?
为什么 MDC 使用后必须 clear?
为什么不能在生产环境随意打开 SQL DEBUG?
为什么不能打印 Token 和密码?

如果这些问题答不上来,说明还只是会“打印日志”,没有真正掌握企业项目日志设计。


📌 十二、学习建议

学习日志框架时,建议自己做一次小实验:

text
1. 写一个接口,入口打印 traceId。
2. 在 Service 中打印 orderId 和 userId。
3. 人为抛出异常,确认日志有完整堆栈。
4. 把日志级别从 INFO 改成 DEBUG,观察输出变化。
5. 配置文件滚动,确认不会无限写入单个日志文件。

这比只看配置更容易建立直觉。