Spring Boot 日志最佳实践:接入、分级、异常记录与滚动拆分

20 阅读20分钟

日志不是“程序运行时打印出来的文字”,而是定位故障、观察系统状态和审计业务行为的重要数据。一个合理的日志体系至少需要解决以下问题:

  • 项目如何接入并统一记录日志?
  • DEBUG、INFO、WARN、ERROR 应该如何选择?
  • 系统异常和业务错误应该如何记录?
  • 如何记录 HTTP 请求接入日志?
  • 如何按业务、日期和文件大小拆分日志?
  • 如何保留当日日志,并自动归档历史日志?
  • 如何避免日志泄露密码、令牌等敏感信息?

本文以 Spring Boot 默认使用的 SLF4J + Logback 为例,给出一套适用于生产环境的实践方案。


Spring Boot 如何接入日志

Spring Boot Web 项目通常已经通过 spring-boot-starter 或 spring-boot-starter-web 间接引入日志组件,因此一般不需要再手动添加 Logback。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Spring Boot 默认的日志组合是:

  • SLF4J:日志门面,业务代码面向它编程。
  • Logback:默认日志实现,负责日志输出、文件拆分和滚动归档。

代码中不要直接依赖 Logback 的具体类,而应该使用 SLF4J:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private static final Logger log =
            LoggerFactory.getLogger(OrderService.class);

    public void createOrder(Long userId) {
        log.info("开始创建订单, userId={}", userId);
    }
}

如果项目使用 Lombok,也可以通过 @Slf4j 简化代码:

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class OrderService {

    public void createOrder(Long userId) {
        log.info("开始创建订单, userId={}", userId);
    }
}

建议业务代码始终使用参数占位符:

log.info("订单创建成功, orderId={}, userId={}", orderId, userId);

不要使用字符串拼接:

// 不推荐
log.info("订单创建成功, orderId=" + orderId + ", userId=" + userId);

占位符可以避免不必要的字符串构造,并能保持日志格式统一。


日志级别应该如何选择

常见日志级别从低到高依次为:

TRACE < DEBUG < INFO < WARN < ERROR

生产环境通常以 INFO 为默认级别,只针对必要的模块临时开启 DEBUG。

TRACE

TRACE 用于非常细粒度的执行过程,例如循环内部状态、协议字段解析过程等。

它产生的日志量很大,在普通业务系统中很少使用,也不应该在生产环境长期打开。

log.trace("解析协议字段, index={}, value={}", index, value);

DEBUG

DEBUG 用于开发和问题排查,记录对理解程序执行过程有帮助的内部信息。

log.debug("订单优惠计算完成, orderId={}, originalAmount={}, discount={}",
        orderId, originalAmount, discount);

适合记录:

  • 方法的重要输入。
  • 条件分支结果。
  • 中间计算结果。
  • 外部接口响应摘要。
  • 缓存是否命中。

不要把每一步代码执行都打印成 DEBUG,否则真正有价值的信息会被大量噪声淹没。

对于计算成本较高的日志参数,可以先判断级别:

if (log.isDebugEnabled()) {
    log.debug("订单完整快照: {}", buildOrderSnapshot(order));
}

普通占位符参数一般不需要额外判断。

INFO

INFO 用于记录正常且具有业务或运维价值的重要事件。

log.info("订单创建成功, orderId={}, userId={}, amount={}",
        orderId, userId, amount);

适合记录:

  • 应用启动和关闭。
  • 重要配置加载结果。
  • 业务流程开始或完成。
  • 订单创建、支付成功、退款完成等关键状态变化。
  • 定时任务的开始、结束和处理统计。
  • 外部服务调用结果摘要。

INFO 不应该记录每一次普通方法调用,否则生产日志会迅速膨胀。

WARN

WARN 表示系统出现了异常情况,但当前请求仍然可以继续,或者系统已经完成降级、重试和兜底。

log.warn("查询商品缓存失败,降级查询数据库, productId={}", productId);

适合记录:

  • 参数不符合预期,但可以使用默认值。
  • 第三方服务调用失败后成功降级。
  • 重试中的前几次失败。
  • 数据状态异常但没有中断业务。
  • 资源使用率接近阈值。
  • 非关键配置缺失。

WARN 应该具有可操作性。如果一个警告长期存在且任何人都不需要处理,它很可能不应该是 WARN。

ERROR

ERROR 表示当前操作失败,通常需要开发或运维人员关注。

log.error("订单支付处理失败, orderId={}, paymentNo={}",
        orderId, paymentNo, exception);

适合记录:

  • 未预期的程序异常。
  • 数据库操作失败。
  • 核心外部服务不可用。
  • 数据损坏或状态严重不一致。
  • 请求无法继续完成。
  • 补偿、降级和重试全部失败。

