💡 一则也许对你有用的小广告 🏆
欢迎飞飞程序员 ,你将获得:专属的实战项目(已更新的所有会员标识的项目都能学习) / 1v1 提问 / Java 学习路线 / PHP 学习路线 / 学习打卡 / 社群讨论
截止目前, 飞飞 正在疯狂爆肝实战项目,后续还会上新更多项目,目标是将所学知识开发成项目并且分享给大家,如知识付费系统, Ai系统, CMS系统,在线商城系统,等等 ,欢迎点击围观
一句话结论:MDC(Mapped Diagnostic Context)是 SLF4J/Logback 提供的线程级“上下文存储”,用 ThreadLocal 把 traceId、userId 等字段绑定到当前线程,日志模板里用 %X{key} 就能自动输出,从而在成千上万行日志中串起同一次请求。
背景:多线程日志为什么难串
一个 Spring Boot 应用里,一次 HTTP 请求会经过 Controller → Service → DAO,可能还会丢进线程池、发 MQ、调下游 RPC。日志是并行刷的,默认格式长这样:
2024-05-20 10:00:01 INFO OrderService - 开始创建订单
2024-05-20 10:00:01 INFO PayService - 扣款成功
2024-05-20 10:00:01 INFO OrderService - 创建订单完成
问题很明显:
- 同一时刻多个用户的日志交织在一起,无法区分哪几行属于同一次请求。
- 出问题时要靠时间戳和肉眼猜,链路一长就废。
- 想在每条日志里带上
traceId,又不想给每个方法都加参数(侵入性太大)。
MDC 就是解决这个的:不改方法签名,也能让日志自动带上下文。
核心结构:MDC 与线程的关系
图中关键点:
- 请求入口(Filter/Interceptor)把
traceId、userId 放进 MDC。
- MDC 底层是
ThreadLocal<Map<String,String>>,只对当前线程可见。
- 业务代码里任何一次
log.info(...),Logback 通过 %X{traceId} 从当前线程的 MDC 取值。
- 请求结束必须
MDC.clear(),否则线程复用会串号。
关键步骤与配置
1. 日志格式里声明占位符
logback-spring.xml 或 application.yml:
logging:
pattern:
console: "%d{HH:mm:ss.SSS} [%thread] [%X{traceId:-}] [%X{userId:-}] %-5level %logger{36} - %msg%n"
%X{traceId:-} 中的 :- 表示 key 不存在时输出空串,避免打出 traceId_IS_UNDEFINED。
2. 在入口写入 MDC
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MdcFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse resp,
FilterChain chain) throws ServletException, IOException {
String traceId = req.getHeader("X-Trace-Id");
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
try {
MDC.put("traceId", traceId);
MDC.put("userId", currentUserId(req));
resp.setHeader("X-Trace-Id", traceId);
chain.doFilter(req, resp);
} finally {
MDC.clear(); // 必须清理,防止线程池串号
}
}
}
3. 异步 / 线程池:MDC 会丢
这是最常见的坑。ThreadLocal 不会自动传递给子线程,@Async、CompletableFuture、ExecutorService 里拿到的 MDC 是空的。
方案一,手动拷贝:
Map<String, String> ctx = MDC.getCopyOfContextMap();
executor.submit(() -> {
if (ctx != null) MDC.setContextMap(ctx);
try {
doSomething();
} finally {
MDC.clear();
}
});
方案二,包装线程池(推荐):
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> ctx = MDC.getCopyOfContextMap();
return () -> {
if (ctx != null) MDC.setContextMap(ctx);
try {
runnable.run();
} finally {
MDC.clear();
}
};
}
}
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(8);
exec.setMaxPoolSize(32);
exec.setTaskDecorator(new MdcTaskDecorator());
exec.initialize();
return exec;
}
}
TaskDecorator 是 Spring 提供的钩子,@Async 线程池会自动应用它。
4. 一次请求的时序
对比:MDC vs 其它方案
| 方案 | 侵入性 | 跨线程 | 跨服务 | 适用场景 |
| 方法参数透传 traceId | 高 | 手动 | 手动 | 不推荐,签名污染 |
| MDC + ThreadLocal | 低 | 需装饰器 | 需透传 Header | 单体 / 单线程链路 |
| MDC + Sleuth/Micrometer Tracing | 低 | 自动 | 自动(W3C/B3) | 微服务全链路追踪 |
| 结构化日志(JSON + 字段) | 低 | 同 MDC | 同 MDC | 接 ELK/Loki 检索 |
要点:MDC 负责“本进程内上下文”,跨进程要靠 Header 透传,Sleuth / Micrometer Tracing 就是在 MDC 之上补了自动透传和采样。
注意点 / 常见坑
- 必须
clear():Tomcat 线程是复用的,不清理下一次请求会带上一次残留的 traceId。
- 线程池会丢:
@Async、CompletableFuture、parallelStream、new Thread 都不继承 MDC,用 TaskDecorator 或手动 getCopyOfContextMap。
- 响应式(WebFlux)不适用:Reactor 一个请求会在多个线程间切换,MDC 的 ThreadLocal 模型失效,要用
Hooks.onEachOperator 或 Reactor Context。
%X 取不到时:如果一次请求没经过 Filter,MDC 是空的,格式里用 %X{traceId:-} 兜底。
- 不要塞大对象:MDC 只是
Map<String,String>,放 JSON、堆栈会污染日志并占用内存。
- MDC 不参与日志级别过滤:
%X 只是渲染值,若想按 traceId 动态改级别,得用 Logback 的 TurboFilter 或 MDCBasedDiscriminator。
小结
MDC 的本质是 SLF4J 暴露的线程级 KV 上下文,配一个 %X{key} 模板就能让日志自动带上 traceId、userId,几乎零侵入。它的边界也很清晰:只在一个线程内有效,只在单个进程内有效。跨线程用 TaskDecorator 补齐,跨服务靠 HTTP Header 透传,再往上就是 Sleuth / Micrometer Tracing 那套全链路方案。理解了 ThreadLocal 这一层,异步丢上下文、响应式失效这些“异常”就不难解释了。