
你排查过线上问题吗?某个接口偶发性报错,没有日志、堆栈也看不懂,业务方在群里催你"赶紧定位"。你登服务器,发现日志全是
System.out.println打的,没时间戳、没级别、没类名、没 traceId,几百条日志糊成一团。你想加日志,又怕打太多拖慢性能;想加链路追踪 ID,发现项目里连 SLF4J 都没引入。日志这个东西,平时没人管,出了事才知道它是救命的稻草。这篇把 Java 日志体系、SLF4J 与 Logback 的关系、Logback 的源码结构、生产级配置、MDC 链路追踪、异步日志、滚动归档,一路讲到底,看完直接能上生产。
一、Java 日志体系:为什么这么乱
刚入行的同学看到一堆日志框架的名字就懵:java.util.logging、log4j、log4j2、logback、slf4j、commons-logging、JCL......它们到底是什么关系?
先看一张全景图:

Java 日志体系分两层:日志门面 (API)和日志实现(框架)。
| 类型 | 角色 | 代表 | 类比 |
|---|---|---|---|
| 日志门面 | 提供统一 API | SLF4J、JCL(commons-logging) | JDBC API |
| 日志实现 | 真正打日志 | Logback、Log4j、Log4j2、JUL | MySQL Driver |
为什么要分两层?因为日志实现各家的 API 不一样,今天用 Log4j,明天换 Logback,业务代码全得改。门面层提供统一接口,业务代码只依赖门面,实现可以自由切换------和 JDBC 一个道理。
1.1 历史脉络
yaml
1999 年 log4j 诞生(Ceki Gülcü)
2002 年 SUN 在 JDK 1.4 里内置 java.util.logging(JUL)
2002 年 Apache 出 commons-logging(JCL)做统一门面
2004 年 Ceki 因为 JCL 设计问题,自己出 SLF4J 做门面
2010 年 Ceki 出 Logback 作为 SLF4J 的原生实现
2014 年 Apache 出 Log4j 2 重写架构
2021 年 Log4j 2 爆出 Log4Shell 漏洞(CVE-2021-44228)
1.2 SpringBoot 为什么选 Logback
SpringBoot 默认用 Logback,原因有三:
- 同一个作者:Ceki 同时写了 SLF4J 和 Logback,Logback 是 SLF4J 的"原生实现",性能最好
- 比 Log4j 快:原生支持 SLF4J,没有适配层;锁粒度更细,多线程下吞吐高
- 配置更灵活 :Groovy 配置、条件化处理(
<if>)、内嵌滚动策略
SpringBoot 自动引入 spring-boot-starter-logging,里面已经有 slf4j-api + logback-classic + log4j-to-slf4j(把 Log4j 2 的 API 也桥到 SLF4J),开箱即用。
二、SLF4J 的源码:门面层做了什么
先看业务代码怎么写日志:
java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
public void createOrder(OrderRequest req) {
log.info("create order, userId={}, productId={}",
req.getUserId(), req.getProductId());
}
}
业务代码只依赖 org.slf4j.Logger,不知道底层是 Logback 还是 Log4j2。SLF4J 是怎么找到实现的?看源码。
2.1 LoggerFactory.getLogger() 的实现原理
java
public final class LoggerFactory {
public static Logger getLogger(String name) {
ILoggerFactory factory = getProvider().getLoggerFactory();
return factory.getLogger(name);
}
private static LoggerProvider getProvider() {
// 通过类加载查找 org/slf4j/impl/StaticLoggerBinder.class
// 这个类由具体的日志实现(Logback、Log4j2)提供
}
}
SLF4J 的核心设计是 ServiceLoader 风格 :在 classpath 下查找 org.slf4j.impl.StaticLoggerBinder 这个类。哪个日志实现提供了这个类,SLF4J 就用它。Logback 的 logback-classic.jar 里就有这个类:
java
// logback-classic 源码
public class StaticLoggerBinder implements LoggerAdapter {
private static final StaticLoggerBinder SINGLETON = new StaticLoggerBinder();
public static StaticLoggerBinder getSingleton() {
return SINGLETON;
}
private LoggerContext defaultLoggerContext = new LoggerContext();
public ILogger getLogger(String name) {
return defaultLoggerContext.getLogger(name);
}
}
这就是为什么 SLF4J "零依赖"就能找到实现------靠的是 classpath 扫描。
2.2 SLF4J 的占位符:避免字符串拼接
老代码里常见这种写法:
java
log.debug("user info: " + user.toString()); // 错误
即使日志级别是 INFO(DEBUG 不输出),user.toString() 也会执行,浪费性能。SLF4J 的占位符:
java
log.debug("user info: {}", user); // 正确
只有 DEBUG 开启时,才会真正调用 user.toString() 做字符串替换。看 SLF4J 源码:
java
public void debug(String format, Object arg) {
if (isDebugEnabled()) { // 1. 先判断级别
formattingStrategy.format(format, arg); // 2. 才做占位符替换
}
}
但要注意:如果占位符里要调用的方法有副作用(比如改变了对象状态),即便日志不输出,副作用还是会发生,因为对象引用是被立即传递的。
三、Logback 架构与源码
Logback 分三个模块:logback-core(核心)、logback-classic(SLF4J 实现)、logback-access(Servlet 容器访问日志)。我们主要看 classic。
3.1 三大核心组件
| 组件 | 作用 | 类比 |
|---|---|---|
| Logger | 记录器,业务代码调它打日志 | 数据库连接 |
| Appender | 输出目的地(文件、控制台、Kafka) | 数据库表 |
| Layout/Encoder | 格式化日志内容 | 数据转换器 |
三者关系:

一次日志的完整流转:业务代码调 log.info() → Logger 判断级别 → 传给 Appender → Encoder 编码 → 输出到目的地。
3.2 Logger 树形结构
Logback 里的 Logger 是树形结构,根 Logger 叫 ROOT。包名就是 Logger 名:
markdown
ROOT
├── com
│ └── example
│ ├── OrderService (com.example.OrderService)
│ └── UserService (com.example.UserService)
└── org
└── springframework
└── web (org.springframework.web)
子 Logger 默认继承父 Logger 的级别。配置里写 <logger name="com.example" level="DEBUG"/>,那 com.example.OrderService 也是 DEBUG。这是 Logback 配置能"按包控制级别"的原理。
3.3 Logger 源码核心方法
java
public final class Logger implements org.slf4j.Logger {
private String name;
private volatile int level; // 级别(int 值,便于比较)
private AppenderAttachableImpl<ILoggingEvent> aai; // Appender 列表
private Logger parent; // 父 Logger
public void info(String msg) {
filterAndLog_0_Or3Plus(FQCN, null, Level.INFO, msg, null, null);
}
private void filterAndLog(String localFQCN, Marker marker, Level level,
String msg, Object[] params, Throwable t) {
// 1. 构造 ILoggingEvent
ILoggingEvent event = new LoggingEvent(localFQCN, this, level, msg, t, params);
// 2. 走 TurboFilter(全局过滤器,性能最高)
if (turboFilterChainNotAdvised) {
// 通过的才继续
}
// 3. appendLoopOnAppenders:遍历所有 Appender 输出
aai.appendLoopOnAppenders(event);
}
}
appendLoopOnAppenders 是关键:把日志事件丢给所有绑定的 Appender。
3.4 Appender 源码:输出到哪里
最常见的 RollingFileAppender(滚动文件输出):
java
public class RollingFileAppender extends OutputStreamAppender<ILoggingEvent> {
private RollingPolicy rollingPolicy; // 滚动策略(按时间、按大小)
private TriggeringPolicy triggeringPolicy; // 触发策略(什么时候滚)
@Override
public void subAppend(ILoggingEvent event) {
synchronized (lock) {
// 1. 触发策略判断是否要滚动
if (triggeringPolicy.isTriggeringEvent(currentFile, event)) {
rollingPolicy.rollover(); // 滚动:rename + 新建文件
}
// 2. 调用父类真正写入
super.subAppend(event);
}
}
}
注意 synchronized (lock):所有 Appender 写入都是加锁的,多线程并发写日志,这里就是性能瓶颈。
3.5 Encoder 源码:日志长什么样
java
public class PatternLayoutEncoder extends PatternLayoutEncoderBase<ILoggingEvent> {
@Override
public byte[] encode(ILoggingEvent event) {
// 1. PatternLayout 把 event 按 pattern 格式化成字符串
String txt = patternLayout.doLayout(event);
// 2. 转成字节数组
return txt.getBytes(charset);
}
}
pattern 里的 %d、%thread、%logger、%msg 都是 PatternLayout 的转换符,每个转换符对应一个 Converter 类。
四、Logback 配置详解
SpringBoot 应用默认加载 logback-spring.xml(带 -spring 后缀,能用 SpringBoot 的 profile 特性)。
4.1 基础配置(控制台 + 文件)
xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 引入 SpringBoot 默认配置 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- 属性定义 -->
<property name="LOG_PATH" value="logs"/>
<property name="APP_NAME" value="order-service"/>
<!-- 控制台 Appender -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 滚动文件 Appender -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${APP_NAME}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- Root Logger -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
<!-- 包级别单独控制 -->
<logger name="com.example" level="DEBUG"/>
<logger name="org.springframework.web" level="INFO"/>
<logger name="org.hibernate.SQL" level="DEBUG"/>
</configuration>
4.2 Pattern 转换符速查
| 转换符 | 含义 | 示例 |
|---|---|---|
%d{pattern} |
时间 | 2026-06-15 10:30:00.123 |
%level |
级别 | INFO |
%thread |
线程名 | http-nio-8080-exec-1 |
%logger{length} |
Logger 名(可截断) | c.e.OrderService |
%msg |
日志内容 | create order... |
%n |
换行 | |
%highlight |
ANSI 颜色高亮 | 控制台用 |
%cyan |
青色 | 控制台用 |
%M |
方法名 | 性能差,生产不用 |
%L |
行号 | 性能差,生产不用 |
%X{key} |
MDC 中的值 | %X{traceId} |
%-5level |
左对齐 5 字符 | INFO |
注意 %M(方法名)和 %L(行号)走的是堆栈反射,性能开销大,生产环境不要用。
4.3 滚动策略对比
| 策略类 | 触发条件 | 适用场景 |
|---|---|---|
TimeBasedRollingPolicy |
按时间 | 每天滚动 |
SizeAndTimeBasedRollingPolicy |
时间 + 大小 | 每天滚动,单文件超 100MB 也滚 |
FixedWindowRollingPolicy |
固定窗口大小 | 大小超过就滚,保留固定数量 |
生产环境推荐 SizeAndTimeBasedRollingPolicy:每天滚 + 单文件大小限制,避免单个文件太大不好排查。
fileNamePattern 里的几个细节:
%d{yyyy-MM-dd}按天滚动%d{yyyy-MM-dd-HH}按小时滚动%i同一时间段内第几个文件(从 0 开始).gz后缀自动 gzip 压缩,节省 80% 空间
4.4 滚动归档流程