ERROR 并不等于“代码进入了 catch”。只有真正影响系统或请求结果、需要关注的异常才应记录为 ERROR。


系统异常和业务错误不能混为一谈

实际项目中最常见的问题,是把所有异常都记录为 ERROR。这会造成监控系统持续报警,却很难找到真正的系统故障。

什么是系统异常

系统异常通常不是用户正常操作导致的,例如:

  • 数据库连接中断。
  • Redis 不可用。
  • 空指针异常。
  • 数据转换异常。
  • 第三方服务超时。
  • 文件读写失败。
  • 代码状态不一致。

这类错误通常应该记录为 ERROR,并保留完整异常栈:

try {
    paymentClient.pay(request);
} catch (Exception exception) {
    log.error("调用支付服务失败, orderId={}, paymentNo={}",
            orderId, paymentNo, exception);
    throw exception;
}

注意,异常对象应该作为日志方法的最后一个参数传入。

// 正确:会输出完整异常栈
log.error("支付失败, orderId={}", orderId, exception);

以下写法通常只能记录异常描述,无法保留完整调用栈:

// 不推荐
log.error("支付失败: {}", exception.getMessage());

// 不推荐
log.error("支付失败: " + exception);

异常栈包含异常类型、发生位置和调用链,是定位系统故障最重要的信息之一。

什么是业务错误

业务错误是系统正常运行时可以预期的拒绝结果,例如:

  • 登录密码错误。
  • 商品库存不足。
  • 优惠券已过期。
  • 订单状态不允许取消。
  • 用户余额不足。
  • 请求的数据不存在。
  • 用户没有操作权限。

这些通常不属于系统故障,不应该全部记录为 ERROR。

if (stock < quantity) {
    log.info("库存不足,订单创建被拒绝, productId={}, requested={}, available={}",
            productId, quantity, stock);
    throw new BusinessException("库存不足");
}

业务错误应该使用什么级别,需要根据它的价值判断:

  • 正常且常见的业务拒绝:通常使用 INFO,也可以不记录。
  • 值得观察的异常业务状态:使用 WARN。
  • 业务状态已经严重不一致,需要人工处理:使用 ERROR。

例如,用户输入错误密码是正常业务结果,不应记录为 ERROR:

log.info("用户登录失败, username={}, reason=INVALID_PASSWORD", username);

但同一账号短时间内连续失败很多次,可能涉及安全风险,可以聚合后记录为 WARN:

log.warn("账号短时间内多次登录失败, userId={}, failureCount={}",
        userId, failureCount);

如果订单显示支付成功,但支付流水不存在,这虽然表现为业务数据问题,却需要立即处理,可以记录为 ERROR:

log.error("订单支付状态不一致, orderId={}, orderStatus={}, paymentRecord={}",
        orderId, orderStatus, paymentRecord);

因此,日志级别不应该简单根据“是不是业务异常”决定,而应该根据影响范围和是否需要处理决定。


避免同一个异常被重复记录

下面是一种常见但错误的写法:

public void createOrder() {
    try {
        inventoryService.deduct();
    } catch (Exception exception) {
        log.error("扣减库存失败", exception);
        throw exception;
    }
}

上层又记录一次:

try {
    orderService.createOrder();
} catch (Exception exception) {
    log.error("创建订单失败", exception);
    throw exception;
}

全局异常处理器再记录一次:

@ExceptionHandler(Exception.class)
public Result<?> handle(Exception exception) {
    log.error("系统异常", exception);
    return Result.fail("系统繁忙");
}

一次异常由此产生三份几乎相同的异常栈,不仅浪费存储,也会干扰告警统计。

推荐遵循一个原则:

异常只在能够最终处理它,或者能够补充关键上下文的位置记录一次。

如果当前方法只是继续向上抛出异常,而且不能增加有价值的信息,可以不记录:

public void createOrder() {
    inventoryService.deduct();
}

