本节目标:把图书服务的日志从「给人看的文本」升级为「给机器检索的 JSON」,掌握 MDC、请求 ID 串联、线程池上下文传递,以及多环境日志配置。
适用版本:Spring Boot 4.1.x(Java 21)
16.2 结构化日志
16.1 结束时,图书服务已经能把日志写进文件并轮转了。但那些日志是纯文本,一旦进了日志平台,问题就来了:想查「所有 500 错误的请求 ID」时,只能靠正则去啃字符串。本节把日志变成结构化的——每条日志都是一个 JSON 对象,字段明确,机器可以直接索引和聚合。
16.2.1 文本日志为什么难检索
同一个「图书不存在」事件,两种输出形态对比:
2026-10-09 10:12:03.114 ERROR [http-nio-8080-exec-3] c.e.l.service.BookService - 图书不存在 id=42, userId=1001, 耗时 3ms
{"@timestamp":"2026-10-09T10:12:03.114+08:00","level":"ERROR","logger_name":"com.example.library.service.BookService","message":"图书不存在","thread_name":"http-nio-8080-exec-3","bookId":42,"userId":1001,"durationMs":3,"requestId":"7f3a1c"}
差在哪:
| 维度 | 文本 | JSON |
|---|---|---|
| 提取字段 | 靠正则,格式一改就崩 | 直接按字段名取 |
| 类型 | 全是字符串 | 数字是数字,便于范围查询 |
| 聚合 | 难统计「每本书的失败次数」 | group by bookId 即可 |
| 字段缺失 | 看不出是「没有」还是「没匹配上」 | 字段显式存在或不存在 |
| 中英文混排 | 分词、转义麻烦 | 值就是一个字符串字段 |
一句话:文本日志是写给人读的,结构化日志是写给机器查的。生产环境两者都要——控制台给人看,文件/采集器给机器看。
16.2.2 用 logback-spring.xml 输出 JSON
要改输出格式,就得接管 Logback 配置。在 src/main/resources/ 下放一个 logback-spring.xml。JSON 编码最成熟的方案是 logstash-logback-encoder:
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>8.0</version>
</dependency>
(近几个版本的 Logback 也内置了轻量的 JsonEncoder,能输出基础 JSON;需要 MDC、结构化参数、自定义字段时,logstash-logback-encoder 更完整,本节以它为例。)
一个同时支持「dev 彩色文本 + prod JSON」的 logback-spring.xml:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProperty scope="context" name="APP_NAME"
source="spring.application.name" defaultValue="book-service"/>
<!-- dev:彩色文本,给人看 -->
<springProfile name="dev">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n</pattern>
</encoder>
</appender>
</springProfile>
<!-- 其他环境:JSON,给机器看 -->
<springProfile name="!dev">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdc>true</includeMdc>
<customFields>{"service":"${APP_NAME}"}</customFields>
</encoder>
</appender>
</springProfile>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
几个要点:
<springProfile name="...">让同一份配置按 profile 走不同分支,这是logback-spring.xml独有的能力。<springProperty>把 Spring 的配置项(如spring.application.name)读进 Logback 变量,供${APP_NAME}引用。<includeMdc>true</includeMdc>是后面 MDC 能出现在 JSON 里的开关。customFields给每条日志固定附加字段,如服务名、版本号。
16.2.3 logback-spring.xml 与 logback.xml 的区别
两个文件名只差 -spring,行为差别很大:
| 维度 | logback.xml | logback-spring.xml |
|---|---|---|
| 加载者 | Logback 直接加载 | Spring Boot 的 LogbackLoggingSystem |
| 加载时机 | 极早,早于 Spring 环境就绪 | Spring 环境就绪后 |
<springProfile> | 不支持 | 支持 |
<springProperty> | 不支持 | 支持 |
能用 logging.* 属性 | 不能 | 能 |
结论:永远用 logback-spring.xml。用了 logback.xml 会踩的典型坑是:<springProfile> 被当成未知标签忽略,dev 和 prod 用同一份配置;或者 Logback 抢在 Spring 之前加载,${APP_NAME} 解析成空。
16.2.4 MDC:给日志带上上下文
结构化日志真正的价值在于把同一次请求的日志串起来。做法是 MDC(Mapped Diagnostic Context)——一个绑定到当前线程的键值容器,放进去的值会自动出现在每条日志里。
import org.slf4j.MDC;
MDC.put("requestId", "7f3a1c");
log.info("开始借书");
MDC.remove("requestId");
配合 <includeMdc>true</includeMdc>,requestId 就成了 JSON 里的一个字段,可以在日志平台里 requestId:"7f3a1c" 一次拉出这条请求的全部日志。
陷阱:MDC 绑定在 ThreadLocal 上,线程池里会丢。 一次请求在 http-nio-8080-exec-3 线程放进 MDC,一旦业务里用了 @Async、CompletableFuture 或线程池,任务跑在另一个线程上,那个线程的 MDC 是空的——requestId 消失,日志又断链了。
16.2.5 用 TaskDecorator 传递 MDC
解法是 TaskDecorator:在任务被提交到线程池之前捕获当前线程的 MDC,在任务执行时恢复到目标线程。
package com.example.library.config;
import java.util.Map;
import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
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();
}
};
}
}
关键是 MDC.getCopyOfContextMap() 取的是快照,不是引用——线程池里的线程会被复用,如果直接传引用,上一个任务残留的 requestId 会污染下一个任务。finally 里的 MDC.clear() 则是把线程「洗干净」再还回池子。
挂到线程池上:
package com.example.library.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public ThreadPoolTaskExecutor applicationTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("book-async-");
executor.setTaskDecorator(new MdcTaskDecorator());
executor.initialize();
return executor;
}
}
4.0 起支持多个 TaskDecorator 组合:你可以注册若干个(一个传 MDC、一个传安全上下文、一个做计时),Spring 会把它们按顺序组合成一个链,而不像以前只能设一个。这对「既要 MDC 又要 Spring Security 的 SecurityContext」的场景尤其有用。
16.2.6 用 Filter 把请求 ID 写进 MDC
MDC 的值从哪来?最自然的来源是请求进入系统的第一站——Filter(呼应 11.3 的过滤器)。在 Filter 里生成或读取请求 ID,放进 MDC,请求结束再清理。
package com.example.library.web.filter;
import java.io.IOException;
import java.util.UUID;
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 jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestIdFilter extends OncePerRequestFilter {
public static final String REQUEST_ID = "requestId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String requestId = request.getHeader("X-Request-Id");
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
MDC.put(REQUEST_ID, requestId);
response.setHeader("X-Request-Id", requestId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove(REQUEST_ID);
}
}
}
三个设计要点:
- 优先复用上游传来的
X-Request-Id:网关已经生成过就不重复生成,跨服务链路才连得上。 - 回写到响应头:客户端拿到
X-Request-Id后,报障时可以直接给你这个 ID,省去大海捞针。 finally里清理:Tomcat 线程是复用的,不清理会让 requestId 泄漏到下个请求。用@Order(HIGHEST_PRECEDENCE)保证它最早执行,让后续所有日志都能带上 ID。
16.2.7 多环境日志配置
把上面的片段拼成完整策略:dev 用彩色文本方便人看,test/prod 用 JSON 方便机器采集。除了 16.2.2 的 <springProfile> 分支,还可以把「输出到文件」也按环境区分。
<springProfile name="prod">
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/book-service.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/book-service.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdc>true</includeMdc>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</springProfile>
SizeAndTimeBasedRollingPolicy 同时按天和按大小切分:%d{yyyy-MM-dd} 是天,%i 是同一天内的序号,.gz 让归档压缩存放。这与 16.1 讲的 logging.logback.rollingpolicy.* 属性是同一套机制的两种写法——一旦有了 logback-spring.xml,logging.logback.* 里的轮转属性就不再生效,配置权交给了 XML。
16.2.8 结构化日志的字段设计建议
字段设计得好,检索才顺手。推荐每个服务固定输出这些字段:
| 字段 | 来源 | 用途 |
|---|---|---|
@timestamp | 编码器自动 | 时间轴、排序 |
level | 编码器自动 | 过滤级别 |
logger_name | 编码器自动 | 定位来源类 |
message | log.info(...) | 事件描述 |
thread_name | 编码器自动 | 排查并发/异步 |
service | customFields | 多服务混合采集时区分 |
requestId | MDC | 串联一次请求 |
traceId / spanId | 链路追踪(16.3 预告) | 跨服务串联 |
三条原则:
- 字段名统一:全公司都叫
requestId,别一个服务叫reqId、另一个叫trace_id。 - 值要机器可算:数字就用数字类型,
durationMs比"耗时 3ms"有用得多。 - 不要塞 PII:requestId、bookId 可以,手机号、身份证、完整卡号绝不能进日志——这是 16.3 的重点。
小结
- 文本日志靠正则提取字段,格式一改就崩;结构化日志(JSON)字段明确、可聚合、可范围查询,是生产环境的正确形态。
- 用
logback-spring.xml(不是logback.xml)接管配置,配合logstash-logback-encoder输出 JSON;<springProfile>/<springProperty>是 Spring 版独有的能力。 - MDC 把上下文(如 requestId)自动附到每条日志;它绑在 ThreadLocal 上,线程池里会丢。
- 用
TaskDecorator捕获 MDC 快照并在目标线程恢复,finally里MDC.clear()防污染;4.0 起支持多个 TaskDecorator 组合。 - 用 Filter 生成/复用
X-Request-Id写入 MDC,并回写响应头;@Order(HIGHEST_PRECEDENCE)保证它最先执行。 - 多环境用
<springProfile>分支:dev 彩色文本、prod JSON;一旦使用 XML,logging.logback.*轮转属性失效。 - 字段设计遵循统一命名、机器可算、不含 PII 三条原则。
日志现在既能给人看、又能给机器查了。下一节我们把镜头拉远:该记什么、不该记什么,异常怎么写,异步日志的取舍,以及一份可直接照做的日志规范 checklist。
阅读导航:上一节:16.1 日志配置与级别 · 下一节:16.3 日志实践要点 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。