maxHistory 和 totalSizeCap 配合使用:
maxHistory="30":保留最近 30 天的归档totalSizeCap="10GB":所有归档总和不超过 10GB,超了删除最老的
五、异步日志:性能优化利器
同步日志意味着每条日志都要等磁盘 IO 完成才返回,QPS 高的时候严重拖慢接口。异步日志把日志事件丢到队列,后台线程批量刷盘。
5.1 AsyncAppender 配置
xml
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<!-- 队列大小,默认 256 -->
<queueSize>1024</queueSize>
<!-- 队列剩余 20% 时丢弃 TRACE/DEBUG/INFO -->
<discardingThreshold>20</discardingThreshold>
<!-- 是否包含调用方信息(类名/方法名/行号),性能差,默认 false -->
<includeCallerData>false</includeCallerData>
<!-- 真正的 Appender -->
<appender-ref ref="FILE"/>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_FILE"/>
</root>
5.2 异步日志的源码
java
public class AsyncAppender extends AsyncAppenderBase<ILoggingEvent> {
@Override
protected void append(ILoggingEvent eventObject) {
// 1. 队列没满,丢进队列
if (queue.remainingCapacity() > discardingThreshold) {
put(eventObject);
} else {
// 2. 队列满了,根据策略处理
if (isNeverBlock()) {
return; // 直接丢
} else {
put(eventObject); // 阻塞
}
}
}
// 工作线程:从队列取,调用真实 Appender
class Worker extends Thread {
public void run() {
while (!isInterrupted()) {
ILoggingEvent event = queue.take();
aai.appendLoopOnAppenders(event);
}
}
}
}
5.3 异步日志的坑
| 坑 | 原因 | 解决 |
|---|---|---|
| 应用崩溃丢日志 | 队列里的日志没刷盘 | 关闭钩子、neverBlock=false |
| OOM | queueSize 太大 | 控制在 1024~4096 |
| 顺序乱 | 多线程并发 | 单线程 worker,顺序保持 |
| 看不到堆栈 | includeCallerData=false |
要堆栈就开,性能损耗 3-5 倍 |
异步日志 vs 同步日志,吞吐量差 3-5 倍。生产环境强烈推荐。
5.4 异步日志流程