最后在全局异常处理器中统一记录:

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException exception) {
        log.info("业务请求未完成, code={}, message={}",
                exception.getCode(), exception.getMessage());

        return Result.fail(exception.getCode(), exception.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleSystemException(Exception exception) {
        log.error("未处理的系统异常", exception);
        return Result.fail("SYSTEM_ERROR", "系统繁忙,请稍后重试");
    }
}

如果底层必须记录,例如异常即将被转换、吞掉或降级,则应补充调用对象、关键参数和降级结果:

try {
    return recommendationClient.query(userId);
} catch (Exception exception) {
    log.warn("推荐服务调用失败,返回默认推荐列表, userId={}", userId, exception);
    return defaultRecommendations();
}

日志内容应该包含什么

一条有效日志通常需要回答几个问题:

  • 发生了什么?
  • 在哪个请求中发生?
  • 涉及哪个用户或业务对象?
  • 执行结果是什么?
  • 失败原因是什么?
  • 是否执行了重试、降级或补偿?

推荐采用“固定事件描述 + 结构化字段”的形式:

log.info("订单创建成功, traceId={}, orderId={}, userId={}, amount={}",
        traceId, orderId, userId, amount);

重要字段可以包括:

  • traceId:一次请求的链路标识。
  • userId:用户标识。
  • orderId:订单等业务实体标识。
  • requestId:请求标识。
  • clientIp:客户端地址。
  • costMs:执行耗时。
  • result:处理结果。
  • errorCode:标准错误码。
  • retryCount:重试次数。

不推荐只打印模糊描述:

log.error("执行失败");
log.warn("数据不正确");
log.info("进入方法");

这种日志无法帮助定位具体请求和业务数据。

同时,也不要简单序列化整个对象:

// 可能包含密码、Token、身份证号或大字段
log.info("注册请求: {}", request);

应该只记录定位问题所需的安全字段:

log.info("收到注册请求, username={}, channel={}",
        request.getUsername(), request.getChannel());

使用 MDC 记录 Trace ID

如果每条日志都手动传递 traceId,代码会非常繁琐。可以使用 SLF4J 的 MDC 保存当前请求的上下文信息。

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.UUID;

@Component
public class TraceIdFilter implements Filter {

    private static final String TRACE_ID = "traceId";

    @Override
    public void doFilter(
            ServletRequest request,
            ServletResponse response,
            FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;

        String traceId = httpRequest.getHeader("X-Trace-Id");
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }

        try {
            MDC.put(TRACE_ID, traceId);
            chain.doFilter(request, response);
        } finally {
            // Web 容器通常会复用线程,必须清理 MDC。
            MDC.remove(TRACE_ID);
        }
    }
}

之后在 Logback 输出格式中加入 %X{traceId}:

<pattern>
    %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level
    [%X{traceId:-NO_TRACE}] %logger{50} - %msg%n
</pattern>

日志效果如下:

2026-08-27 10:21:32.318 [http-nio-8080-exec-2] INFO
[84b830c095be45e7a284fac4580895d0] com.example.OrderService -
订单创建成功, orderId=10001, userId=20001

这样便可以通过 traceId 检索一次请求产生的全部日志。

如果使用线程池或异步任务,需要额外传递和清理 MDC。普通 ThreadLocal 上下文不会自动传播到另一个线程。


记录 HTTP 接入日志

业务日志和 HTTP 接入日志的职责不同。

业务日志描述系统做了什么;接入日志描述哪个客户端在什么时间访问了哪个接口,以及请求最终返回了什么结果。

一条接入日志通常包括:

  • 请求方法。
  • 请求路径。
  • HTTP 状态码。
  • 客户端 IP。
  • User-Agent。
  • Trace ID。
  • 请求耗时。
  • 用户标识。
  • 响应大小。

可以使用过滤器统一记录:

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

@Slf4j(topic = "ACCESS_LOG")
@Component
public class AccessLogFilter implements Filter {

    @Override
    public void doFilter(
            ServletRequest request,
            ServletResponse response,
            FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        long start = System.nanoTime();

        try {
            chain.doFilter(request, response);
        } finally {
            long costMs = TimeUnit.NANOSECONDS.toMillis(
                    System.nanoTime() - start);

            log.info(
                    "method={}, path={}, status={}, clientIp={}, costMs={}, userAgent={}",
                    httpRequest.getMethod(),
                    httpRequest.getRequestURI(),
                    httpResponse.getStatus(),
                    getClientIp(httpRequest),
                    costMs,
                    httpRequest.getHeader("User-Agent")
            );
        }
    }

    private String getClientIp(HttpServletRequest request) {
        String forwardedFor = request.getHeader("X-Forwarded-For");

        if (forwardedFor != null && !forwardedFor.isBlank()) {
            return forwardedFor.split(",")[0].trim();
        }

        return request.getRemoteAddr();
    }
}

这里通过单独的 Logger 名称 ACCESS_LOG 输出接入日志,后续可以在 Logback 中将它写入独立文件。

需要注意:只有当请求确实经过可信的反向代理,并且代理正确覆盖 X-Forwarded-For 时,才能信任这个请求头。否则客户端可以伪造它。

接入日志一般不要打印完整请求体和响应体,原因包括:

  • 可能包含密码、Token 和个人隐私。
  • 文件上传或大响应会造成日志急剧膨胀。
  • 流式请求和响应不适合直接读取。
  • 重复读取请求体可能影响业务处理。
  • 高频序列化会增加接口耗时。

确实需要记录时,应设置白名单、大小限制和脱敏规则。


Logback 生产环境配置

在 Spring Boot 项目的 src/main/resources 下创建:

logback-spring.xml

之所以推荐使用 logback-spring.xml,是因为它能够使用 Spring Boot 提供的扩展能力,例如读取 Spring 属性和按 Profile 配置日志。

