《Java 编程实战》12.2 结构化日志:JSON、MDC 与请求上下文

文本日志无法按 requestId 聚合一次借阅的多个步骤。本节用 logstash-logback-encoder 9.0 把 libraryd 的日志输出成 JSON,讲清 MDC 的线程绑定与泄漏、requestId 与 traceId 的生命周期、StructuredArguments 字段,以及线程池之间的上下文传递。

本节目标:让 libraryd 的每条日志都带可检索的 requestId 与 traceId,并能用 JSON 字段而不是正则从日志里还原一次请求的完整路径。
适用版本:JDK 25(Temurin 25.0.5)、SLF4J 2.0.20、Logback 1.6.6、logstash-logback-encoder 9.0。

承接的问题

12.1 节的文本日志人能读,机器不好读。一次借阅请求会产生多条日志:

2026-10-27T18:03:58.412+08:00 INFO  [JettyServerThreadPool-46] com.plume.library.web.HttpMetrics requestId=90be800e-aa87-4948-963c-ba19e5f1c153 traceId=11111111111111111111111111111111 - http_request method=POST path=/api/loans status=201 duration_ms=18 requestId=90be800e-aa87-4948-963c-ba19e5f1c153

同一次请求里如果还有 Hikari 的慢连接、Redis 的降级、业务层的 WARN,它们分散在不同行。用 rg 按 requestId=90be... 能找到,但要做「按状态码统计耗时」「按 traceId 关联下游服务」「把异常堆栈单独提取」就很别扭。文本格式把字段压进了一行字符串,消费端只能靠正则。

结构化日志把每个字段变成 JSON 的键。同一行可以写成:

{"@timestamp":"2026-10-27T18:04:16.73474+08:00","@version":"1","message":"http_request method=GET path=/api/books/9787111213826 status=200 duration_ms=21 requestId=json-demo-1","logger_name":"com.plume.library.web.HttpMetrics","thread_name":"JettyServerThreadPool-41","level":"INFO","level_value":20000,"requestId":"json-demo-1","traceId":"602afe5c17b93cbbd7377544135869dd","service":"libraryd"}

消费端可以直接按 requestId、level、logger_name 建索引,不需要解析消息正文。代价是日志体积变大,且字段设计一旦混乱,JSON 也救不了排查。本节先解决字段从哪里来,再解决哪些字段该进来。

核心机制一:MDC 是线程绑定的上下文

SLF4J 的 MDC(Mapped Diagnostic Context)是一个 ThreadLocal<Map<String, String>>。调用 MDC.put("requestId", id) 只影响当前线程,随后同一线程上的日志都能通过 %X{requestId} 或 JSON encoder 拿到这个值。

libraryd 在请求进入时写入:

package com.plume.library.web;

import io.javalin.http.Context;
import java.util.UUID;
import org.slf4j.MDC;

public final class RequestContext {

    public static final String HEADER = "X-Request-Id";
    public static final String ATTRIBUTE = "libraryd.requestId";
    private static final String START_NANOS = "libraryd.startNanos";

    public static String begin(Context context) {
        String requestId = context.header(HEADER);
        if (requestId == null || requestId.isBlank()) {
            requestId = UUID.randomUUID().toString();
        }
        context.attribute(ATTRIBUTE, requestId);
        context.attribute(START_NANOS, System.nanoTime());
        context.header(HEADER, requestId);
        MDC.put("requestId", requestId);
        return requestId;
    }

    public static String requestId(Context context) {
        return context.attribute(ATTRIBUTE);
    }

    public static long elapsedNanos(Context context) {
        Long start = context.attribute(START_NANOS);
        return start == null ? 0L : System.nanoTime() - start;
    }

    public static void clear(Context context) {
        MDC.remove("requestId");
        MDC.remove("traceId");
    }
}

