SpringBoot(15):日志框架选型与配置——Logback 从入门到生产级

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

一、Java 日志体系:为什么这么乱

刚入行的同学看到一堆日志框架的名字就懵:java.util.logginglog4jlog4j2logbackslf4jcommons-loggingJCL......它们到底是什么关系?

先看一张全景图:

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,原因有三:

  1. 同一个作者:Ceki 同时写了 SLF4J 和 Logback,Logback 是 SLF4J 的"原生实现",性能最好
  2. 比 Log4j 快:原生支持 SLF4J,没有适配层;锁粒度更细,多线程下吞吐高
  3. 配置更灵活 :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 滚动归档流程

maxHistorytotalSizeCap 配合使用:

  • 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();
    }
}

日志输出时,PatternLayoutMDCConverter 会调 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);  // 有上下文 + 堆栈

规范要点:

  1. 占位符 {}:不用字符串拼接,性能差
  2. 业务标识优先orderIduserIdtraceId 必须打
  3. 打参数不打整个对象 :除非对象的 toString() 经过脱敏
  4. ERROR 必须带异常对象log.error("xxx", e),最后一个参数是 Throwable
  5. 不打敏感信息:密码、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 避免同步锁竞争

多线程下,FileAppendersynchronized(lock) 是性能瓶颈。两个优化方向:

  1. 异步 Appender:业务线程只入队,不直接争锁
  2. 分片 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 和带 %ifileNamePattern

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,把生产环境的应用健康度可视化做起来。

相关推荐
庄周de蝴蝶1 小时前
两次考试,总算压线通过系统分析师
后端·架构
孫治AllenSun2 小时前
【MDC】使用 MDC 封装一个 LogMdcUtil 工具类
java·spring boot·后端
神奇小汤圆2 小时前
Java 26正式发布!这3个新特性,让代码量直接减半
后端
再吃一根胡萝卜2 小时前
一亿数据下,如何实时统计“业务员业绩 TOP 10”?别再 GROUP BY 了
后端
Oneslide2 小时前
systemd-journald 使用与配置指南
后端
神奇小汤圆2 小时前
30分钟实现自己的AI聊天机器人(SpringBoot接入DeepSeek API)
后端
再吃一根胡萝卜2 小时前
从最左前缀到地址索引:我对 MySQL 索引的一次完整复盘
后端
郡杰2 小时前
Spring Boot 请求流转与 RESTful
后端
再吃一根胡萝卜3 小时前
Redis 都有哪些用处?都会用在什么地方?
后端