下面是一套基础生产配置:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <springProperty
            scope="context"
            name="APP_NAME"
            source="spring.application.name"
            defaultValue="spring-app"/>

    <springProperty
            scope="context"
            name="LOG_PATH"
            source="logging.file.path"
            defaultValue="./logs"/>

    <property name="LOG_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-NO_TRACE}] %logger{50} - %msg%n"/>

    <!-- 控制台输出 -->
    <appender name="CONSOLE"
              class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- 应用综合日志 -->
    <appender name="APP_FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <!-- 始终表示正在写入的当日日志 -->
        <file>${LOG_PATH}/${APP_NAME}.log</file>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <!-- 日期变化或文件达到 100MB 时归档 -->
            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ERROR 独立日志 -->
    <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.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>90</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- HTTP 接入日志 -->
    <appender name="ACCESS_FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <file>${LOG_PATH}/${APP_NAME}-access.log</file>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}-access.%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>

    <!-- ACCESS_LOG 只写接入日志文件,不重复传递到 root -->
    <logger name="ACCESS_LOG"
            level="INFO"
            additivity="false">
        <appender-ref ref="ACCESS_FILE"/>
    </logger>

    <!-- 根据项目实际情况调整第三方组件级别 -->
    <logger name="org.springframework" level="INFO"/>
    <logger name="org.hibernate" level="WARN"/>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="APP_FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>

</configuration>

在 application.yml 中配置应用名和日志路径:

spring:
  application:
    name: order-service

logging:
  file:
    path: ./logs

生产环境也可以通过启动参数覆盖日志路径:

--logging.file.path=/data/logs/order-service

运行后目录大致如下:

logs/
├── order-service.log
├── order-service-error.log
├── order-service-access.log
└── archive/
    ├── order-service.2026-08-26.0.log.gz
    ├── order-service.2026-08-26.1.log.gz
    ├── order-service-error.2026-08-26.0.log.gz
    └── order-service-access.2026-08-26.0.log.gz

当日日志和历史日志如何滚动

配置中的核心是:

<file>${LOG_PATH}/${APP_NAME}.log</file>

以及:

<fileNamePattern>
    ${LOG_PATH}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz
</fileNamePattern>

两者配合后的行为是:

  1. 当前正在写入的日志始终叫 order-service.log。
  2. 日期变化时,旧文件会被归档到 archive 目录。
  3. 同一天文件超过 maxFileSize 后,会按照序号继续拆分。
  4. 历史文件使用 .gz 压缩,减少磁盘占用。

例如,某一天日志量超过 100 MB 后可能生成:

order-service.2026-08-27.0.log.gz
order-service.2026-08-27.1.log.gz
order-service.2026-08-27.2.log.gz

这里的 %d{yyyy-MM-dd} 负责按日期滚动,%i 负责同一天按大小编号。

只配置日期滚动而不限制单文件大小,可能在流量异常时生成数 GB 的单个日志文件。因此生产环境更推荐同时按日期和大小滚动。

几个关键配置的含义如下:

<maxFileSize>100MB</maxFileSize>

单个归档文件最大 100 MB。

<maxHistory>30</maxHistory>

最多保留 30 天的历史归档。

<totalSizeCap>10GB</totalSizeCap>

所有历史归档总大小不超过 10 GB。即使还没有达到 30 天,超过容量限制时也可能清理旧文件。

<cleanHistoryOnStart>true</cleanHistoryOnStart>

应用启动时检查并清理超过保留策略的旧日志。

不要只配置 maxHistory 而忽略 totalSizeCap。某一天发生日志风暴时,即使只保留几天,也可能耗尽磁盘。


下面这部分可直接替换原文的“按日志级别拆分文件”章节。该方案不修改 Java 代码,仅修改 logback-spring.xml,即可将 INFO、WARN、ERROR 精确拆分到不同文件。

仅修改配置文件实现日志级别分离

如果项目已经统一使用 SLF4J 记录日志,那么不需要修改任何业务代码,只需调整 logback-spring.xml,就可以按照日志级别生成独立文件:

logs/
├── order-service.log
├── order-service-info.log
├── order-service-warn.log
├── order-service-error.log
└── archive/
    ├── order-service-info.2026-08-27.0.log.gz
    ├── order-service-warn.2026-08-27.0.log.gz
    └── order-service-error.2026-08-27.0.log.gz

推荐保留一份包含全部日志的综合文件,同时生成精确级别文件:

  • order-service.log:包含 INFO、WARN、ERROR 等全部有效日志。
  • order-service-info.log:只包含 INFO。
  • order-service-warn.log:只包含 WARN。
  • order-service-error.log:只包含 ERROR。

这样既可以通过综合日志还原完整请求过程,也可以快速查看警告和错误。

