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

0 阅读11分钟

img

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

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

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

先看一张全景图:

java-logging-overview.svg

Java 日志体系分两层:日志门面(API)和日志实现(框架)。

类型角色代表类比
日志门面提供统一 APISLF4J、JCL(commons-logging)JDBC API
日志实现真正打日志Logback、Log4j、Log4j2、JULMySQL Driver

为什么要分两层?因为日志实现各家的 API 不一样,今天用 Log4j,明天换 Logback,业务代码全得改。门面层提供统一接口,业务代码只依赖门面,实现可以自由切换——和 JDBC 一个道理。

1.1 历史脉络

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 的源码:门面层做了什么

先看业务代码怎么写日志:

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() 的实现原理

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 里就有这个类:

// 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 的占位符:避免字符串拼接

老代码里常见这种写法:

log.debug("user info: " + user.toString());  // 错误

即使日志级别是 INFO(DEBUG 不输出),user.toString() 也会执行,浪费性能。SLF4J 的占位符:

log.debug("user info: {}", user);  // 正确

只有 DEBUG 开启时,才会真正调用 user.toString() 做字符串替换。看 SLF4J 源码:

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格式化日志内容数据转换器

三者关系:

logback-architecture.svg

一次日志的完整流转:业务代码调 log.info() → Logger 判断级别 → 传给 Appender → Encoder 编码 → 输出到目的地。

3.2 Logger 树形结构

Logback 里的 Logger 是树形结构,根 Logger 叫 ROOT。包名就是 Logger 名:

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 源码核心方法

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(滚动文件输出):

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 源码:日志长什么样

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 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换行
%highlightANSI 颜色高亮控制台用
%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 滚动归档流程

logback-rolling-policy.svg

maxHistorytotalSizeCap 配合使用:

  • maxHistory="30":保留最近 30 天的归档
  • totalSizeCap="10GB":所有归档总和不超过 10GB,超了删除最老的

五、异步日志:性能优化利器

同步日志意味着每条日志都要等磁盘 IO 完成才返回,QPS 高的时候严重拖慢接口。异步日志把日志事件丢到队列,后台线程批量刷盘。

5.1 AsyncAppender 配置

<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 异步日志的源码

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
OOMqueueSize 太大控制在 1024~4096
顺序乱多线程并发单线程 worker,顺序保持
看不到堆栈includeCallerData=false要堆栈就开,性能损耗 3-5 倍

异步日志 vs 同步日志,吞吐量差 3-5 倍。生产环境强烈推荐。

5.4 异步日志流程

logback-async-flow.sv

六、MDC:链路追踪的基础

线上问题排查,最难的是"这条日志是哪个请求打的"。MDC(Mapped Diagnostic Context)解决这个问题。

6.1 MDC 用法

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} 就能输出:

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

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,提交任务给线程池,子线程拿不到:

// 父线程
MDC.put("traceId", "abc123");
executor.submit(() -> {
    log.info("子线程日志");  // traceId 丢失!
});

解决方法:任务提交前把 MDC 复制进去。

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 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 内容规范

// 反例
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 日志脱敏

生产环境日志可能被很多人看到,密码、手机号必须脱敏:

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 配置

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

logging:
  level:
    com.example.mapper: DEBUG   # mapper 接口包

或者开启 MyBatis 自带日志:

mybatis:
  configuration:
    log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl

9.3 打印第三方 HTTP 调用

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

<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 异步日志的队列调优

// 监控异步队列积压
@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 级别判断的开销

// 性能好:先判断,再拼接(SLF4J 占位符已经做了)
if (log.isDebugEnabled()) {
    log.debug("expensive operation: {}", expensiveOperation());
}

// 性能差:不管级别都执行 expensiveOperation
log.debug("expensive operation: {}", expensiveOperation());

SLF4J 1.x 里,占位符的字符串替换是延迟的,但参数本身是立即求值的。所以如果参数本身有昂贵计算,外面还是要包 isDebugEnabled()。SLF4J 2.0+ 用 Fluent API 解决了这个问题:

// 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 怎么选

维度LogbackLog4j2
默认集成SpringBoot 默认需手动替换依赖
异步性能AsyncAppender(队列)AsyncLogger(LMAX Disruptor,性能更高)
配置方式XML / GroovyXML / 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 动态改

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