日志框架

System.out.println 是新手最爱的调试工具,但生产环境用它就是灾难——不能按级别过滤、不能写文件、不能滚动归档、不能控制格式、性能差(同步 IO 阻塞)。日志框架就是为解决这些而生——它是生产级应用的”黑匣子记录仪”,让你在出问题时能从一堆日志里还原现场。

这一章看 Java 日志的”门面 + 实现”双层架构——SLF4J 门面 + Logback 实现,以及日志的最佳实践。

一、Java 日志的混乱史

Java 日志生态很混乱,先理清楚:

框架角色时期
java.util.logging(JUL)JDK 自带1999
Log4j 1.x第一代主流2001
SLF4J日志门面(API)2004
LogbackLog4j 作者的新实现2009
Log4j 2Log4j 重写,性能最好2014

混乱的根源——Log4j 1.x 是早期主流,但没设计门面;JUL 是 JDK 自带的,但 API 不好用;Apache Commons Logging(JCL)想做门面但用类加载器有坑;最后 SLF4J 横空出世成为门面标准,Logback/Log4j2 是实现。

现代最佳实践

应用代码 --> SLF4J (门面) --> Logback 或 Log4j2 (实现)

应用只面向 SLF4J API 编程,背后接什么实现由配置决定。换实现不改业务代码——这就是”门面模式”。

二、SLF4J:日志门面

SLF4J(Simple Logging Facade for Java)只是 API,本身不输出日志。它需要绑定一个底层实现:

<!-- SLF4J API -->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>2.0.9</version>
</dependency>

<!-- 绑定 Logback (Logback 自带 slf4j binding, 不需要额外依赖) -->
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.4.14</version>
</dependency>

2.1 基本用法

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

public class UserService {
    // 用类名声明 logger, 方便定位
    private static final Logger log = LoggerFactory.getLogger(UserService.class);

    public void login(String name) {
        log.info("用户登录: {}", name);          // 参数化日志
        log.debug("调试信息: id={}", 42);

        try {
            // 业务...
        } catch (Exception e) {
            log.error("登录失败: user={}", name, e);   // 异常作为最后参数
        }
    }
}

{} 是占位符——参数按顺序填充。这是 SLF4J 的杀手锏之一(详见下文性能章节)。

三、日志级别

日志分 5 级,从详细到严重:

级别用途示例
TRACE极细的跟踪信息Entering method foo with args=[1,2]
DEBUG调试信息Cache miss for key=user:42
INFO重要业务事件User 42 logged in from 1.2.3.4
WARN警告,可恢复Connection pool 80% full
ERROR错误,影响功能Failed to send email to user 42

级别有继承关系——开 INFO 会输出 INFO/WARN/ERROR,不输出 TRACE/DEBUG。生产环境一般开 INFO,开发环境开 DEBUG

log.trace("最详细");    // 生产几乎不开
log.debug("调试用");    // 开发开, 生产关
log.info("重要事件");   // 默认级别
log.warn("警告");
log.error("错误");      // 一定要看

怎么选级别?

  • ERROR —— 影响功能、需要立即关注(数据库连不上、关键调用失败)。
  • WARN —— 可恢复但要注意(重试成功、降级、连接池逼近上限)。
  • INFO —— 关键业务节点(登录、下单、支付完成)。
  • DEBUG —— 开发期排查问题用,生产关掉。
  • TRACE —— 极少用,几乎只在调试特定 bug 时短暂开。

常见误用

  • 把异常都 log 成 ERROR——一些异常是业务正常分支(如 UserNotFoundException),该 WARN 或 INFO。
  • 大量 INFO——日志爆炸,淹没关键信息。INFO 要克制。
  • ERROR 后又抛异常——上层再 log 一次 ERROR,重复。要么 log 要么抛,不要都做。

四、Logback 配置

logback.xml(放在 src/main/resources/)控制日志格式、输出位置、级别:

<configuration>
    <!-- 控制台输出 -->
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 文件输出 (滚动归档) -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/app.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
        </rollingPolicy>
        <encoder>
            <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 按包名控制级别 -->
    <logger name="com.example.service" level="DEBUG"/>
    <logger name="org.springframework" level="WARN"/>
    <logger name="org.hibernate.SQL" level="DEBUG"/>

    <!-- 根级别 -->
    <root level="INFO">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>

4.1 Pattern 格式

%pattern 控制每行日志的格式:

占位符含义
%d{...}时间戳,格式同 SimpleDateFormat
%thread线程名
%-5level级别(左对齐 5 字符)
%logger{36}logger 名(类名),{36} 是缩写长度
%msg / %message日志消息
%n换行
%M方法名(性能开销大,慎用)
%L行号(性能开销大,慎用)
%X{key}MDC(Mapped Diagnostic Context)值
%-20.20loggerlogger 名固定 20 字符宽

经典 Pattern:

%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n

输出:

2024-01-15 14:30:25.123 [main] INFO  c.e.s.UserService - 用户登录: 张三

4.2 滚动归档

生产日志不能无限增长——RollingFileAppender 按时间/大小滚动:

  • 每天一个文件,超过 100MB 切分。
  • 保留 30 天历史。
  • 总大小超 10GB 自动删旧。
  • 历史文件 gzip 压缩。
logs/
├── app.log                       # 当天
├── app.2024-01-13.0.log.gz       # 前天 (压缩)
├── app.2024-01-14.0.log.gz
└── app.2024-01-14.1.log.gz       # 昨天第二个文件 (>100MB 切的)

五、性能与异步日志

5.1 参数化日志:避免字符串拼接

错误写法

log.debug("用户 " + name + " 登录, id=" + id);   // 总是拼接!

即使 DEBUG 关闭,字符串拼接照样执行——浪费 CPU 和内存。

正确写法

log.debug("用户 {} 登录, id={}", name, id);

{} 是占位符,只在日志实际输出时才替换——DEBUG 关闭时几乎零开销。这就是 SLF4J 的关键设计。

5.2 关闭日志的代价

// 假设 DEBUG 关闭
log.debug("复杂计算: " + expensiveCompute());   // expensiveCompute 仍然执行!

log.debug("复杂计算: {}", expensiveCompute());   // expensiveCompute 仍然执行! (参数要算)

// 用 guard 避免高开销参数
if (log.isDebugEnabled()) {
    log.debug("复杂计算: {}", expensiveCompute());
}

注意第二种——参数化日志只在拼接上省了,参数本身还是要算的。如果参数计算很贵(序列化大对象、查询数据库),用 isDebugEnabled() 守卫。但简单参数(基本类型、字段访问)不用守卫,参数化日志就够了。

5.3 异步日志

同步日志——写文件 IO 阻塞业务线程。高并发下日志成为瓶颈。异步日志——业务线程扔到队列,后台线程批量写:

Logback 异步 Appender:

<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>4096</queueSize>
    <discardingThreshold>0</discardingThreshold>   <!-- 0 = 不丢弃任何级别 -->
    <neverBlock>true</neverBlock>                   <!-- 队列满不阻塞业务 -->
    <appender-ref ref="FILE"/>
</appender>
  • 业务线程入队即返回,不等写盘。
  • 后台线程批量写,性能高 10~100 倍。
  • 代价——程序崩溃时队列里没写完的日志会丢。

Log4j 2 的 Async Logger 更激进——用 Disruptor 无锁队列,比 Logback 异步快 10 倍以上。追求极致性能选 Log4j 2。

5.4 异常栈怎么打

// 对: 异常作为最后一个参数, 不写在 {} 里
try {
    ...
} catch (SQLException e) {
    log.error("查询用户失败: id={}", id, e);   // 异常自动打栈
}

// 错: e.getMessage() 丢了栈
log.error("查询失败: {}", e.getMessage());   // 没栈, 怎么定位?