LevelFilter 和 ThresholdFilter 的区别

实现日志分离时,需要特别注意两种过滤器的区别。

LevelFilter 表示精确匹配某个级别:

<filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
</filter>

该过滤器只接受 INFO,不会接受 WARN 和 ERROR,因此适合日志级别拆分。

ThresholdFilter 表示接受指定级别及其以上的所有日志:

<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
    <level>WARN</level>
</filter>

它会同时接受:

WARN
ERROR

因此,如果目标是让 warn.log 只包含 WARN,就不能使用 ThresholdFilter,而应该使用 LevelFilter。

完整配置示例

下面的配置仅依赖 Logback,不需要修改 Controller、Service 或其他 Java 代码。

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- 从 Spring Boot 配置中读取应用名称 -->
    <springProperty
            scope="context"
            name="APP_NAME"
            source="spring.application.name"
            defaultValue="spring-app"/>

    <!-- 从 Spring Boot 配置中读取日志目录 -->
    <springProperty
            scope="context"
            name="LOG_PATH"
            source="logging.file.path"
            defaultValue="./logs"/>

    <!-- 统一日志格式 -->
    <property
            name="LOG_PATTERN"
            value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-NO_TRACE}] %logger{50} - %msg%n"/>

    <!-- 控制台日志 -->
    <appender name="CONSOLE"
              class="ch.qos.logback.core.ConsoleAppender">

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- 综合日志:包含 INFO、WARN、ERROR 等全部有效级别 -->
    <appender name="ALL_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}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- INFO 日志:只接收 INFO -->
    <appender name="INFO_FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <file>${LOG_PATH}/${APP_NAME}-info.log</file>

        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>INFO</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}-info.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- WARN 日志:只接收 WARN -->
    <appender name="WARN_FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <file>${LOG_PATH}/${APP_NAME}-warn.log</file>

        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>WARN</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}-warn.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>60</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ERROR 日志:只接收 ERROR -->
    <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.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>90</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- 降低第三方组件的日志噪声 -->
    <logger name="org.springframework" level="INFO"/>
    <logger name="org.hibernate" level="WARN"/>

    <!--
        root 只决定允许产生的最低日志级别。
        日志具体写入哪个文件,由各 Appender 的过滤器决定。
    -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="ALL_FILE"/>
        <appender-ref ref="INFO_FILE"/>
        <appender-ref ref="WARN_FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>

</configuration>

对应的 application.yml 配置如下:

spring:
  application:
    name: order-service

logging:
  file:
    path: ./logs

应用重新启动后,日志将自动按照级别写入不同文件,不需要修改原有的日志代码:

log.info("订单创建成功, orderId={}", orderId);
log.warn("库存即将不足, productId={}", productId);
log.error("订单创建失败, orderId={}", orderId, exception);

对应关系为:

日志代码综合日志INFO 文件WARN 文件ERROR 文件
log.info(...)写入写入不写入不写入
log.warn(...)写入不写入写入不写入
log.error(...)写入不写入不写入写入

为什么 Root Logger 要配置多个 Appender

一条日志产生后,会被发送给 Root Logger 引用的所有 Appender。每个 Appender 再通过自己的过滤器判断是否接收。

例如,一条 WARN 日志的处理过程如下:

WARN 日志
    ├── CONSOLE:接收并输出到控制台
    ├── ALL_FILE:接收并写入综合日志
    ├── INFO_FILE:级别不匹配,拒绝
    ├── WARN_FILE:级别匹配,接收
    └── ERROR_FILE:级别不匹配,拒绝

因此,业务代码不需要选择日志文件。代码只负责使用正确的日志级别,日志配置负责决定最终写入位置。

如果不需要综合日志

如果希望不同级别完全独立,不需要 ${APP_NAME}.log 综合文件,可以从 Root Logger 中删除:

<appender-ref ref="ALL_FILE"/>

修改后:

<root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="INFO_FILE"/>
    <appender-ref ref="WARN_FILE"/>
    <appender-ref ref="ERROR_FILE"/>
</root>

此时每条日志只会出现在对应的级别文件中。

不过,生产环境通常建议保留综合日志。排查一次请求时,往往需要按照时间顺序同时查看 INFO、WARN 和 ERROR。如果日志完全分散在不同文件中,还原完整调用过程会更加困难。

如果需要拆分 DEBUG 日志

开发或测试环境可能需要单独保存 DEBUG 日志,可以增加:

<appender name="DEBUG_FILE"
          class="ch.qos.logback.core.rolling.RollingFileAppender">

    <file>${LOG_PATH}/${APP_NAME}-debug.log</file>

    <filter class="ch.qos.logback.classic.filter.LevelFilter">
        <level>DEBUG</level>
        <onMatch>ACCEPT</onMatch>
        <onMismatch>DENY</onMismatch>
    </filter>

    <rollingPolicy
            class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

        <fileNamePattern>
            ${LOG_PATH}/archive/${APP_NAME}-debug.%d{yyyy-MM-dd}.%i.log.gz
        </fileNamePattern>

        <maxFileSize>100MB</maxFileSize>
        <maxHistory>7</maxHistory>
        <totalSizeCap>5GB</totalSizeCap>
    </rollingPolicy>

    <encoder>
        <pattern>${LOG_PATTERN}</pattern>
        <charset>UTF-8</charset>
    </encoder>
</appender>

同时需要将 Root Logger 的最低级别调整为 DEBUG,并引用该 Appender:

<root level="DEBUG">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="ALL_FILE"/>
    <appender-ref ref="DEBUG_FILE"/>
    <appender-ref ref="INFO_FILE"/>
    <appender-ref ref="WARN_FILE"/>
    <appender-ref ref="ERROR_FILE"/>
</root>

需要注意:只有代码中原本存在 log.debug(...),并且 Logger 允许 DEBUG 级别时,才能产生 DEBUG 日志。配置文件可以决定已有日志是否输出、输出到哪里,但不能把代码中的 log.info(...) 自动变成 DEBUG。

生产环境不建议长期将 Root Logger 设置为 DEBUG,否则 Spring、数据库驱动及其他依赖可能产生大量日志。更安全的方式是只为项目业务包开启:

<root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="ALL_FILE"/>
    <appender-ref ref="INFO_FILE"/>
    <appender-ref ref="WARN_FILE"/>
    <appender-ref ref="ERROR_FILE"/>
</root>

<logger name="com.example.order"
        level="DEBUG"
        additivity="true">
    <appender-ref ref="DEBUG_FILE"/>
</logger>

这里的 com.example.order 应替换为项目实际的业务包名。

仅通过配置还可以按包拆分日志

除了按级别分离,如果项目的 Controller、Service、Mapper 位于不同包中,也可以在不修改代码的情况下按包输出日志。

例如,将 Mapper 日志写入独立文件:

<logger name="com.example.order.mapper"
        level="DEBUG"
        additivity="false">
    <appender-ref ref="MAPPER_FILE"/>
</logger>

其中,MAPPER_FILE 的定义方式与其他滚动文件 Appender 相同。

需要注意:

  1. 按包拆分不需要修改 Java 代码。
  2. 前提是现有代码的包结构能够区分不同模块。
  3. 如果多个业务模块混合在同一个包中,仅靠配置无法准确识别日志属于订单、支付还是库存。
  4. 如果希望按照任意业务类型拆分,通常需要代码使用不同的 Logger 名称或 Marker。

因此,“按日志级别拆分”和“按现有包名拆分”可以完全通过配置实现;“按照代码中不存在的业务标签拆分”则无法仅依靠配置凭空完成。

配置时的注意事项

不要让同一个文件被多个 Appender 写入

每个滚动文件应该只由一个 Appender 负责写入。多个 Appender 同时操作同一路径,可能造成日志丢失、滚动失败或文件竞争。

不要混淆 Logger 级别和 Appender 过滤器

Logger 级别决定日志事件是否能够产生:

<root level="INFO">

这表示 TRACE 和 DEBUG 会在进入 Appender 前被丢弃。

Appender 过滤器决定已经产生的事件写入哪个文件:

<level>ERROR</level>

因此,如果 Root Logger 是 INFO,即使配置了 DEBUG_FILE,也不会收到 Root Logger 拦截掉的 DEBUG 日志。

精确分级必须使用 LevelFilter

如果希望文件之间不存在级别交叉,应统一使用:

<filter class="ch.qos.logback.classic.filter.LevelFilter">

不要用 ThresholdFilter 实现精确级别分离。

ERROR 文件可以保留更长时间

不同级别可以设置不同的保留策略,例如:

INFO:保留 30 天
WARN:保留 60 天
ERROR:保留 90 天
DEBUG:保留 7 天

但仍应为每类日志设置 totalSizeCap,防止某个级别突然暴增并耗尽磁盘。

验证日志分离是否正确

配置完成并重启应用后,可以通过现有接口触发不同日志,或者使用项目中已有的日志调用进行验证。

重点检查:

  1. INFO 是否只进入综合日志和 INFO 文件。
  2. WARN 是否只进入综合日志和 WARN 文件。
  3. ERROR 是否只进入综合日志和 ERROR 文件。
  4. 日期变化后是否生成历史归档文件。
  5. 单文件达到 maxFileSize 后是否继续按照 %i 拆分。
  6. 历史日志是否成功压缩为 .gz。
  7. 超过保留天数或总容量后是否清理旧文件。
  8. 启动过程中是否出现 Logback 配置错误。

