日志框架
Java 中级 · 工程化
日志是线上排查的第一现场。Java 日志生态出了名的乱:JUL、JCL、Log4j、Log4j2、Logback、SLF4J……新手常常「重复打印」或「冲突报错」。本篇讲清 SLF4J 门面 + 实现分离的设计哲学、Logback 配置、以及规范地打日志。
一、日志的门面与实现
1.1 为什么要有「门面」
如果代码直接依赖 Log4j,将来换成 Logback 就要改遍全项目。SLF4J(Simple Logging Facade for Java) 是门面(接口),业务代码只依赖它;具体实现(Logback/Log4j2)可在部署时替换。
// 业务代码只 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 / JCL | Logback / Log4j2 / JUL / Log4j |
Spring Boot 默认组合
Spring Boot 的 spring-boot-starter-logging 默认是 SLF4J + Logback。所以引依赖时别再引 Log4j,否则类路径多套实现会冲突/重复打印。
二、SLF4J 的核心优势:占位符
// ❌ 字符串拼接:无论日志级别是否输出,都会拼字符串(浪费)
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 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 改为异步:
<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 自动打进每条日志,便于链路追踪。
// 入口(过滤器/拦截器)放 traceId
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put("traceId", traceId);
try {
chain.doFilter(req, resp);
} finally {
MDC.clear(); // 必须清理,否则线程池复用会串
}<!-- pattern 里加 %X{traceId} -->
<pattern>%d{HH:mm:ss} [%thread] [%X{traceId}] %-5level %logger{20} - %msg%n</pattern>输出:
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 别直接打,截断或只打关键字段。
- 日志文件必须滚动 + 限制大小,否则撑爆磁盘。
禁止事项
// ❌ 吞异常:比不打日志还糟
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)); // 含手机号/身份证📚 官方文档与参考资料
✏️ 练习与自测
- SLF4J 是门面还是实现?为什么业务代码只依赖它?Spring Boot 默认用什么实现?
log.debug("a=" + x)和log.debug("a={}", x)有什么区别?为什么后者更优?- MDC 的底层是什么?为什么用完之后必须
clear()? - 列举至少 4 种错误的打日志方式,并说明危害。
本节小结
日志体系的核心是 SLF4J 门面 + 实现分离,业务只依赖门面、实现可替换(Spring Boot 默认 Logback)。用 {} 占位符而非拼接,异常作为最后一个参数打印堆栈。级别别用错,异步日志提性能,MDC 打 traceId 串链路(用完必 clear)。规范打日志,是线上可运维的基础。