日志不是"程序运行时打印出来的文字",而是定位故障、观察系统状态和审计业务行为的重要数据。一个合理的日志体系至少需要解决以下问题:
- 项目如何接入并统一记录日志?
DEBUG、INFO、WARN、ERROR应该如何选择?- 系统异常和业务错误应该如何记录?
- 如何记录 HTTP 请求接入日志?
- 如何按业务、日期和文件大小拆分日志?
- 如何保留当日日志,并自动归档历史日志?
- 如何避免日志泄露密码、令牌等敏感信息?
本文以 Spring Boot 默认使用的 SLF4J + Logback 为例,给出一套适用于生产环境的实践方案。
Spring Boot 如何接入日志
Spring Boot Web 项目通常已经通过 spring-boot-starter 或 spring-boot-starter-web 间接引入日志组件,因此一般不需要再手动添加 Logback。
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Spring Boot 默认的日志组合是:
- SLF4J:日志门面,业务代码面向它编程。
- Logback:默认日志实现,负责日志输出、文件拆分和滚动归档。
代码中不要直接依赖 Logback 的具体类,而应该使用 SLF4J:
java
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 简化代码:
typescript
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class OrderService {
public void createOrder(Long userId) {
log.info("开始创建订单, userId={}", userId);
}
}
建议业务代码始终使用参数占位符:
c
log.info("订单创建成功, orderId={}, userId={}", orderId, userId);
不要使用字符串拼接:
c
// 不推荐
log.info("订单创建成功, orderId=" + orderId + ", userId=" + userId);
占位符可以避免不必要的字符串构造,并能保持日志格式统一。
日志级别应该如何选择
常见日志级别从低到高依次为:
vbnet
TRACE < DEBUG < INFO < WARN < ERROR
生产环境通常以 INFO 为默认级别,只针对必要的模块临时开启 DEBUG。
TRACE
TRACE 用于非常细粒度的执行过程,例如循环内部状态、协议字段解析过程等。
它产生的日志量很大,在普通业务系统中很少使用,也不应该在生产环境长期打开。
c
log.trace("解析协议字段, index={}, value={}", index, value);
DEBUG
DEBUG 用于开发和问题排查,记录对理解程序执行过程有帮助的内部信息。
lua
log.debug("订单优惠计算完成, orderId={}, originalAmount={}, discount={}",
orderId, originalAmount, discount);
适合记录:
- 方法的重要输入。
- 条件分支结果。
- 中间计算结果。
- 外部接口响应摘要。
- 缓存是否命中。
不要把每一步代码执行都打印成 DEBUG,否则真正有价值的信息会被大量噪声淹没。
对于计算成本较高的日志参数,可以先判断级别:
lua
if (log.isDebugEnabled()) {
log.debug("订单完整快照: {}", buildOrderSnapshot(order));
}
普通占位符参数一般不需要额外判断。
INFO
INFO 用于记录正常且具有业务或运维价值的重要事件。
c
log.info("订单创建成功, orderId={}, userId={}, amount={}",
orderId, userId, amount);
适合记录:
- 应用启动和关闭。
- 重要配置加载结果。
- 业务流程开始或完成。
- 订单创建、支付成功、退款完成等关键状态变化。
- 定时任务的开始、结束和处理统计。
- 外部服务调用结果摘要。
INFO 不应该记录每一次普通方法调用,否则生产日志会迅速膨胀。
WARN
WARN 表示系统出现了异常情况,但当前请求仍然可以继续,或者系统已经完成降级、重试和兜底。
c
log.warn("查询商品缓存失败,降级查询数据库, productId={}", productId);
适合记录:
- 参数不符合预期,但可以使用默认值。
- 第三方服务调用失败后成功降级。
- 重试中的前几次失败。
- 数据状态异常但没有中断业务。
- 资源使用率接近阈值。
- 非关键配置缺失。
WARN 应该具有可操作性。如果一个警告长期存在且任何人都不需要处理,它很可能不应该是 WARN。
ERROR
ERROR 表示当前操作失败,通常需要开发或运维人员关注。
lua
log.error("订单支付处理失败, orderId={}, paymentNo={}",
orderId, paymentNo, exception);
适合记录:
- 未预期的程序异常。
- 数据库操作失败。
- 核心外部服务不可用。
- 数据损坏或状态严重不一致。
- 请求无法继续完成。
- 补偿、降级和重试全部失败。
ERROR 并不等于"代码进入了 catch"。只有真正影响系统或请求结果、需要关注的异常才应记录为 ERROR。
系统异常和业务错误不能混为一谈
实际项目中最常见的问题,是把所有异常都记录为 ERROR。这会造成监控系统持续报警,却很难找到真正的系统故障。
什么是系统异常
系统异常通常不是用户正常操作导致的,例如:
- 数据库连接中断。
- Redis 不可用。
- 空指针异常。
- 数据转换异常。
- 第三方服务超时。
- 文件读写失败。
- 代码状态不一致。
这类错误通常应该记录为 ERROR,并保留完整异常栈:
php
try {
paymentClient.pay(request);
} catch (Exception exception) {
log.error("调用支付服务失败, orderId={}, paymentNo={}",
orderId, paymentNo, exception);
throw exception;
}
注意,异常对象应该作为日志方法的最后一个参数传入。
c
// 正确:会输出完整异常栈
log.error("支付失败, orderId={}", orderId, exception);
以下写法通常只能记录异常描述,无法保留完整调用栈:
c
// 不推荐
log.error("支付失败: {}", exception.getMessage());
// 不推荐
log.error("支付失败: " + exception);
异常栈包含异常类型、发生位置和调用链,是定位系统故障最重要的信息之一。
什么是业务错误
业务错误是系统正常运行时可以预期的拒绝结果,例如:
- 登录密码错误。
- 商品库存不足。
- 优惠券已过期。
- 订单状态不允许取消。
- 用户余额不足。
- 请求的数据不存在。
- 用户没有操作权限。
这些通常不属于系统故障,不应该全部记录为 ERROR。
erlang
if (stock < quantity) {
log.info("库存不足,订单创建被拒绝, productId={}, requested={}, available={}",
productId, quantity, stock);
throw new BusinessException("库存不足");
}
业务错误应该使用什么级别,需要根据它的价值判断:
- 正常且常见的业务拒绝:通常使用
INFO,也可以不记录。 - 值得观察的异常业务状态:使用
WARN。 - 业务状态已经严重不一致,需要人工处理:使用
ERROR。
例如,用户输入错误密码是正常业务结果,不应记录为 ERROR:
c
log.info("用户登录失败, username={}, reason=INVALID_PASSWORD", username);
但同一账号短时间内连续失败很多次,可能涉及安全风险,可以聚合后记录为 WARN:
c
log.warn("账号短时间内多次登录失败, userId={}, failureCount={}",
userId, failureCount);
如果订单显示支付成功,但支付流水不存在,这虽然表现为业务数据问题,却需要立即处理,可以记录为 ERROR:
lua
log.error("订单支付状态不一致, orderId={}, orderStatus={}, paymentRecord={}",
orderId, orderStatus, paymentRecord);
因此,日志级别不应该简单根据"是不是业务异常"决定,而应该根据影响范围和是否需要处理决定。
避免同一个异常被重复记录
下面是一种常见但错误的写法:
csharp
public void createOrder() {
try {
inventoryService.deduct();
} catch (Exception exception) {
log.error("扣减库存失败", exception);
throw exception;
}
}
上层又记录一次:
php
try {
orderService.createOrder();
} catch (Exception exception) {
log.error("创建订单失败", exception);
throw exception;
}
全局异常处理器再记录一次:
php
@ExceptionHandler(Exception.class)
public Result<?> handle(Exception exception) {
log.error("系统异常", exception);
return Result.fail("系统繁忙");
}
一次异常由此产生三份几乎相同的异常栈,不仅浪费存储,也会干扰告警统计。
推荐遵循一个原则:
异常只在能够最终处理它,或者能够补充关键上下文的位置记录一次。
如果当前方法只是继续向上抛出异常,而且不能增加有价值的信息,可以不记录:
csharp
public void createOrder() {
inventoryService.deduct();
}
最后在全局异常处理器中统一记录:
kotlin
@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", "系统繁忙,请稍后重试");
}
}
如果底层必须记录,例如异常即将被转换、吞掉或降级,则应补充调用对象、关键参数和降级结果:
kotlin
try {
return recommendationClient.query(userId);
} catch (Exception exception) {
log.warn("推荐服务调用失败,返回默认推荐列表, userId={}", userId, exception);
return defaultRecommendations();
}
日志内容应该包含什么
一条有效日志通常需要回答几个问题:
- 发生了什么?
- 在哪个请求中发生?
- 涉及哪个用户或业务对象?
- 执行结果是什么?
- 失败原因是什么?
- 是否执行了重试、降级或补偿?
推荐采用"固定事件描述 + 结构化字段"的形式:
c
log.info("订单创建成功, traceId={}, orderId={}, userId={}, amount={}",
traceId, orderId, userId, amount);
重要字段可以包括:
traceId:一次请求的链路标识。userId:用户标识。orderId:订单等业务实体标识。requestId:请求标识。clientIp:客户端地址。costMs:执行耗时。result:处理结果。errorCode:标准错误码。retryCount:重试次数。
不推荐只打印模糊描述:
lua
log.error("执行失败");
log.warn("数据不正确");
log.info("进入方法");
这种日志无法帮助定位具体请求和业务数据。
同时,也不要简单序列化整个对象:
c
// 可能包含密码、Token、身份证号或大字段
log.info("注册请求: {}", request);
应该只记录定位问题所需的安全字段:
less
log.info("收到注册请求, username={}, channel={}",
request.getUsername(), request.getChannel());
使用 MDC 记录 Trace ID
如果每条日志都手动传递 traceId,代码会非常繁琐。可以使用 SLF4J 的 MDC 保存当前请求的上下文信息。
java
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}:
perl
<pattern>
%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level
[%X{traceId:-NO_TRACE}] %logger{50} - %msg%n
</pattern>
日志效果如下:
ini
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。
- 请求耗时。
- 用户标识。
- 响应大小。
可以使用过滤器统一记录:
java
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
<?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 中配置应用名和日志路径:
yaml
spring:
application:
name: order-service
logging:
file:
path: ./logs
生产环境也可以通过启动参数覆盖日志路径:
css
--logging.file.path=/data/logs/order-service
运行后目录大致如下:
lua
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
当日日志和历史日志如何滚动
配置中的核心是:
bash
<file>${LOG_PATH}/${APP_NAME}.log</file>
以及:
bash
<fileNamePattern>
${LOG_PATH}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz
</fileNamePattern>
两者配合后的行为是:
- 当前正在写入的日志始终叫
order-service.log。 - 日期变化时,旧文件会被归档到
archive目录。 - 同一天文件超过
maxFileSize后,会按照序号继续拆分。 - 历史文件使用
.gz压缩,减少磁盘占用。
例如,某一天日志量超过 100 MB 后可能生成:
c
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 的单个日志文件。因此生产环境更推荐同时按日期和大小滚动。
几个关键配置的含义如下:
xml
<maxFileSize>100MB</maxFileSize>
单个归档文件最大 100 MB。
xml
<maxHistory>30</maxHistory>
最多保留 30 天的历史归档。
xml
<totalSizeCap>10GB</totalSizeCap>
所有历史归档总大小不超过 10 GB。即使还没有达到 30 天,超过容量限制时也可能清理旧文件。
bash
<cleanHistoryOnStart>true</cleanHistoryOnStart>
应用启动时检查并清理超过保留策略的旧日志。
不要只配置 maxHistory 而忽略 totalSizeCap。某一天发生日志风暴时,即使只保留几天,也可能耗尽磁盘。
下面这部分可直接替换原文的"按日志级别拆分文件"章节。该方案不修改 Java 代码,仅修改 logback-spring.xml,即可将 INFO、WARN、ERROR 精确拆分到不同文件。
仅修改配置文件实现日志级别分离
如果项目已经统一使用 SLF4J 记录日志,那么不需要修改任何业务代码,只需调整 logback-spring.xml,就可以按照日志级别生成独立文件:
lua
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 表示精确匹配某个级别:
xml
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
该过滤器只接受 INFO,不会接受 WARN 和 ERROR,因此适合日志级别拆分。
ThresholdFilter 表示接受指定级别及其以上的所有日志:
xml
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>WARN</level>
</filter>
它会同时接受:
vbnet
WARN
ERROR
因此,如果目标是让 warn.log 只包含 WARN,就不能使用 ThresholdFilter,而应该使用 LevelFilter。
完整配置示例
下面的配置仅依赖 Logback,不需要修改 Controller、Service 或其他 Java 代码。
xml
<?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 配置如下:
yaml
spring:
application:
name: order-service
logging:
file:
path: ./logs
应用重新启动后,日志将自动按照级别写入不同文件,不需要修改原有的日志代码:
lua
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 日志的处理过程如下:
markdown
WARN 日志
├── CONSOLE:接收并输出到控制台
├── ALL_FILE:接收并写入综合日志
├── INFO_FILE:级别不匹配,拒绝
├── WARN_FILE:级别匹配,接收
└── ERROR_FILE:级别不匹配,拒绝
因此,业务代码不需要选择日志文件。代码只负责使用正确的日志级别,日志配置负责决定最终写入位置。
如果不需要综合日志
如果希望不同级别完全独立,不需要 ${APP_NAME}.log 综合文件,可以从 Root Logger 中删除:
csharp
<appender-ref ref="ALL_FILE"/>
修改后:
ini
<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 日志,可以增加:
xml
<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:
ini
<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、数据库驱动及其他依赖可能产生大量日志。更安全的方式是只为项目业务包开启:
ini
<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 日志写入独立文件:
ini
<logger name="com.example.order.mapper"
level="DEBUG"
additivity="false">
<appender-ref ref="MAPPER_FILE"/>
</logger>
其中,MAPPER_FILE 的定义方式与其他滚动文件 Appender 相同。
需要注意:
- 按包拆分不需要修改 Java 代码。
- 前提是现有代码的包结构能够区分不同模块。
- 如果多个业务模块混合在同一个包中,仅靠配置无法准确识别日志属于订单、支付还是库存。
- 如果希望按照任意业务类型拆分,通常需要代码使用不同的 Logger 名称或 Marker。
因此,"按日志级别拆分"和"按现有包名拆分"可以完全通过配置实现;"按照代码中不存在的业务标签拆分"则无法仅依靠配置凭空完成。
配置时的注意事项
不要让同一个文件被多个 Appender 写入
每个滚动文件应该只由一个 Appender 负责写入。多个 Appender 同时操作同一路径,可能造成日志丢失、滚动失败或文件竞争。
不要混淆 Logger 级别和 Appender 过滤器
Logger 级别决定日志事件是否能够产生:
ini
<root level="INFO">
这表示 TRACE 和 DEBUG 会在进入 Appender 前被丢弃。
Appender 过滤器决定已经产生的事件写入哪个文件:
xml
<level>ERROR</level>
因此,如果 Root Logger 是 INFO,即使配置了 DEBUG_FILE,也不会收到 Root Logger 拦截掉的 DEBUG 日志。
精确分级必须使用 LevelFilter
如果希望文件之间不存在级别交叉,应统一使用:
ini
<filter class="ch.qos.logback.classic.filter.LevelFilter">
不要用 ThresholdFilter 实现精确级别分离。
ERROR 文件可以保留更长时间
不同级别可以设置不同的保留策略,例如:
vbnet
INFO:保留 30 天
WARN:保留 60 天
ERROR:保留 90 天
DEBUG:保留 7 天
但仍应为每类日志设置 totalSizeCap,防止某个级别突然暴增并耗尽磁盘。
验证日志分离是否正确
配置完成并重启应用后,可以通过现有接口触发不同日志,或者使用项目中已有的日志调用进行验证。
重点检查:
INFO是否只进入综合日志和 INFO 文件。WARN是否只进入综合日志和 WARN 文件。ERROR是否只进入综合日志和 ERROR 文件。- 日期变化后是否生成历史归档文件。
- 单文件达到
maxFileSize后是否继续按照%i拆分。 - 历史日志是否成功压缩为
.gz。 - 超过保留天数或总容量后是否清理旧文件。
- 启动过程中是否出现 Logback 配置错误。
如果同一条日志在同一个文件中出现两次,应重点检查:
- Logger 是否同时引用了 Appender。
- Root Logger 是否也引用了同一个 Appender。
- 子 Logger 的
additivity是否设置正确。
对于自定义 Logger,如果不希望日志继续传递到 Root Logger,应配置:
ini
additivity="false"
小结
Spring Boot 项目已经使用 SLF4J 和 Logback 时,日志级别分离完全可以通过修改 logback-spring.xml 实现,不需要改动业务代码。
核心做法是:
- 为
INFO、WARN、ERROR分别创建一个RollingFileAppender。 - 每个 Appender 使用
LevelFilter精确匹配日志级别。 - Root Logger 同时引用这些 Appender。
- 使用
SizeAndTimeBasedRollingPolicy实现按日期和大小滚动。 - 同时设置保留天数和总容量上限。
- 根据排查需要决定是否保留一份综合日志。
配置负责日志的输出位置,代码负责选择正确的日志级别。两者职责分离后,既能做到不侵入业务代码,也能获得清晰、可维护的生产日志目录。
按业务拆分日志
对于支付、审计、消息消费等重要场景,可以建立独立 Logger。
java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class PaymentLog {
public static final Logger LOGGER =
LoggerFactory.getLogger("PAYMENT_LOG");
private PaymentLog() {
}
}
业务代码中使用:
css
PaymentLog.LOGGER.info(
"支付状态变更, orderId={}, paymentNo={}, from={}, to={}",
orderId, paymentNo, oldStatus, newStatus);
Logback 中增加对应的 Appender 和 Logger:
xml
<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,否则同一条支付日志可能同时出现在支付日志和应用综合日志中。
不过,业务日志文件不应该代替数据库中的业务记录。日志适合检索、排错和审计辅助,不适合作为唯一的交易事实来源。
开发和生产环境使用不同配置
开发环境通常希望:
- 输出到控制台。
- 可以临时开启
DEBUG。 - 日志格式便于阅读。
生产环境通常希望:
- 默认使用
INFO。 - 输出到文件或标准输出采集系统。
- 控制第三方框架的日志量。
- 启用滚动、压缩和清理策略。
可以在 logback-spring.xml 中使用 Spring Profile:
ini
<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>
也可以在不同的配置文件中设置级别:
yaml
# application-dev.yml
logging:
level:
com.example: DEBUG
yaml
# application-prod.yml
logging:
level:
root: INFO
com.example: INFO
org.springframework: INFO
org.hibernate: WARN
是否应该使用异步日志
同步写日志会占用请求线程。在日志量较大的系统中,可以通过 AsyncAppender 降低文件写入对业务线程的影响:
xml
<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:
ini
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_APP_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
异步日志并非没有代价:
- 应用异常退出时,队列中尚未写出的日志可能丢失。
- 队列过小时,高峰期可能阻塞或丢弃日志。
- 队列过大会占用更多内存。
- 如果同一个实际文件被多个 Appender 同时写入,可能产生冲突。
对于错误日志、审计日志和支付关键日志,应根据可靠性要求谨慎决定是否异步写入。日志不能替代可靠消息、数据库流水或审计存储。
日志脱敏与安全
日志经常被开发、测试、运维或外部日志平台访问,因此不能默认它是安全的。
以下信息不应该直接写入日志:
- 密码和支付密码。
- Access Token、Refresh Token。
- Cookie、Session ID。
Authorization请求头。- 银行卡号和安全码。
- 身份证号。
- 手机号、邮箱和家庭地址等个人信息。
- 加密私钥和数据库密码。
- 完整请求体或第三方原始响应中的敏感字段。
错误示例:
less
log.info("用户登录, username={}, password={}",
request.getUsername(), request.getPassword());
log.debug("请求头 Authorization={}",
request.getHeader("Authorization"));
正确做法是完全不记录秘密信息,并对必要的个人信息脱敏:
less
log.info("用户登录请求, username={}, mobile={}",
request.getUsername(), maskMobile(request.getMobile()));
同时需要防止日志注入。来自用户的文本可能包含换行符,导致伪造多行日志。应对自由文本进行清理、限制长度,或者使用成熟的结构化日志方案。
常见错误实践
所有异常都记录为 ERROR
业务校验失败并不代表系统故障。大量无意义的 ERROR 会让真正的故障告警失去价值。
只记录异常消息
lua
log.error("处理失败: {}", exception.getMessage());
这会丢失异常类型、代码位置和完整调用链。系统异常应传入异常对象。
同一异常层层打印
异常在 DAO、Service、Controller 和全局异常处理器中重复记录,会造成日志污染和告警重复。
打印完整请求和响应
这容易泄露敏感数据,也可能导致磁盘、网络和 CPU 压力。
生产环境长期打开 DEBUG
高流量系统开启 DEBUG 后可能迅速耗尽磁盘,并影响应用性能。
日志没有业务标识
只写"订单处理失败"远远不够,至少应该包含 orderId、userId、traceId 和错误原因。
没有设置容量上限
只有按天滚动,没有单文件限制和总容量限制,仍然可能因为日志风暴耗尽磁盘。
使用日志代替业务数据
日志可能清理、丢失或重复,不能作为订单、支付、余额等关键数据的唯一来源。
推荐的日志决策方式
写日志之前,可以依次判断:
- 这条信息对排错、监控、审计或业务分析是否有价值?
- 它是正常事件、可恢复异常,还是操作失败?
- 是否已经在其他层记录过?
- 是否包含足够的请求和业务上下文?
- 是否包含敏感信息?
- 在生产流量下,这条日志会产生多大规模?
- 看到这条
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 配置,才能让日志在生产故障发生时真正发挥作用,而不是变成占满磁盘却无法检索的文本。