三个动作要一起看:

  • 客户端带了 X-Request-Id 就沿用,没带就生成 UUID。这样调用方可以把请求 ID 带进自己的日志。
  • 同一个值写进 Javalin 的 Context 属性和响应头,后续 handler 与错误响应都能拿到。
  • 写进 MDC,当前线程后续的日志自动带上。

MDC 是线程绑定的,这一点在池化线程上会变成泄漏。下面这段代码在单线程池里提交四个任务,第一个任务写入 requestId 但没有清理:

pool.submit(() -> {
    MDC.put("requestId", "req-A");
    log.info("first task handled");
}).get();
pool.submit(() -> log.info("second task handled")).get();

本机输出:

2026-10-27T18:04:37.816+08:00 INFO  [pool-1-thread-1] MdcLeakDemo requestId=req-A traceId= - first task handled
2026-10-27T18:04:37.818+08:00 INFO  [pool-1-thread-1] MdcLeakDemo requestId=req-A traceId= - second task handled

第二个任务已经不属于 req-A,但日志仍然带着它。正确做法是在 finally 里清理:

pool.submit(() -> {
    MDC.put("requestId", "req-C");
    try {
        log.info("third task handled");
    } finally {
        MDC.clear();
    }
}).get();

清理之后,第四个任务的日志变回 requestId=。libraryd 的 RequestContext.clear 放在 Javalin 的 after 处理器里,并且用 finally 包住,确保异常路径也会执行。

核心机制二:LogstashEncoder 与 JSON 字段

Logback 只负责输出,JSON 编码交给 net.logstash.logback:logstash-logback-encoder。libraryd 的 logback-json.xml 是:

<configuration>
  <property name="LOG_LEVEL" value="${LIBRARYD_LOG_LEVEL:-INFO}"/>

  <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <includeMdcKeyName>requestId</includeMdcKeyName>
      <includeMdcKeyName>traceId</includeMdcKeyName>
      <customFields>{"service":"libraryd"}</customFields>
    </encoder>
  </appender>

  <logger name="org.eclipse.jetty" level="WARN"/>
  <logger name="com.zaxxer.hikari" level="INFO"/>
  <logger name="io.opentelemetry" level="INFO"/>

  <root level="${LOG_LEVEL}">
    <appender-ref ref="JSON"/>
  </root>
</configuration>

LogstashEncoder 会自动写出一组标准字段:

字段来源示例
@timestamp事件时间2026-10-27T18:04:16.73474+08:00
@versionencoder 版本1
message格式化后的消息http_request method=GET ...
logger_namelogger 名com.plume.library.web.HttpMetrics
thread_name线程名JettyServerThreadPool-41
level级别文本INFO
level_value级别数值20000
stack_trace异常堆栈,仅有异常时出现java.lang.IllegalStateException: ...
requestIdMDCjson-demo-1
traceIdMDC602afe5c17b93cbbd7377544135869dd
servicecustomFieldslibraryd

includeMdcKeyName 是白名单。指定之后,只有列出的 MDC 键会进入 JSON。本机额外放了一个 memberId 做对比:

{"@timestamp":"2026-10-27T18:13:18.789735+08:00","@version":"1","message":"mdc filter demo","logger_name":"JsonMdcFilterDemo","thread_name":"main","level":"INFO","level_value":20000,"requestId":"req-1","traceId":"4bf92f3577b34da6a3ce929d0e0e4736","service":"libraryd"}

memberId 没有出现。这个白名单很重要:MDC 里的键一旦被随意写入,JSON 顶层字段会跟着膨胀;更糟的是会员 ID、邮箱、令牌如果进了 MDC,就会被所有日志复制。12.3 节会专门处理脱敏。

异常在 JSON 里是独立的 stack_trace 字段,不在 message 里。本机输出:

{"@timestamp":"2026-10-27T18:04:56.399945+08:00","@version":"1","message":"book fetch failed isbn=9787111213826","logger_name":"JsonLogDemo","thread_name":"main","level":"ERROR","level_value":40000,"stack_trace":"java.lang.IllegalStateException: connection reset\n\tat JsonLogDemo.main(JsonLogDemo.java:13)\n","requestId":"req-json-1","traceId":"4bf92f3577b34da6a3ce929d0e0e4736","service":"libraryd"}

消费端可以把 stack_trace 单独存字段,也可以按 level=ERROR 聚合,不需要在消息文本里找 Exception。

核心机制三:requestId 与 traceId 的生命周期

libraryd 在 LibraryApi.create 里注册了两个全局处理器:

config.routes.before(this::before);
config.routes.after(this::after);

before 的顺序是:

private void before(Context context) {
    RequestContext.begin(context);
    tracing.start(context);
}

RequestContext.begin 先写 requestId,TracingMiddleware.start 再写 traceId。后者从 traceparent 提取父上下文,创建 SERVER span,然后把 span 的 trace id 放进 MDC:

Span span = tracer.spanBuilder(context.method().name() + " " + context.path())
        .setParent(parent)
        .setSpanKind(SpanKind.SERVER)
        .startSpan();
span.setAttribute("http.request.method", context.method().name());
span.setAttribute("url.path", context.path());
span.setAttribute("request.id", RequestContext.requestId(context));
context.attribute(SPAN_ATTRIBUTE, span);
MDC.put("traceId", span.getSpanContext().getTraceId());

after 的顺序相反:

private void after(Context context) {
    try {
        httpMetrics.record(context, RequestContext.elapsedNanos(context));
    } finally {
        tracing.finish(context);
        RequestContext.clear(context);
    }
}

httpMetrics.record 会打一条访问日志,此时 MDC 还在,所以日志里有 requestId 和 traceId。随后 tracing.finish 设置响应状态、注入 traceparent 响应头、结束 span;最后 RequestContext.clear 移除两个 MDC 键。顺序不能反:如果先清 MDC 再打访问日志,日志里就只剩空字段。

实际应用里一次请求的文本日志长这样:

2026-10-27T18:15:47.771+08:00 INFO  [JettyServerThreadPool-46] com.plume.library.web.HttpMetrics requestId=missing-1 traceId=c0bf76787883276d917859ba8f0df6f6 - http_request method=GET path=/api/books/does-not-exist status=404 duration_ms=10 requestId=missing-1

JSON 模式下的同一条请求:

{"@timestamp":"2026-10-27T18:04:16.73474+08:00","@version":"1","message":"http_request method=GET path=/api/books/9787111213826 status=200 duration_ms=21 requestId=json-demo-1","logger_name":"com.plume.library.web.HttpMetrics","thread_name":"JettyServerThreadPool-41","level":"INFO","level_value":20000,"requestId":"json-demo-1","traceId":"602afe5c17b93cbbd7377544135869dd","service":"libraryd"}

请求头里的 X-Request-Id 也会出现在响应里。本机发送 X-Request-Id: demo-req-001 创建图书,响应头是:

HTTP/1.1 201 Created
X-Request-Id: demo-req-001
Location: /api/books/9787111213826
traceparent: 00-39fd656118a7a167f26f3440ce3ff0b8-8cdea47bad56a785-03

调用方拿响应头里的值去日志平台查,就能对上服务端的每一条记录。错误响应也一样。404 的 Problem Details 里带 requestId:

{"type":"https://plumphp.com/problems/book-not-found","title":"Not Found","status":404,"detail":"book not found: does-not-exist","code":"E_BOOK_NOT_FOUND","requestId":"missing-1","errors":{}}

requestId 和 traceId 的职责不同:requestId 标识一次 HTTP 请求,客户端可以自带;traceId 标识一条跨服务调用链,由追踪 SDK 生成或从上游传入。一次请求通常只有一个 requestId,但可能有多个 span;所有 span 共享同一个 traceId。

核心机制四:结构化参数与字段选择

