Skip to content

日志框架 ​

Java 中级 · 工程化

日志是线上排查的第一现场。Java 日志生态出了名的乱:JUL、JCL、Log4j、Log4j2、Logback、SLF4J……新手常常「重复打印」或「冲突报错」。本篇讲清 SLF4J 门面 + 实现分离的设计哲学、Logback 配置、以及规范地打日志。

一、日志的门面与实现 ​

1.1 为什么要有「门面」 ​

如果代码直接依赖 Log4j,将来换成 Logback 就要改遍全项目。SLF4J(Simple Logging Facade for Java) 是门面(接口),业务代码只依赖它;具体实现(Logback/Log4j2)可在部署时替换。

java
// 业务代码只 import SLF4J
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);
    public void create() {
        log.info("创建订单, id={}", 1001);
    }
}
门面实现
SLF4J / JCLLogback / Log4j2 / JUL / Log4j

Spring Boot 默认组合

Spring Boot 的 spring-boot-starter-logging 默认是 SLF4J + Logback。所以引依赖时别再引 Log4j,否则类路径多套实现会冲突/重复打印。

二、SLF4J 的核心优势:占位符 ​

java
// ❌ 字符串拼接:无论日志级别是否输出,都会拼字符串(浪费)
log.debug("user: " + user.getName() + ", age: " + user.getAge());

// ❌ 早期 JCL 的 isDebugEnabled 判断(啰嗦)
if (log.isDebugEnabled()) log.debug("user: " + user);

// ✅ SLF4J 占位符:只有真正输出时才格式化(惰性)
log.debug("user: {}, age: {}", user.getName(), user.getAge());

// ✅ 异常作为最后一个参数,自动打印堆栈
try { doSomething(); } catch (Exception e) {
    log.error("处理失败, id={}", id, e);   // 注意:e 放最后,不加占位符
}

{} 相比字符串拼接的性能优势

log.debug("x=" + compute()) 即使 debug 没开也会执行 compute() 和拼接;而 SLF4J 的 {} 在日志级别未开启时直接返回,不做任何格式化。高并发下差别明显。

三、日志级别 ​

从低到高:TRACE < DEBUG < INFO < WARN < ERROR。级别设成 N 表示只输出 >= N 的日志。

级别用途
TRACE最细,逐帧/逐字段(几乎不用)
DEBUG调试细节,开发环境
INFO关键流程节点(用户登录、订单创建)
WARN可恢复的异常(重试成功、降级触发)
ERROR错误,需要人工关注/告警

级别用错是常见事故

  • 把异常当 warn:告警不触发,问题被埋。
  • 把正常业务打 error:告警风暴,狼来了。
  • 生产开 debug:日志量暴涨,磁盘写满、性能下降。

四、Logback 配置 ​

Logback 读取 logback-spring.xml(Spring Boot 推荐,可用 <springProfile>):

xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <property name="LOG_HOME" value="./logs"/>
    <property name="PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>

    <!-- 控制台 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder><pattern>${PATTERN}</pattern></encoder>
    </appender>

    <!-- 按天 + 按大小滚动,保留 30 天 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_HOME}/app.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_HOME}/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
        </rollingPolicy>
        <encoder><pattern>${PATTERN}</pattern></encoder>
    </appender>

    <springProfile name="dev">
        <root level="DEBUG"><appender-ref ref="CONSOLE"/></root>
    </springProfile>
    <springProfile name="prod">
        <root level="INFO"><appender-ref ref="FILE"/></root>
        <logger name="com.demo.order" level="DEBUG"/>   <!-- 只给业务包开 debug -->
    </springProfile>
</configuration>

4.1 异步日志 ​

高并发下同步写盘会拖慢业务,用 AsyncAppender 改为异步:

xml
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>512</queueSize>
    <discardingThreshold>0</discardingThreshold>  <!-- 0=不丢弃任何日志 -->
    <appender-ref ref="FILE"/>
</appender>

异步日志的取舍

异步队列满了默认会丢弃 TRACE/DEBUG/INFO。关键业务日志要设 discardingThreshold=0,或接受轻微性能损耗用同步。另外注意服务关闭前要 flush,否则丢日志。

五、MDC:给日志打上「请求链路」 ​

MDC(Mapped Diagnostic Context)基于 ThreadLocal,可以把 traceId、userId 自动打进每条日志,便于链路追踪。

java
// 入口(过滤器/拦截器)放 traceId
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put("traceId", traceId);
try {
    chain.doFilter(req, resp);
} finally {
    MDC.clear();          // 必须清理,否则线程池复用会串
}
xml
<!-- pattern 里加 %X{traceId} -->
<pattern>%d{HH:mm:ss} [%thread] [%X{traceId}] %-5level %logger{20} - %msg%n</pattern>

输出:

text
10:20:31 [http-nio-8080-exec-1] [a1b2c3d4] INFO  c.d.OrderService - 创建订单成功

ThreadLocal 泄漏

MDC 用 ThreadLocal 存储,线程池会复用线程。如果不在 finally 里 MDC.clear()/remove(),下一个请求会带着上一个请求的 traceId,排查时张冠李戴。

六、日志规范 ​

  • log.info 打业务关键节点,log.error 打需要告警的异常,别滥用。
  • 异常必须带堆栈(把 e 作为最后一个参数),否则只剩一行消息,排查无门。
  • 日志参数化({}),不拼接字符串。
  • 不打印敏感信息:手机号、身份证、密码、银行卡要脱敏(138****8000)。
  • 大对象/大 JSON 别直接打,截断或只打关键字段。
  • 日志文件必须滚动 + 限制大小,否则撑爆磁盘。

禁止事项

java
// ❌ 吞异常:比不打日志还糟
try { ... } catch (Exception e) { }

// ❌ 只打 e.getMessage(),丢堆栈
catch (Exception e) { log.error(e.getMessage()); }

// ❌ 循环里打日志:日志风暴
for (Order o : orders) { log.info("处理 {}", o.getId()); }  // 几万条直接刷屏

// ❌ 打全量敏感数据
log.info("用户信息: {}", JSON.toJSONString(user)); // 含手机号/身份证

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. SLF4J 是门面还是实现?为什么业务代码只依赖它?Spring Boot 默认用什么实现?
  2. log.debug("a=" + x) 和 log.debug("a={}", x) 有什么区别?为什么后者更优?
  3. MDC 的底层是什么?为什么用完之后必须 clear()?
  4. 列举至少 4 种错误的打日志方式,并说明危害。

本节小结

日志体系的核心是 SLF4J 门面 + 实现分离,业务只依赖门面、实现可替换(Spring Boot 默认 Logback)。用 {} 占位符而非拼接,异常作为最后一个参数打印堆栈。级别别用错,异步日志提性能,MDC 打 traceId 串链路(用完必 clear)。规范打日志,是线上可运维的基础。