// 错: 拼字符串
log.error("查询失败: " + e);   // e.toString() 没栈

异常对象作为 log.error(msg, args, exception) 的最后一个参数(不加 {}),SLF4J 自动打印完整栈。e.getMessage() 只有消息文本,没有栈——出问题没法定位。

六、MDC:链路追踪

MDC(Mapped Diagnostic Context)——线程本地的日志上下文。在请求开始时塞入 traceId,后续日志自动带上:

import org.slf4j.MDC;

// 请求开始
MDC.put("traceId", UUID.randomUUID().toString());
MDC.put("userId", "user42");

try {
    // 业务代码, 各处 log 都会自动带 traceId/userId
    log.info("处理订单");
} finally {
    MDC.clear();   // 清理, 避免线程池泄漏
}

Pattern 里加 %X{traceId}

%d{HH:mm:ss} [%X{traceId}] %-5level %logger - %msg%n

输出:

14:30:25 [a1b2c3d4] INFO  c.e.OrderService - 处理订单

MDC 是分布式链路追踪的基础——同一请求的所有日志带同一 traceId,能在 ELK/Loki 里一键搜出完整调用链。Spring Cloud Sleuth/Micrometer Tracing 就是基于 MDC 自动注入 traceId。

七、实战:日志最佳实践演示

由于 Piston 环境没有 SLF4J/Logback 依赖,下面用 Java SE 模拟一个迷你日志框架——演示级别过滤、参数化日志、MDC、异步日志的核心思想。

Java · 在线运行

观察重点

  • 级别关闭时参数化日志几乎零开销——这是 SLF4J 的关键设计,对比字符串拼接省了百万次拼接。
  • 异常作为最后参数自动打栈——不要用 e.getMessage(),丢栈无法定位。
  • MDC 让日志自动带 traceId——同一请求所有日志可串起来。
  • 异步日志提交 100 条几乎瞬间完成——业务线程不等写盘。

八、日志最佳实践清单

  1. 用 SLF4J 门面——业务代码不直接依赖 Logback/Log4j2,便于换实现。
  2. 永远参数化——log.info("x={}", x),不要 log.info("x=" + x)
  3. 异常作为最后参数——log.error("msg", e),自动打栈。
  4. 级别克制——INFO 要精,ERROR 要严。
  5. 不要在循环里大量 log——容易日志爆炸。
  6. 不要记录敏感信息——密码、身份证、卡号千万别进日志。
  7. 生产用异步日志——避免 IO 阻塞业务。
  8. 日志要带上下文——log.info("用户 {} 登录", name)log.info("登录成功") 有用得多。
  9. 文件滚动归档——别让日志撑爆磁盘。
  10. ERROR 后不要抛——log 和抛二选一,避免重复记录。

九、本章小结

概念核心要点
SLF4J日志门面,应用面向它编程
LogbackSLF4J 的主流实现
5 级日志TRACE/DEBUG/INFO/WARN/ERROR
参数化日志{} 占位符,关闭时零开销
异常打栈异常作为最后参数,自动输出栈
MDC线程本地上下文,做链路追踪
异步日志队列 + 后台写,不阻塞业务
Pattern控制日志格式
滚动归档按时间/大小切文件,控制总量

记忆口诀

  • 门面 + 实现——SLF4J 门面,Logback 实现。
  • 5 级日志——TRACE 最细、ERROR 最严重。
  • 参数化别拼接——{} 占位符,关闭时零开销。
  • 异常末尾不带占位符——log.error("msg", e) 自动打栈。
  • 生产异步 + 滚动——不阻塞、不爆炸。
  • MDC 串请求——traceId 是分布式追踪的基础。

结语:从 println 到日志框架

这一章我们看了日志——从 SLF4J 门面到 Logback 实现,从级别到 Pattern,从 MDC 到异步。生产环境没有日志等于盲飞——出问题只能瞎猜。下一章看 Java 生态的”工具箱”——Lombok 与常用工具库,让你少写样板代码。