如果同一条日志在同一个文件中出现两次,应重点检查:

  1. Logger 是否同时引用了 Appender。
  2. Root Logger 是否也引用了同一个 Appender。
  3. 子 Logger 的 additivity 是否设置正确。

对于自定义 Logger,如果不希望日志继续传递到 Root Logger,应配置:

additivity="false"

小结

Spring Boot 项目已经使用 SLF4J 和 Logback 时,日志级别分离完全可以通过修改 logback-spring.xml 实现,不需要改动业务代码。

核心做法是:

  1. 为 INFO、WARN、ERROR 分别创建一个 RollingFileAppender。
  2. 每个 Appender 使用 LevelFilter 精确匹配日志级别。
  3. Root Logger 同时引用这些 Appender。
  4. 使用 SizeAndTimeBasedRollingPolicy 实现按日期和大小滚动。
  5. 同时设置保留天数和总容量上限。
  6. 根据排查需要决定是否保留一份综合日志。

配置负责日志的输出位置,代码负责选择正确的日志级别。两者职责分离后,既能做到不侵入业务代码,也能获得清晰、可维护的生产日志目录。


按业务拆分日志

对于支付、审计、消息消费等重要场景,可以建立独立 Logger。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class PaymentLog {

    public static final Logger LOGGER =
            LoggerFactory.getLogger("PAYMENT_LOG");

    private PaymentLog() {
    }
}

业务代码中使用:

PaymentLog.LOGGER.info(
        "支付状态变更, orderId={}, paymentNo={}, from={}, to={}",
        orderId, paymentNo, oldStatus, newStatus);

Logback 中增加对应的 Appender 和 Logger:

<appender name="PAYMENT_FILE"
          class="ch.qos.logback.core.rolling.RollingFileAppender">

    <file>${LOG_PATH}/${APP_NAME}-payment.log</file>

    <rollingPolicy
            class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

        <fileNamePattern>
            ${LOG_PATH}/archive/${APP_NAME}-payment.%d{yyyy-MM-dd}.%i.log.gz
        </fileNamePattern>

        <maxFileSize>100MB</maxFileSize>
        <maxHistory>180</maxHistory>
        <totalSizeCap>20GB</totalSizeCap>
    </rollingPolicy>

    <encoder>
        <pattern>${LOG_PATTERN}</pattern>
        <charset>UTF-8</charset>
    </encoder>
</appender>

<logger name="PAYMENT_LOG"
        level="INFO"
        additivity="false">
    <appender-ref ref="PAYMENT_FILE"/>
</logger>

additivity="false" 非常重要。它表示这条日志不会继续传递给父 Logger,否则同一条支付日志可能同时出现在支付日志和应用综合日志中。

不过,业务日志文件不应该代替数据库中的业务记录。日志适合检索、排错和审计辅助,不适合作为唯一的交易事实来源。


开发和生产环境使用不同配置

开发环境通常希望:

  1. 输出到控制台。
  2. 可以临时开启 DEBUG。
  3. 日志格式便于阅读。

生产环境通常希望:

  1. 默认使用 INFO。
  2. 输出到文件或标准输出采集系统。
  3. 控制第三方框架的日志量。
  4. 启用滚动、压缩和清理策略。

可以在 logback-spring.xml 中使用 Spring Profile:

<springProfile name="dev">
    <logger name="com.example" level="DEBUG"/>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</springProfile>

<springProfile name="prod">
    <logger name="com.example" level="INFO"/>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="APP_FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>
</springProfile>

也可以在不同的配置文件中设置级别:

# application-dev.yml
logging:
  level:
    com.example: DEBUG
# application-prod.yml
logging:
  level:
    root: INFO
    com.example: INFO
    org.springframework: INFO
    org.hibernate: WARN

是否应该使用异步日志

同步写日志会占用请求线程。在日志量较大的系统中,可以通过 AsyncAppender 降低文件写入对业务线程的影响:

<appender name="ASYNC_APP_FILE"
          class="ch.qos.logback.classic.AsyncAppender">

    <queueSize>8192</queueSize>
    <discardingThreshold>0</discardingThreshold>
    <neverBlock>false</neverBlock>

    <appender-ref ref="APP_FILE"/>
</appender>

然后让 Root Logger 引用异步 Appender:

<root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="ASYNC_APP_FILE"/>
    <appender-ref ref="ERROR_FILE"/>
</root>

异步日志并非没有代价:

  1. 应用异常退出时,队列中尚未写出的日志可能丢失。
  2. 队列过小时,高峰期可能阻塞或丢弃日志。
  3. 队列过大会占用更多内存。
  4. 如果同一个实际文件被多个 Appender 同时写入,可能产生冲突。

对于错误日志、审计日志和支付关键日志,应根据可靠性要求谨慎决定是否异步写入。日志不能替代可靠消息、数据库流水或审计存储。