六、MDC:链路追踪的基础
线上问题排查,最难的是"这条日志是哪个请求打的"。MDC(Mapped Diagnostic Context)解决这个问题。
6.1 MDC 用法
java
import org.slf4j.MDC;
@RestController
public class OrderController {
@PostMapping("/order")
public OrderResult create(@RequestBody OrderRequest req) {
// 请求开始时塞 traceId
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put("traceId", traceId);
MDC.put("userId", String.valueOf(req.getUserId()));
try {
return orderService.create(req);
} finally {
MDC.clear(); // 请求结束清理
}
}
}
Pattern 里用 %X{traceId} 就能输出:
ini
2026-06-15 10:30:00.123 INFO [http-nio-8080-exec-1] traceId=a1b2c3d4 c.e.OrderService - create order
6.2 MDC 的源码原理
MDC 底层是 ThreadLocal<Map<String, String>>,每个线程一份独立副本。看 Logback 的 LogbackMDCAdapter:
java
public class LogbackMDCAdapter implements MDCAdapter {
final ThreadLocal<Map<String, String>> copyOnThreadLocal = new ThreadLocal<>();
final ThreadLocal<Integer> lastOperation = new ThreadLocal<>();
public void put(String key, String val) {
Map<String, String> oldMap = copyOnThreadLocal.get();
// 写时复制(CopyOnWrite)思想
Map<String, String> newMap = new HashMap<>(oldMap);
newMap.put(key, val);
copyOnThreadLocal.set(newMap);
}
public Map<String, String> getPropertyMap() {
return copyOnThreadLocal.get();
}
}
日志输出时,PatternLayout 的 MDCConverter 会调 MDCAdapter.getPropertyMap() 拿到当前线程的 MDC,再从里面取 traceId 的值。
6.3 线程池下的 MDC 传递问题
MDC 是 ThreadLocal,线程池里的线程是复用的。父线程把 traceId 放 MDC,提交任务给线程池,子线程拿不到:
java
// 父线程
MDC.put("traceId", "abc123");
executor.submit(() -> {
log.info("子线程日志"); // traceId 丢失!
});
解决方法:任务提交前把 MDC 复制进去。
java
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> context = MDC.getCopyOfContextMap();
return () -> {
if (context != null) MDC.setContextMap(context);
try {
runnable.run();
} finally {
MDC.clear();
}
};
}
}
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(new MdcTaskDecorator());
return executor;
}
CompletableFuture、@Async、Reactor 都得这样处理,否则链路追踪就断了。
七、生产级配置(完整模板)
下面这份配置经过多次生产验证,直接复制可用。
7.1 logback-spring.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
<property name="LOG_PATH" value="${LOG_PATH:-logs}"/>
<property name="APP_NAME" value="${APP_NAME:-app}"/>
<property name="LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%X{traceId:-},%X{userId:-}] %logger{40} - %msg%n"/>
<property name="LOG_PATTERN_COLOR"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%thread] [%cyan(%X{traceId:-}),%X{userId:-}] %cyan(%logger{40}) - %msg%n"/>
<!-- 控制台 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN_COLOR}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 主日志文件(异步) -->
<appender name="FILE_ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>1024</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
<appender-ref ref="FILE"/>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${APP_NAME}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>20GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 错误日志单独文件 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}-error.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${APP_NAME}-error-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>90</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 不同环境不同配置 -->
<springProfile name="dev">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<logger name="com.example" level="DEBUG"/>
</springProfile>
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="FILE_ASYNC"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<logger name="com.example" level="INFO"/>
<logger name="org.springframework" level="WARN"/>
<logger name="org.hibernate" level="WARN"/>
</springProfile>
</configuration>
7.2 关键设计点
| 配置项 | 作用 | 为什么这么配 |
|---|---|---|
scan="true" scanPeriod="60s" |
配置热更新 | 改配置不用重启 |
queueSize=1024 |
异步队列大小 | 太小容易阻塞,太大占内存 |
neverBlock=true |
队列满了直接丢 | 防止日志把业务拖死 |
discardingThreshold=0 |
不丢任何级别 | 默认会丢 DEBUG,设 0 保留全部 |
maxFileSize=200MB |
单文件大小 | 方便排查,太大文本编辑器打不开 |
maxHistory=30 |
保留 30 天 | 配合审计需求 |
totalSizeCap=20GB |
总大小封顶 | 防止磁盘撑爆 |
.gz 压缩 |
自动 gzip | 节省 80% 空间 |
| ERROR 单独文件 | 过滤 ERROR 级别 | 快速定位错误 |
%X{traceId:-} |
MDC 取值,没有时默认空 | 链路追踪 |
八、日志规范:写好日志比写代码还重要
8.1 级别使用规范
| 级别 | 什么时候用 |
|---|---|
| ERROR | 系统出错,需要人工介入(数据库连不上、第三方接口挂了) |
| WARN | 异常但可恢复(重试成功、降级处理) |
| INFO | 关键业务节点(下单成功、用户注册、支付完成) |
| DEBUG | 调试用(参数值、中间状态),生产关闭 |
| TRACE | 极细粒度(每个循环、每行代码),几乎不用 |
常见误用:
- 把异常都打成 ERROR:业务异常(参数校验失败)应该是 WARN 或 INFO
- 用 ERROR 打用户操作失败:用户密码错打 INFO 就够了
- 方法进来出去都打 INFO:日志爆炸,关键信息淹没
8.2 内容规范
java
// 反例
log.info("操作成功"); // 没有上下文
log.info("user: " + user); // 字符串拼接 + 可能泄漏敏感信息
log.error("出错了", e); // 没有业务上下文
log.info(JSON.toJSONString(req)); // 序列化整个请求,性能差
// 正例
log.info("create order success, orderId={}, userId={}, amount={}",
orderId, userId, amount); // 有业务标识
log.error("query user failed, userId={}", userId, e); // 有上下文 + 堆栈
规范要点:
- 占位符
{}:不用字符串拼接,性能差 - 业务标识优先 :
orderId、userId、traceId必须打 - 打参数不打整个对象 :除非对象的
toString()经过脱敏 - ERROR 必须带异常对象 :
log.error("xxx", e),最后一个参数是Throwable - 不打敏感信息:密码、token、身份证号脱敏处理
8.3 日志脱敏
生产环境日志可能被很多人看到,密码、手机号必须脱敏:
java
public class LogMasking {
public static String maskPhone(String phone) {
if (phone == null || phone.length() != 11) return phone;
return phone.substring(0, 3) + "****" + phone.substring(7);
}
public static String maskIdCard(String idCard) {
if (idCard == null || idCard.length() < 10) return idCard;
return idCard.substring(0, 4) + "********" + idCard.substring(idCard.length() - 4);
}
public static String maskBankCard(String card) {
if (card == null || card.length() < 8) return card;
return card.substring(0, 4) + "********" + card.substring(card.length() - 4);
}
}
// 使用
log.info("user register, phone={}, idCard={}",
LogMasking.maskPhone(user.getPhone()),
LogMasking.maskIdCard(user.getIdCard()));
九、SpringBoot 集成与常见场景
9.1 application.yml 配置
yaml
logging:
level:
root: INFO
com.example: DEBUG
org.springframework.web: INFO
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
file:
name: logs/app.log
path: logs/
logback:
rollingpolicy:
max-file-size: 200MB
max-history: 30
total-size-cap: 10GB
简单的项目用 yml 配置就够了,复杂的用 logback-spring.xml。
9.2 打印 MyBatis SQL
yaml
logging:
level:
com.example.mapper: DEBUG # mapper 接口包
或者开启 MyBatis 自带日志:
yaml
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
9.3 打印第三方 HTTP 调用
yaml
logging:
level:
org.apache.http: DEBUG # HttpClient
okhttp3: DEBUG # OkHttp
org.springframework.web.client.RestTemplate: DEBUG
feign: DEBUG # OpenFeign
9.4 把日志发到 ELK
生产环境日志最终要进 ELK(Elasticsearch + Logstash + Kibana)或 Loki。两种方式:
方式一:Filebeat 采集文件
Logback 写文件 → Filebeat 监听 → Logstash → Elasticsearch → Kibana
应用只管写文件,Filebeat 负责采集、解析、转发。这种方式最稳定,应用和日志系统解耦。
方式二:Logback 直接发 Logstash
xml
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>logstash:5000</destination>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"appname":"order-service"}</customFields>
</encoder>
</appender>
应用直接通过 TCP 把 JSON 格式日志发给 Logstash。简单,但 Logstash 挂了会影响应用(队列满)。生产环境推荐方式一。
十、性能调优
10.1 异步日志的队列调优
java
// 监控异步队列积压
@Scheduled(fixedRate = 30000)
public void monitorAsyncQueue() {
AsyncAppender appender = (AsyncAppender) loggerContext.getLogger(ROOT)
.getAppender("ASYNC_FILE");
int queueSize = appender.getQueueSize();
int remaining = appender.getNumberOfElementsInQueue();
double usage = (double) (queueSize - remaining) / queueSize;
log.info("async log queue usage: {}%", String.format("%.2f", usage * 100));
if (usage > 0.8) {
log.warn("async log queue almost full, consider increase queueSize or fix disk IO");
}
}
队列使用率超过 80% 就要警惕:要么调大 queueSize,要么查磁盘 IO 是不是瓶颈。
10.2 级别判断的开销
java
// 性能好:先判断,再拼接(SLF4J 占位符已经做了)
if (log.isDebugEnabled()) {
log.debug("expensive operation: {}", expensiveOperation());
}
// 性能差:不管级别都执行 expensiveOperation
log.debug("expensive operation: {}", expensiveOperation());
SLF4J 1.x 里,占位符的字符串替换是延迟的,但参数本身是立即求值的 。所以如果参数本身有昂贵计算,外面还是要包 isDebugEnabled()。SLF4J 2.0+ 用 Fluent API 解决了这个问题:
java
// SLF4J 2.0+
log.atDebug()
.addArgument(() -> expensiveOperation()) // Supplier,延迟求值
.log("expensive operation: {}");
10.3 避免同步锁竞争
多线程下,FileAppender 的 synchronized(lock) 是性能瓶颈。两个优化方向:
- 异步 Appender:业务线程只入队,不直接争锁
- 分片 Appender:按线程 ID 或业务模块分到不同文件,减少锁竞争
10.4 日志大小估算
单条日志约 200 字节
QPS 10000 的应用,每秒 2MB 日志
每天 200GB 日志
提前估算好磁盘容量,别让日志把业务磁盘撑爆。
十一、Logback vs Log4j2 怎么选
| 维度 | Logback | Log4j2 |
|---|---|---|
| 默认集成 | SpringBoot 默认 | 需手动替换依赖 |
| 异步性能 | AsyncAppender(队列) | AsyncLogger(LMAX Disruptor,性能更高) |
| 配置方式 | XML / Groovy | XML / JSON / YAML |
| 功能丰富度 | 满足绝大多数需求 | 更全(Kafka Appender、Routing 等) |
| 安全性 | 无重大漏洞 | Log4Shell 漏洞影响(1.x 不影响,2.17+ 已修复) |
| 社区活跃 | 维护中 | Apache 主推,更活跃 |
怎么选:
- 新项目,SpringBoot 默认就够:Logback + AsyncAppender,覆盖 95% 场景
- 极端性能要求:Log4j2 + AsyncLogger,吞吐量再高一倍
- 已有 Log4j2 项目:升级到 2.17+,别有心理包袱
十二、常见问题
Q1:日志中文乱码
<encoder> 里加 <charset>UTF-8</charset>,启动参数加 -Dfile.encoding=UTF-8。
Q2:日志不输出到文件
检查 <file> 路径是否有写权限,${LOG_PATH} 是否解析正确。SpringBoot 用 ${LOG_PATH} 必须在 application.yml 里配置 logging.file.path。
Q3:日志配置不生效
logback.xml 是 Java 标准名,会被自动加载,但不能用 SpringBoot 的 <springProfile> 标签 。SpringBoot 项目要用 logback-spring.xml。
Q4:异步日志丢日志
neverBlock=true 时队列满会丢。要么调大 queueSize,要么把 neverBlock 设为 false(业务线程会阻塞,但日志不丢)。
Q5:日志文件越来越大
<maxFileSize> 没配,或 RollingPolicy 配错了。SizeAndTimeBasedRollingPolicy 必须配 maxFileSize 和带 %i 的 fileNamePattern。
Q6:日志里看不到 MDC
%X{key} 写错,或 MDC 在子线程里(线程池问题)。%X{key:-default} 表示没值时用默认值。
Q7:动态调整日志级别不重启
方案一:SpringBoot Actuator 的 /actuator/loggers 端点,POST 请求改级别 方案二:Logback 的 scan="true",改 XML 文件 60 秒生效 方案三:接入 Apollo/Nacos,结合 LoggingSystem 动态改
bash
# Actuator 改级别
curl -X POST http://localhost:8080/actuator/loggers/com.example \
-H "Content-Type: application/json" \
-d '{"configuredLevel":"DEBUG"}'
十三、总结
| 知识点 | 要点 |
|---|---|
| 日志体系 | 门面(SLF4J)+ 实现(Logback)两层架构 |
| SLF4J 原理 | classpath 扫描 StaticLoggerBinder 找实现 |
| Logback 三组件 | Logger(树形)+ Appender(输出)+ Encoder(格式化) |
| 滚动策略 | SizeAndTimeBasedRollingPolicy + 时间 + 大小 |
| 异步日志 | AsyncAppender + 队列 + worker 线程 |
| MDC 链路追踪 | ThreadLocal 实现,线程池要手动传递 |
| 生产配置 | 异步 + 滚动 + 错误单独文件 + profile 区分 |
| 日志规范 | 占位符、业务标识、级别合理、脱敏 |
| 性能 | 异步、避免昂贵参数、监控队列使用率 |
| 动态级别 | Actuator + 热加载 + 配置中心 |
日志框架说到底就一句话:业务代码只管调门面 SLF4J,底层实现 Logback 负责把日志安全、高效、可追溯地落盘。配置到位,问题排查就事半功倍;配置不到位,线上出事两眼一抹黑。日志不是装饰品,是生产系统的救命工具。
下一篇咱们聊聊 SpringBoot 系统监控------Actuator + Prometheus + Grafana,把生产环境的应用健康度可视化做起来。