Spring Boot 4.0 日志体系:Logback、MDC 与结构化日志
前言
接口报错时,如果日志里只有“请求成功”或“订单不存在”,你很难判断它对应哪一次请求。并发一高,多个请求的日志还会交错在一起。
这篇教程会给每次 HTTP 请求生成一个 traceId,写入 Slf4j MDC,并通过 Logback 格式输出。完成后,响应头和同一次请求的日志会带同一个标识。
这一章建立在前一篇 Bean 生命周期之上:启动日志负责说明容器状态,traceId 负责把一次请求内的日志串起来。
配套源码:dvsusan/spring-boot4-demo。
最终结果
请求接口后,响应头包含:
X-Trace-Id: 681244892c884fd794f697570563df42控制台日志包含相同值:
2026-11-06 10:30:12.573 INFO [http-nio-18082-exec-2]
[traceId=681244892c884fd794f697570563df42]
c.s.t.web.filter.TraceIdFilter - request completed:
method=GET, path=/api/v1/system/info, status=200, elapsedMs=5实际 traceId、线程名和耗时会变化。

图 1:同一个 traceId 同时出现在响应头和日志中,请求结束后必须从 MDC 清理。
第 1 步:认识 Slf4j 与 Logback
业务代码使用 Slf4j 接口:
// 静态 Logger 属于类本身,不随业务对象重复创建。
private static final Logger log =
LoggerFactory.getLogger(BeanLifecycleDemo.class);Logback 负责真正把日志写到哪里、用什么格式。Spring Boot 默认集成 Logback,项目不需要额外引入日志 Starter。
优先使用参数化日志:
// 使用占位符,避免在日志级别关闭时提前拼接字符串。
log.info("BeanLifecycleDemo 构造器执行,environment={}",
environmentProperties.getLabel());不要使用字符串拼接:
// 日志级别关闭时,字符串也可能先被拼接。
log.info("environment=" + environmentProperties.getLabel());参数化日志更清楚,也更有利于延迟构造日志内容。
第 2 步:配置 Logback 输出格式
创建 demo-web/src/main/resources/logback-spring.xml:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 复用 Spring Boot 的默认日志级别、颜色和格式定义。 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- traceId 由 TraceIdFilter 写入 MDC;没有请求上下文时显示短横线。 -->
<property name="CONSOLE_LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [traceId=%X{traceId:-}] %logger{36} - %msg%n"/>
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>%X{traceId:-} 从 MDC 中读取 traceId,没有值时显示 -。
文件名使用 logback-spring.xml,Spring Boot 会在加载时处理其中的 Spring 扩展。普通 logback.xml 不会被同样的早期扩展机制处理。
第 3 步:创建请求 traceId
创建 TraceIdFilter.java:
package com.susan.training.web.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
/**
* 为每次 HTTP 请求生成 traceId,并写入 MDC。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(TraceIdFilter.class);
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String traceId = UUID.randomUUID().toString().replace("-", "");
long startedAt = System.nanoTime();
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId);
try {
filterChain.doFilter(request, response);
} finally {
long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000;
log.info("request completed: method={}, path={}, status={}, elapsedMs={}",
request.getMethod(), request.getRequestURI(),
response.getStatus(), elapsedMillis);
// 线程会被复用,必须在 finally 中清理 MDC。
MDC.remove("traceId");
}
}
}OncePerRequestFilter 保证一次请求分发只执行一次过滤逻辑。@Order(Ordered.HIGHEST_PRECEDENCE) 让它尽量早地设置 traceId。
finally 中的清理不能省。Web 容器会复用线程,如果 MDC 残留,下一个请求可能继承上一个请求的标识。
第 4 步:配置不同环境的日志级别
公共配置中:
logging:
level:
# 公共环境只开启项目包的 INFO 日志。
com.susan.training: INFO开发环境 application-dev.yml 中:
logging:
level:
# 开发环境临时打开 DEBUG,便于观察细节。
com.susan.training: DEBUG级别从低到高常见为:
TRACE < DEBUG < INFO < WARN < ERROR设置 DEBUG 会输出更详细的信息,也会增加磁盘和采集成本。生产环境不能因为排查方便就长期打开全量 DEBUG。
第 5 步:启动并验证响应头
构建项目:
mvn clean package -DskipTests启动:
java -jar demo-web/target/demo-web-1.0.0-SNAPSHOT.jar --server.port=18087使用 curl -i 查看响应头:
curl -i "http://localhost:18087/api/v1/system/info"响应开头应包含:
HTTP/1.1 200
X-Trace-Id: 681244892c884fd794f697570563df42
Content-Type: application/json同一时间查看服务终端,会找到相同的 traceId。
第 6 步:验证一次失败请求
请求不存在的订单:
curl -i "http://localhost:18087/api/v1/orders/NOT-EXISTS"响应返回 404,过滤器的完成日志仍然会记录状态码:
request completed: method=GET, path=/api/v1/orders/NOT-EXISTS,
status=404, elapsedMs=...即使业务进入异常分支,finally 仍然执行,所以 MDC 会被清理。
如果团队接入 OpenTelemetry、Zipkin 或日志平台,后续可以让平台生成的 traceId 与这里统一,而不是同时维护多个互不相关的请求 ID。
第 7 步:什么时候需要结构化日志?
文本日志适合人工阅读。需要按字段检索、聚合和告警时,可以使用 JSON 或 ECS 结构化格式。
企业项目常用的字段包括:
timestamplevelloggertraceIdrequestPathstatuselapsedMs
结构化日志不是“全部输出 JSON”这么简单。要先确定日志平台已经消费这些字段,再配置文件格式,否则只是让日志更难肉眼阅读。
日志中不能出现什么?
无论是文本还是 JSON,都要避免输出:
- 密码、密钥、Token。
- 完整身份证号、手机号和银行卡号。
- 完整数据库连接串。
- 大段请求体和响应体。
- 用户隐私数据。
需要定位问题时,记录脱敏后的业务 ID、状态码和摘要。日志平台也应该设置访问权限和保留周期。
常见问题排查
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 日志没有 traceId | Logback 格式没有 %X{traceId} | 检查 logback-spring.xml |
| 响应头有 traceId、日志没有 | Filter 执行顺序晚于首个业务日志 | 提高过滤器优先级 |
| 下一请求沿用旧 ID | MDC 没有清理 | 在 finally 中移除 |
| DEBUG 日志不出现 | Profile 或日志级别覆盖 | 检查最终激活配置 |
| 日志突然暴增 | 请求体、循环或异常全量打印 | 降低级别、采样、删除敏感大字段 |
练习题
让请求支持传入 X-Request-Id:
- 如果请求头存在,就复用它作为 traceId。
- 如果不存在,再生成 UUID。
- 响应头回传最终使用的值。
- 仍然在
finally中清理 MDC。
总结
日志体系解决三个问题:发生了什么、属于哪次请求、关闭日志后会不会留下垃圾。Logback 负责输出,MDC 负责上下文,Filter 负责给请求建立边界。
完成工程基建 7 篇后,项目已经具备模块边界、自动配置、环境配置、Bean 生命周期和基础可观测能力。下一部分可以进入 Web 与 API 开发,把这些基础能力接到真正的业务接口上。
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
