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结构化日志治理。
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,否则线程池复用会串号。
结构化日志与 JSON 输出
人读的文本日志机器难解析,日志量大了以后告警、检索、聚合全靠正则,脆弱且慢。结构化日志把每条记录输出成 JSON,字段固定、语义明确,接入 ELK、Loki 或云日志服务后检索体验完全不同。
log.info("order created", Map.of(
"orderId", orderId,
"amount", amount,
"channel", channel
));
配合 Logstash Logback Encoder 的 `LogstashEncoder`,日志会自动变成包含时间戳、级别、类名、消息与自定义字段的 JSON 行。字段命名要提前约定:统一小写下划线、时间统一时区、金额单位明确。结构化之后,「查一个订单的完整生命周期」就只是一条查询语句的事。
日志治理最佳实践
- 敏感信息不进日志:手机号、身份证、密码、令牌一律脱敏或只记尾号。
- 高频路径用 debug 或 trace,info 只留业务关键事件,避免日志洪峰拖垮磁盘。
- 异常日志要记堆栈,`log.error("消息", exception)` 而不是只传字符串。
- 线程池任务里同步传递 MDC 上下文,避免子线程丢失 traceId。
- 统一配置中心管理日志级别,线上动态调级不重启应用。
- 告警只盯 error 与关键业务指标,设置采样与去重,防止告警疲劳。
日志治理的终点是「可观测」:任何一次异常,都能在一分钟内定位到根因。