Java 日志体系演进与选型

早年的 Java 程序用 `System.out.println` 打印调试信息,输出进控制台就没了归档,生产环境根本没法排查。随后出现了 Log4j、Java 自带的 java.util.logging,再到 Logback、Log4j2,一套日志体系逐步成型。

如今的主流实践是「门面加实现」:代码只依赖 SLF4J 这个门面接口,运行时再绑定具体的实现框架。这样业务代码不写死框架,想换 Logback 还是 Log4j2 只改依赖与配置,不动一行业务代码。

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

Spring Boot 默认集成 SLF4J 加 Logback,开箱即用;需要切换 Log4j2 时,排除 spring-boot-starter-logging 再引入对应 starter 即可。选型的核心原则是:代码里永远只出现 SLF4J 的 API,把实现的选择权留在构建与运维侧。

日志是生产环境里唯一的「时光机」,写得好坏直接决定排障速度。
本文核心知识点
  • SLF4J门面与占位符。
  • Logback配置与滚动策略。
  • MDC请求追踪贯穿。
  • JSON结构化日志治理。
Java 日志框架配图:蓝调插画中左侧堆叠条纹日志面板,光束射向右侧滚动圆盘装置,体现日志记录与滚动治理
日志从产生、写入到滚动归档,是一条完整的流水线。

SLF4J 门面与占位符

SLF4J 最实用的特性是 `{}` 占位符。相比字符串拼接,占位符写法只在日志级别达到阈值时才真正拼接参数,避免无谓的对象转字符串开销;同时可读性更好,一眼看清消息模板和变量。

log.info("用户 {} 下单,订单号 {}", userId, orderId);
log.warn("库存不足,商品 {} 剩余 {}", sku, stock);

判断级别的写法 `if (log.isDebugEnabled())` 在旧代码里很常见,现代 SLF4J 在多数实现下占位符方案已足够高效,不必到处写判断。真正要省的是把大对象直接塞进日志、把循环里每条记录都打成 info 这类行为。

门面的另一价值是统一日志行为:无论底层是哪个实现,`trace/debug/info/warn/error` 五级语义一致,换实现时业务代码零改动。这也是微服务多模块项目里最重要的约定:每个模块都用同一个门面,日志风格才能统一。

Logback 核心配置

Logback 的配置文件是 `logback-spring.xml`(Spring Boot 环境)或 `logback.xml`。核心元素三件:`appender` 决定日志写到哪、什么格式;`logger` 决定某个包或类用哪个级别与 appender;根 `root` 是兜底。

<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
    </encoder>
  </appender>
  <root level="info">
    <appender-ref ref="CONSOLE"/>
  </root>
</configuration>

`pattern` 里的占位符是输出格式模板:`%d` 时间、`%level` 级别、`%logger` 类名、`%msg` 消息、`%n` 换行。`%-5level` 表示左对齐补位。格式模板要尽早定好并全员遵守,日志是给人查的,格式混乱等于没有日志。

日志分级与滚动策略

生产日志不能无限增长,必须按大小或时间滚动。Logback 的 `RollingFileAppender` 配合 `TimeBasedRollingPolicy` 可以按天生成文件,并配合 `SizeAndTimeBasedRollingPolicy` 在单文件超过阈值时再切分。

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
  <file>/data/logs/app.log</file>
  <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
    <fileNamePattern>/data/logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
    <maxFileSize>100MB</maxFileSize>
    <maxHistory>30</maxHistory>
    <totalSizeCap>5GB</totalSizeCap>
  </rollingPolicy>
  <encoder>
    <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
  </encoder>
</appender>

`maxHistory` 控制保留天数,`totalSizeCap` 控制总体积上限,超过后旧文件自动删除。建议按日志类型拆分 appender:业务日志、错误日志、访问日志分别归档,错误日志单独一份方便告警系统订阅。

MDC 与请求追踪

一次请求会横跨多个方法、多个线程甚至多个服务,排障时最需要的是把同一次请求的所有日志串起来。MDC(Mapped Diagnostic Context)就是为此设计的:它是一个线程绑定的上下文 Map,往里放 traceId,日志输出模板里引用它,这条线程后续所有日志都会带上同一个 ID。

public class TraceFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        String traceId = UUID.randomUUID().toString().replace("-", "");
        MDC.put("traceId", traceId);
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.remove("traceId");
        }
    }
}

pattern 里加 `%X{traceId}` 就能把 traceId 打进每行日志。跨服务调用时通过请求头传递 traceId,接收方继续写进自己的 MDC,全链路日志就能按同一 ID 聚合。务必在 finally 里清理 MDC,否则线程池复用会串号。