← BACK TO BLOG

Java MDC 详解:Mapped Diagnostic Context

从原理到落地,完整介绍 SLF4J / Logback 的 MDC:是什么、怎么用、异步如何传播、常见坑与生产实践。

次阅读

什么是 MDC

MDC 全称 Mapped Diagnostic Context(映射诊断上下文),是 SLF4J 提供的一套线程绑定的键值上下文。你可以在请求入口往里面放 traceIduserId 等字段,后续任意位置打日志时,日志框架会自动把这些字段拼进输出——业务代码不用每行手传。

一句话理解:

MDC = 给当前线程挂一个"随身小本子",日志打印时自动抄上去。

它解决的典型痛点是:高并发下多请求日志穿插在一起,人眼无法按请求过滤。把 traceId 放进 MDC 后,用 %X{traceId} 输出,再到日志平台按字段检索,一次请求的完整链路就能串起来。更上层的排查思路见:高并发服务日志乱序排查最佳实践

它存在哪里、怎么工作

在 Logback 实现里,MDC 底层是 ThreadLocal<Map<String, String>>

  • MDC.put("traceId", "abc"):往当前线程的 Map 里塞一对键值
  • 打日志时,Encoder / PatternLayout 读取这份 Map,把 %X{traceId} 替换成 abc
  • 请求结束调用 MDC.clear() / MDC.remove(...),避免线程池复用时脏数据串到下一个请求

关键推论:

  1. 同一线程内处处可读,无需方法参数层层传递
  2. 换线程就丢:线程池、@AsyncCompletableFuture、Reactor 默认不会自动带上 MDC
  3. 存的是 String(或会被转成字符串),别塞大对象

这和"手动在每条日志前拼 [traceId=xxx]"效果类似,但集中管理、不易漏、可统一改 pattern。

基本 API

SLF4J 的 org.slf4j.MDC 常用方法:

java
MDC.put("traceId", "a1b2c3");      // 写入
String id = MDC.get("traceId");    // 读取
MDC.remove("traceId");             // 删单个 key
MDC.clear();                       // 清空当前线程全部

Map<String, String> copy = MDC.getCopyOfContextMap(); // 拷贝一份,异步传播常用
MDC.setContextMap(copy);           // 用拷贝恢复上下文
MDC.setContextMap(null);           // 等价于清空(实现相关,建议还是 clear)

还有 MDC.putCloseable(key, val)(部分版本):返回 Closeable,适合 try-with-resources,离开作用域自动 remove

Logback 里怎么打出来

put 不够,还要在 layout / pattern 里声明要输出哪些 key。

文本 pattern

logback-spring.xml

xml
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
  <encoder>
    <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} [traceId=%X{traceId}] [userId=%X{userId}] - %msg%n</pattern>
  </encoder>
</appender>

%X{traceId} 就是读 MDC 的 traceId。没有该 key 时通常输出空字符串,不会报错。

JSON 结构化(更推荐)

生产环境更适合 JSON,便于 ELK / Loki 按字段检索。可用 logstash-logback-encoder

xml
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
  <encoder class="net.logstash.logback.encoder.LogstashEncoder">
    <includeMdcKeyName>traceId</includeMdcKeyName>
    <includeMdcKeyName>userId</includeMdcKeyName>
  </encoder>
</appender>

输出类似:

json
{
  "@timestamp": "2026-08-13T16:00:01.123+08:00",
  "level": "INFO",
  "message": "create order",
  "traceId": "a1b2c3",
  "userId": "10086"
}

Spring Boot 落地:Filter 注入 + 清理

最常见模式:在 Servlet Filter(或 Gateway / Interceptor)入口写入,出口清理。

java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {

  public static final String TRACE_ID = "traceId";
  public static final String HEADER = "X-Request-Id";

  @Override
  protected void doFilterInternal(
      HttpServletRequest request,
      HttpServletResponse response,
      FilterChain chain) throws ServletException, IOException {

    String traceId = request.getHeader(HEADER);
    if (traceId == null || traceId.isBlank()) {
      traceId = UUID.randomUUID().toString().replace("-", "");
    }

    MDC.put(TRACE_ID, traceId);
    response.setHeader(HEADER, traceId);
    try {
      chain.doFilter(request, response);
    } finally {
      MDC.clear();
    }
  }
}

业务代码照常:

java
log.info("pay start, orderId={}", orderId);
// 实际输出已自动带 [traceId=...]

几个约定建议写进团队规范:

  • 优先透传上游 X-Request-Id / W3C traceparent,没有再生成
  • 响应头回写同一 ID,方便联调与客服反馈
  • 必须在 finallyclear——Tomcat / Undertow 工作线程会复用

异步与线程池:MDC 不会"跟着跑"

这是 MDC 使用中最高频的坑

java
// 请求线程里
MDC.put("traceId", "abc");

executor.execute(() -> {
  // 这里往往是空的!换线程了
  log.info("async task");
});

包装 Executor

提交前拷贝,执行时恢复,结束后清理:

java
public class MdcTaskDecorator implements TaskDecorator {

  @Override
  public Runnable decorate(Runnable runnable) {
    Map<String, String> context = MDC.getCopyOfContextMap();
    return () -> {
      Map<String, String> previous = MDC.getCopyOfContextMap();
      if (context != null) {
        MDC.setContextMap(context);
      } else {
        MDC.clear();
      }
      try {
        runnable.run();
      } finally {
        if (previous != null) {
          MDC.setContextMap(previous);
        } else {
          MDC.clear();
        }
      }
    };
  }
}

Spring 线程池示例:

java
@Bean
public ThreadPoolTaskExecutor appExecutor() {
  ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
  executor.setCorePoolSize(8);
  executor.setMaxPoolSize(32);
  executor.setQueueCapacity(500);
  executor.setTaskDecorator(new MdcTaskDecorator());
  executor.initialize();
  return executor;
}

@Async 若走自定义 executor,同样挂上 TaskDecorator

CompletableFuture

java
Map<String, String> context = MDC.getCopyOfContextMap();

CompletableFuture.runAsync(() -> {
  if (context != null) {
    MDC.setContextMap(context);
  }
  try {
    doWork();
  } finally {
    MDC.clear();
  }
}, executor);

常见坑清单

1. 忘记 clear,日志"串请求"
线程池复用时,上一个请求的 traceId / userId 留在 ThreadLocal,下一个请求会打出错误关联。入口 put、出口 clear 成对出现。

2. 子线程、定时任务、MQ 消费者未传播
HTTP Filter 只管 Web 入口。Kafka Listener、@Scheduled、自定义线程都要各自建立或传播上下文。

3. pattern 没配 %X{...}
MDC 有值但日志看不见,先查 layout,再查是不是打到了另一个没配 MDC 的 appender。

4. 键名不统一
有的服务写 traceId,有的写 requestIdtid,跨服务检索就废了。团队定一个名字并坚持。

5. 往 MDC 塞敏感信息
手机号、token、完整身份证进日志等于泄密。只放关联与排障必要的标识,并做脱敏策略。

6. 和 Trace 两套 ID
若已接入 OpenTelemetry / SkyWalking,优先与其 traceId 对齐,避免日志里一个 ID、链路里另一个 ID。

和 ThreadLocal、Trace 的关系

概念 作用 和 MDC 的关系
ThreadLocal 线程本地存储 MDC 的实现基础
MDC 面向日志的诊断上下文 专门给日志框架消费
TraceId / SpanId 分布式追踪标识 常作为 MDC 的字段写入
OpenTelemetry 跨进程上下文传播 可自动把 trace 信息注入 MDC

实践上建议:MDC 负责"日志好看、能滤";OpenTelemetry 负责"跨服务能跟"。 单服务先把 MDC 做稳,多服务再补链路追踪。

最小生产清单

  1. Filter / 拦截器:生成或透传 traceIdMDC.putfinally MDC.clear
  2. Logback pattern 或 JSON encoder 输出 traceId(可选 userIdtenantId
  3. 业务线程池、@Async、MQ 消费线程做 MDC 拷贝与恢复
  4. 键名、请求头名写成规范,前后端、多服务统一
  5. 不把密码、token、隐私明文放进 MDC

小结

MDC 并不神秘:它就是给日志用的、基于 ThreadLocal 的请求级字典。用好三件事就够了——入口写入、布局输出、跨线程传播并清理。

把它做扎实之后,高并发下的"日志乱序"不再靠肉眼翻屏,而是变成按 traceId 一键过滤。若你还在纠结整体排查策略,可以回到这篇总览:高并发服务日志乱序排查最佳实践