Java 线程池中如何正确使用 MDC
Java 线程池中的 MDC 不会可靠地自动传播。提交前捕获快照,执行前设置或清空,finally 恢复原上下文,避免复用线程及 CallerRunsPolicy 下的上下文污染。
一句话回答:以 SLF4J + Logback 为例,MDC 按线程保存,线程池不会可靠地传播调用方上下文。提交任务时捕获 MDC 快照,执行前保存原上下文并设置快照(快照为空时也要清空),在 finally 中恢复原上下文。所有提交入口都使用包装器才会生效。
问题本质
// ❌ 错误示范:在提交线程设置 MDC
MDC.put("requestId", "req-123");
executor.submit(() -> {
log.info("处理任务"); // 日志里没有 requestId——MDC 没跟过来
});
MDC 存在 ThreadLocal 里,submit 时设置的是主线程的 ThreadLocal,工作线程的 MDC 是空的。反过来,工作线程设了 MDC 没清,下一个复用该线程的任务会读到上一个任务的 requestId——日志张冠李戴。
正确姿势:装饰器包装
import java.util.Map;
import org.slf4j.MDC;
public class MdcTaskWrapper {
public static Runnable wrap(Runnable task) {
Map<String, String> context = MDC.getCopyOfContextMap(); // 提交时快照
return () -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
try {
if (context == null) MDC.clear();
else MDC.setContextMap(context);
task.run();
} finally {
if (previous == null) MDC.clear();
else MDC.setContextMap(previous);
}
};
}
}
// 使用
executor.submit(MdcTaskWrapper.wrap(() -> {
log.info("这条日志带上了 requestId");
}));
三个要素缺一不可:捕获提交上下文、覆盖执行上下文、finally 恢复原上下文。示例中的 executor 与 log 由应用提供;Callable 同样在 finally 恢复。提交方请求结束时也要清理自己写入的字段。用两个不同 requestId 连续复用同一工作线程,检查任务日志没有串号;复制 MDC 只传递日志字段,不会自行创建或传播父子 Span。
更省心的现成方案
- TransmittableThreadLocal:传播 TTL 管理的变量;普通日志框架 MDC 不会仅因包装线程池或安装 agent 就自动变成 TTL,需要针对日志后端做桥接并验证。
- Spring 环境:用
TaskDecorator注册到ThreadPoolTaskExecutor,官方支持的扩展点:
executor.setTaskDecorator(MdcTaskWrapper::wrap);
常见问题(FAQ)
Q:MDC.put 之后同一线程后面的日志都有了?
A:正是 ThreadLocal 的"粘性"——忘记 MDC.clear() 或 MDC.remove("key"),线程池场景必然串扰。团队规范:put 与 clear 成对出现在 try/finally 里。
Q:CompletableFuture 链式调用 MDC 也丢?
A:丢。链上的每个 thenApplyAsync 都可能换线程。解法相同:包装执行器传入(supplyAsync(task, ttlExecutor)),并验证每个异步边界;不要为传播日志字段盲目改成同步执行。
Q:MDC 存什么最有价值?
A:requestId/traceId(排障主线)、userId/tenantId(业务维度)、接口路径。别放大对象,字段数量按排障与隐私需要控制,pattern 里 %X{requestId} 输出。
参考资料
本文依据官方资料核对,未进行现场运行测试;代码与配置示例需结合实际版本、权限和环境验证。