日志是分布式系统的事实排查第一工具:没有日志,再好的架构也无法排障。但日志三板斧(谁能打、打多少、怎么交错)许多人没认真设计过。本文从 SLF4J 门面讲到 Logback/Log4j2 配置,再到 MDC 链路追踪与异步/结构化日志,给你一套可落地的工程方案。
一、门面 vs 实现:为什么必须 SLF4J
Java 日志生态多 VM 并立:java.util.logging、Log4j、Log4j2、Logback、SLF4J、Common-Logging。工程上用一个门面 API(SLF4J)统一调用,底层绑定任意实现——这样改实现不动业务代码。
// 永远这样写(用门面,不直接用实现类)
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
// 好处:切换日报实现只改依赖,不撞代码
log.info("create order, order={}", orderId);
| 门面 | 说明 |
|---|---|
| SLF4J | 现代最流行门面,配 logback / log4j2 |
| Common-Logging (JCL) | 老牌,自动发现实现 |
| java.util.logging | JDK 自带,功能弱 |
| 实现 | 特点 |
|---|---|
| Logback | 门面作者同人,已有余最大,性能好 |
| Log4j2 | 异步支持强,性能峰值(LMAX disruptor) |
| JUL | JDK 自带,默认全部打 info |
一句话:业务代码只依赖 SLF4J(门面),实现由 build 依赖注入 Logback/Log4j2;这样升级日志框架无需改一行业务代码,这是"依赖倒置"的典型实践。
二、日志级别与配置
2.1 级别定义与运维语义
| 级别 | 使用场景 | 是否告警 |
|---|---|---|
| TRACE | 详细调试,生产一般关闭 | 否 |
| DEBUG | 排障细节,可用于 dev/test | 否 |
| INFO | 关键业务流程、调用出入口 | 否 |
| WARN | 可恢复/可疑但不致命 | 按需 |
| ERROR | 失败/异常,必须记录上下文 | 是(alert) |
if (log.isDebugEnabled()) log.debug("detail pay={}", producePayload); // 条件判断省序列化
级别过滤:log.info 也是两次字符串拼接 "a"+"b" 也可能浪费。占位符 {} 只在级别满足时才拼接:
log.info("user {} login from {}", userId, ip); // 懒拼,优于字符串拼接
2.2 配置文件(logback.xml 片段)
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>[%d{HH:mm:ss.SSS}] [%thread] %-5level %logger{40} - %msg%n</pattern>
</encoder>
</appender>
<!-- 按大小尺寸+时间戳滚动 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
</appender>
<root level="INFO"><appender-ref ref="CONSOLE"/><appender-ref ref="FILE"/></root>
</configuration>
一句话总结:级别是成本的规则——INFO 打关键,DEBUG 打细节且要关,WARN/ERROR 要告警;用占位符 + isDebugEnabled 双重防浪费。
三、MDC:上下文贯穿链路
3.1 为什么需要
一个请求会穿过多线程/多服务,怎么一次让它走完都带同一个 traceId?用综合 MDC(Mapped Diagnostic Context)——本质是一个线程绑定的 Map。
// 入口设置(过滤器/AOP/网关)
MDC.put("traceId", TraceUtils.gen());
try {
// 整个请求链路都带上 traceId
log.info("handling request");
} finally {
MDC.clear();
}
// pattern 中引用 MDC key
<pattern>[%d][%p][%X{traceId}] %logger - %msg%n</pattern>
// 每次 log 都自动带上 traceId,日志天然可串
异步/跨线程要手动传:线程池内 MDC 不复现,需在线程 Executor 里再放一次,或用 TransmittableThreadLocal(TTL)让它随线程透传。
3.2 跨服务链路
- 自建:response 传递时用
X-Trace-Idheader。 - 协议:接入 OpenTelemetry,用 traceId/spanId 统一全链路(Web 端、DB、MQ)。
一句话总结: MDC 让一个请求的所有日志"自带 traceId",是分布式排障的基础设施;跨线程要手动透传(TTL),跨服务要传 header,最终目标是"一条 traceId 串起一次请求的完整人生"。
四、异步日志:别让日志拖慢主流程
同步写文件会阻塞业务线程,高并发下日志 I/O 可能成为瓶颈。解决:异步日志(队列缓冲 + 独立线程刷盘)。
<!-- logback 异步 Appender -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>512</queueSize> <!-- 队列容量 -->
<discardingThreshold>0</discardingThreshold> <!-- 永不丢弃 -->
<appender-ref ref="FILE"/>
</appender>
// Log4j2 更彻底:全部异步(LMAX Disruptor 无锁队列)
<!-- log4j2.xml 关键配置 -->
<Configuration>
<Appenders>
<Async name="AsyncFile">
<appender-ref ref="RollingFile"/>
</Async>
</Appenders>
</Configuration>
| 方案 | 说明 |
|---|---|
| 同步 + RollingFile | 简单,IO 会成为瓶颈 |
| Logback AsyncAppender | 队列缓冲,默认丢日志风险低 |
| Log4j2 Async | Disruptor 无锁环形缓冲,性能最高 |
异步日志的代价:进程崩溃/机器宕机时,队列里未刷盘的部分可能丢失——所以关键审计日志(交易、安全)建议同步,普通业务日志用异步。
一句话总结: 高并发下让日志走独立异步队列,把 I/O 从业务线程剥离;但"会丢"的代价意味着审计类日志保留同步,取舍标准是"丢一条能不能接受"。
五、结构化日志:从"人能读"到"机器能查"
传统纯文本日志难以被机器解析。结构化日志把每条日志变成 JSON 字段,配合日志平台(ELK/Loki/OpenTelemetry Collector)实现字段级检索、告警、聚合。
// 用 SLF4J + LogstashEncoder 输出 JSON
logger.info("order created, {}, {}, {}", orderId, userId, amount);
// 输出为:
{"@timestamp":"2026-09-26T15:00:00","level":"INFO",
"logger":"com.demo.OrderService","message":"order created",
"mdc":{"traceId":"a1b2c3"},"orderId":"10086","userId":"42","amount":"99.9"}
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"app":"order-service","env":"prod"}</customFields>
</encoder>
统一字段规范(约定优于配置):
app、env、level、traceId、spanId、service、host、timestamp、message、error
· 敏感字段不入日志(手机号、token、密钥要脱敏/不打印)
· error 结构:errorClass + errorMessage + stackTrace
一句话总结: 结构化日志让"每条日志都是可查询的 JSON 文档",日志平台才能做字段级聚合与告警;同时必须约定统一字段并做敏感数据脱敏。
六、日志轮转、采集与治理
6.1 轮转策略
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
三个约束:单文件大小(100MB)、保留天数(30 天)、总容量上限(5GB)。防止日志把磁盘打爆。
6.2 采集管线
应用(结构化 JSON)
→ Agent(Filebeat / Fluent Bit / OpenTelemetry Collector)
→ 存储(Elasticsearch / Loki / ClickHouse)
→ 查询告警(Kibana / Grafana / Alertmanager)
要点:
1. Agent 独立进程,与应用故障隔离
2. 采集端要处理"追尾/回传/断点续传"
3. 日志平台要设索引生命周期(ILM),防止无限增长
6.3 日志治理规范
| 规范 | 说明 |
|---|---|
| 禁止在循环里打 DEBUG | 大量无效日志刷屏 |
| ERROR 必须带堆栈和上下文 | 否则没法定位 |
| 敏感字段脱敏 | 手机号/身份证/密钥打码 |
| 统一时间格式 | ISO8601,UTC 或带时区 |
| 统一 traceId 命名 | 全链路一致,便于串联 |
| 关键业务有 INFO | 交易/支付必须可追踪 |
一句话总结: 日志治理 = 轮转限大小 + Agent 采集 + 平台查询,外加一套"打什么、怎么打、别打敏感"的规范;否则日志会从"资产"变成"噪声"和"合规风险"。
七、踩坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
| 多个日志实现冲突 | NoClassDefFoundError 或"没日志" | 只用 SLF4J + 一个实现,排除其余 |
| 循环里打 INFO/DEBUG | 日志刷屏、磁盘告警 | 降级、条件日志 |
| 打异常不带堆栈 | 只看到一句话 | 传异常对象 e |
| 打印敏感字段 | 泄露客户数据 | 脱敏过滤器 + 代码审查 |
| 异步日志丢消息 | 宕机后关键日志缺失 | 审计类用同步 |
| 无 traceId | 排障找不到关联日志 | MDC + 网关注入 |
八、总结
| 环节 | 要点 |
|---|---|
| 门面 | 业务只依赖 SLF4J,实现可替换 |
| 级别 | INFO 关键、DEBUG 关闭、ERROR 告警 |
| MDC | traceId 贯穿全链路,跨线程透传 |
| 异步 | 高并发用异步队列,审计用同步 |
| 结构化 | JSON 输出,统一字段 + 脱敏 |
| 治理 | 轮转 + 采集 + 查询 + 规范 |
一句话记住:日志的使命是"让任何一个线上问题能在 5 分钟内被定位"——门面统一、级别分级、traceId 串链、结构化输出、异步防抖、轮转防爆,这六招齐了,日志才真正成为可观测性的地基。
延伸阅读
- Spring Boot 深度解析:自动配置与 Starter 原理 — Spring Boot 日志自动配置与生产配置
- Java 性能优化:从代码到 JVM 的全链路调优 — 异步 IO 与性能平衡
- Java 测试策略:JUnit 5 + Mockito + Testcontainers — 测试中如何隔离与校验日志
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。