MDC 适合放「整个请求期间都成立」的字段,比如 requestId、traceId、租户 ID。单条日志独有的业务字段更适合用 StructuredArguments。logstash-logback-encoder 提供 kv():

import static net.logstash.logback.argument.StructuredArguments.kv;

log.info("book fetched {}", kv("isbn", "9787111213826"), kv("cache", "hit"));
log.warn("loan overdue {}", kv("loanId", 42L), kv("overdueDays", 3));

本机 JSON 输出:

{"@timestamp":"2026-10-27T18:14:24.609775+08:00","@version":"1","message":"book fetched isbn=9787111213826","logger_name":"StructuredArgsDemo","thread_name":"main","level":"INFO","level_value":20000,"requestId":"req-1","isbn":"9787111213826","cache":"hit","service":"libraryd"}
{"@timestamp":"2026-10-27T18:14:24.620662+08:00","@version":"1","message":"loan overdue loanId=42","logger_name":"StructuredArgsDemo","thread_name":"main","level":"WARN","level_value":30000,"requestId":"req-1","loanId":42,"overdueDays":3,"service":"libraryd"}

isbn、cache、loanId、overdueDays 都成了顶层字段,message 仍然保留人类可读的文本。loanId 是数字类型,JSON 里没有引号;如果消费端按字符串建索引,需要提前约定类型。

字段选择的原则是「查询用什么,就放什么」:

  • 按请求聚合:requestId、traceId,放 MDC。
  • 按业务实体过滤:isbn、memberId、loanId,用 kv()。
  • 按结果分类:cache=hit/miss、status=200/404,用 kv() 或指标 tag。
  • 排查细节:SQL、参数、堆栈,放 DEBUG 或异常字段,不要塞进 MDC。

不要把高基数字段放进 MDC。ISBN 有几十万种,放进 MDC 后每条日志都复制一遍,字段膨胀且没有聚合价值;它属于单条事件的参数。

在 libraryd 里落地:一次借阅的完整日志

把上面的部分串起来,用真实请求走一遍。启动 libraryd,带 X-Request-Id: demo-req-001 和 traceparent 发起借阅:

curl -s -i -X POST http://127.0.0.1:8080/api/loans \
  -H 'Content-Type: application/json' \
  -H 'X-Request-Id: demo-req-001' \
  -H 'traceparent: 00-11111111111111111111111111111111-2222222222222222-01' \
  -d '{"isbn":"9787111213826","memberId":"m-001"}'

本机响应:

HTTP/1.1 201 Created
X-Request-Id: demo-req-001
Location: /api/loans/1
traceparent: 00-11111111111111111111111111111111-205062f2c0ea07de-01

服务端访问日志:

2026-10-27T18:03:58.xxx+08:00 INFO  [JettyServerThreadPool-41] com.plume.library.web.HttpMetrics requestId=demo-req-001 traceId=11111111111111111111111111111111 - http_request method=POST path=/api/loans status=201 duration_ms=18 requestId=demo-req-001

这条日志里能确认四件事:

  • 客户端自带的 requestId 被沿用,没有生成新的 UUID。
  • traceId 保留了上游的 32 位十六进制值,没有换新的。
  • 响应头回写了 traceparent,下游可以继续串联。
  • 访问日志只记录方法、路径、状态、耗时,没有把请求体整段打出来。

如果借阅失败,LibraryApi.handleLibraryException 会按错误码的严重级别决定日志级别:严重度 3 及以上记 WARN,DB_UNAVAILABLE 和 INTERNAL 记 ERROR,其余(例如 404)只靠访问日志。这样 404 不会把日志刷成 WARN,真正的故障仍然有堆栈。

要在日志平台里还原一次请求,按 requestId 过滤即可:

requestId="demo-req-001"

如果一次请求跨了多个服务,按 traceId 过滤:

traceId="11111111111111111111111111111111"

常见错误与边界