日志脱敏与安全

日志经常被开发、测试、运维或外部日志平台访问,因此不能默认它是安全的。

以下信息不应该直接写入日志:

  • 密码和支付密码。
  • Access Token、Refresh Token。
  • Cookie、Session ID。
  • Authorization 请求头。
  • 银行卡号和安全码。
  • 身份证号。
  • 手机号、邮箱和家庭地址等个人信息。
  • 加密私钥和数据库密码。
  • 完整请求体或第三方原始响应中的敏感字段。

错误示例:

log.info("用户登录, username={}, password={}",
        request.getUsername(), request.getPassword());

log.debug("请求头 Authorization={}",
        request.getHeader("Authorization"));

正确做法是完全不记录秘密信息,并对必要的个人信息脱敏:

log.info("用户登录请求, username={}, mobile={}",
        request.getUsername(), maskMobile(request.getMobile()));

同时需要防止日志注入。来自用户的文本可能包含换行符,导致伪造多行日志。应对自由文本进行清理、限制长度,或者使用成熟的结构化日志方案。


常见错误实践

所有异常都记录为 ERROR

业务校验失败并不代表系统故障。大量无意义的 ERROR 会让真正的故障告警失去价值。

只记录异常消息

log.error("处理失败: {}", exception.getMessage());

这会丢失异常类型、代码位置和完整调用链。系统异常应传入异常对象。

同一异常层层打印

异常在 DAO、Service、Controller 和全局异常处理器中重复记录,会造成日志污染和告警重复。

打印完整请求和响应

这容易泄露敏感数据,也可能导致磁盘、网络和 CPU 压力。

生产环境长期打开 DEBUG

高流量系统开启 DEBUG 后可能迅速耗尽磁盘,并影响应用性能。

日志没有业务标识

只写“订单处理失败”远远不够,至少应该包含 orderId、userId、traceId 和错误原因。

没有设置容量上限

只有按天滚动,没有单文件限制和总容量限制,仍然可能因为日志风暴耗尽磁盘。

使用日志代替业务数据

日志可能清理、丢失或重复,不能作为订单、支付、余额等关键数据的唯一来源。


推荐的日志决策方式

写日志之前,可以依次判断:

  1. 这条信息对排错、监控、审计或业务分析是否有价值?
  2. 它是正常事件、可恢复异常,还是操作失败?
  3. 是否已经在其他层记录过?
  4. 是否包含足够的请求和业务上下文?
  5. 是否包含敏感信息?
  6. 在生产流量下,这条日志会产生多大规模?
  7. 看到这条 WARN 或 ERROR 后,是否有人能够采取行动?

可以采用以下级别参考:

场景建议级别
订单创建成功INFO
用户输入错误密码INFO 或不记录
库存不足INFO
非关键服务失败,已成功降级WARN
外部服务第一次重试失败WARN
核心服务重试全部失败ERROR
未预期的空指针异常ERROR
数据库连接失败ERROR
支付状态严重不一致ERROR
方法内部中间计算结果DEBUG
高频循环内的执行细节TRACE 或不记录

这张表不是绝对规则。最终级别取决于业务影响、发生频率和是否需要人工关注。


生产环境落地清单

一套可用的 Spring Boot 日志体系,建议至少完成以下配置:

  • 业务代码统一使用 SLF4J。
  • 生产环境默认日志级别为 INFO。
  • 使用占位符输出参数,避免字符串拼接。
  • 接入 Trace ID,并在请求结束时清理 MDC。
  • HTTP 接入日志与普通业务日志分离。
  • 应用综合日志和 ERROR 日志分离。
  • 重要支付或审计业务使用独立 Logger。
  • 按日期和文件大小同时滚动。
  • 历史日志启用压缩。
  • 同时设置 maxHistory 和 totalSizeCap。
  • 系统异常记录完整异常栈。
  • 正常业务错误不滥用 ERROR。
  • 避免同一个异常被重复记录。
  • 对密码、Token 和个人信息执行隐藏或脱敏。
  • 对日志目录配置磁盘空间监控。
  • 对 ERROR 数量、接口失败率和高耗时请求建立告警。
  • 定期检查日志是否仍然具有定位价值。

总结

优秀的日志体系并不是记录得越多越好,而是在正确的位置,以正确的级别,记录足够且安全的上下文。

正常业务流程使用 INFO,开发排查信息使用 DEBUG,已经降级或值得关注的异常使用 WARN,真正导致操作失败、需要处理的系统故障使用 ERROR。系统异常应记录完整异常栈,普通业务拒绝则不应制造无意义的错误告警。

配合 Trace ID、HTTP 接入日志、业务日志拆分,以及按日期和大小滚动的 Logback 配置,才能让日志在生产故障发生时真正发挥作用,而不是变成占满磁盘却无法检索的文本。