什么是 MDC
MDC 全称 Mapped Diagnostic Context(映射诊断上下文),是 SLF4J 提供的一套线程绑定的键值上下文。你可以在请求入口往里面放 traceId、userId 等字段,后续任意位置打日志时,日志框架会自动把这些字段拼进输出——业务代码不用每行手传。
一句话理解:
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(...),避免线程池复用时脏数据串到下一个请求
关键推论:
- 同一线程内处处可读,无需方法参数层层传递
- 换线程就丢:线程池、
@Async、CompletableFuture、Reactor 默认不会自动带上 MDC - 存的是
String(或会被转成字符串),别塞大对象
这和"手动在每条日志前拼 [traceId=xxx]"效果类似,但集中管理、不易漏、可统一改 pattern。
基本 API
SLF4J 的 org.slf4j.MDC 常用方法:
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:
<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:
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
</encoder>
</appender>输出类似:
{
"@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)入口写入,出口清理。
@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();
}
}
}业务代码照常:
log.info("pay start, orderId={}", orderId);
// 实际输出已自动带 [traceId=...]几个约定建议写进团队规范:
- 优先透传上游
X-Request-Id/ W3Ctraceparent,没有再生成 - 响应头回写同一 ID,方便联调与客服反馈
- 必须在
finally里clear——Tomcat / Undertow 工作线程会复用
异步与线程池:MDC 不会"跟着跑"
这是 MDC 使用中最高频的坑。
// 请求线程里
MDC.put("traceId", "abc");
executor.execute(() -> {
// 这里往往是空的!换线程了
log.info("async task");
});包装 Executor
提交前拷贝,执行时恢复,结束后清理:
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 线程池示例:
@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
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,有的写 requestId、tid,跨服务检索就废了。团队定一个名字并坚持。
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 做稳,多服务再补链路追踪。
最小生产清单
- Filter / 拦截器:生成或透传
traceId→MDC.put→finally MDC.clear - Logback pattern 或 JSON encoder 输出
traceId(可选userId、tenantId) - 业务线程池、
@Async、MQ 消费线程做 MDC 拷贝与恢复 - 键名、请求头名写成规范,前后端、多服务统一
- 不把密码、token、隐私明文放进 MDC
小结
MDC 并不神秘:它就是给日志用的、基于 ThreadLocal 的请求级字典。用好三件事就够了——入口写入、布局输出、跨线程传播并清理。
把它做扎实之后,高并发下的"日志乱序"不再靠肉眼翻屏,而是变成按 traceId 一键过滤。若你还在纠结整体排查策略,可以回到这篇总览:高并发服务日志乱序排查最佳实践。