错误一:MDC 写入后不清理。 前面的 MdcLeakDemo 已经演示:池化线程复用时会带着上一个请求的 requestId。清理必须放在 finally,不能只放在成功路径。libraryd 把 RequestContext.clear 放在 after 的 finally 里,即使访问日志或 tracing 抛异常也会执行。

错误二:把 MDC 带到别的线程。 MDC 默认不是 InheritableThreadLocal,提交到线程池的任务看不到父线程的值。本机对比:

2026-10-27T18:11:16.631+08:00 INFO  [pool-1-thread-1] MdcPropagationDemo requestId= traceId= - without propagation
2026-10-27T18:11:16.633+08:00 INFO  [pool-1-thread-1] MdcPropagationDemo requestId=req-parent traceId= - with propagation

正确写法是先抓取快照,再在任务里设置:

Map<String, String> captured = MDC.getCopyOfContextMap();
executor.submit(() -> {
    if (captured != null) {
        MDC.setContextMap(captured);
    }
    try {
        log.info("async task");
    } finally {
        MDC.clear();
    }
});

如果使用虚拟线程,每个虚拟线程仍然是独立的 ThreadLocal,规则不变;不要假设它会自动继承。

错误三:MDC.clear() 清掉了别人的字段。 clear() 会清空整个 map。如果同一个线程上还承载了其他上下文(例如测试框架、批处理任务),应该用 MDC.remove("requestId") 精确移除。libraryd 用的是 remove,因为只写入了这两个键。

错误四:JSON 里出现重复字段。 message 里已经拼了 requestId=...,MDC 又输出一个 requestId 顶层字段,消费端会看到两份。HttpMetrics 目前的消息文本确实带了 requestId,这是为了兼容只读文本日志的场景;如果日志平台按顶层字段查询,消息里的副本可以去掉。两处保留时,要明确哪一个是权威字段。

错误五:异常只写进 message。 把 e.toString() 拼进消息会丢掉堆栈,JSON 里也没有 stack_trace。统一把异常作为最后一个参数,让 encoder 生成独立字段。

边界:日志采样与 traceId。 如果对 INFO 日志采样,未采样的请求可能只剩指标没有日志;排查时不能假设每个 traceId 都有完整日志。13.2 节会把 trace 与日志的关系讲清楚。采样策略要保证 ERROR 和 WARN 不被采样掉。

边界:同步输出阻塞请求线程。 ConsoleAppender 默认同步写标准输出。容器把标准输出接到管道时,如果下游消费慢,写日志会阻塞 Jetty 线程。高吞吐场景需要异步 Appender 或 sidecar 收集,但异步会带来丢日志风险,12.1 节已经量过。选择哪种方式取决于日志是否参与审计。

小结

  • MDC 是线程绑定的 map,requestId 与 traceId 在请求进入时写入,在 after 的 finally 中清理。
  • RequestContext.begin 复用客户端 X-Request-Id,否则生成 UUID,并同时写入响应头。
  • TracingMiddleware.start 从 traceparent 提取父上下文,创建 SERVER span,把 trace id 放进 MDC。
  • LogstashEncoder 输出标准字段,includeMdcKeyName 是白名单,customFields 适合放 service 这类固定字段。
  • StructuredArguments.kv() 把单条事件的业务字段变成 JSON 顶层字段,MDC 只放请求级上下文。
  • 线程池任务不会自动继承 MDC,要用 getCopyOfContextMap 和 setContextMap 显式传递。
  • 文本消息与 JSON 字段可能重复,消费端要约定哪个是权威来源。

字段齐了之后,下一个问题是字段里能放什么。会员 ID、邮箱、令牌一旦进日志,就会跟着所有副本扩散。下一节处理脱敏、采样与日志成本。

阅读导航:上一节:12.1 SLF4J 与 Logback:级别、Appender 与配置 · 下一节:12.3 日志规范:脱敏、采样与成本 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发