《Spring Boot 入门》16.2 结构化日志

文本日志检索困难,本节把图书服务日志改成结构化 JSON:用 logback-spring.xml 配合 logstash-logback-encoder 输出字段化日志,讲清 MDC 用法与线程池不传递的陷阱及 TaskDecorator 解法,用 Filter 把请求 ID 写进 MDC 串联一次请求,并对比 logback-spring.xml 与 logback.xml 的差别。

本节目标:把图书服务的日志从「给人看的文本」升级为「给机器检索的 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.xmllogback-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编码器自动定位来源类
messagelog.info(...)事件描述
thread_name编码器自动排查并发/异步
servicecustomFields多服务混合采集时区分
requestIdMDC串联一次请求
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 日志实践要点 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计