本文目录导读:

- 案例一:Web 应用中的请求追踪(单机同步)
- 案例二:异步线程池中的 MDC “丢失”问题(核心难点)
- 案例三:微服务之间的链路追踪(传递 Header)
- 案例四:低频日志的“性能开关”场景
- 案例五:业务隔离(租户系统)
- 总结与注意事项
MDC(Mapped Diagnostic Context,映射诊断上下文)是日志框架(如 Log4j、Logback、SLF4J)中用于在多线程或异步环境中,将特定上下文信息(如请求ID、用户ID、SessionID)与日志事件关联起来的一种机制。
它的核心价值在于:当你打印日志时,无需在每一行日志中手动拼接这些公共信息,而是通过MDC自动将它们注入到日志格式中。
下面我将通过几个经典且具有代表性的案例,从单机同步到微服务异步再到多线程池,来演示MDC的实际用法和解决方案。
Web 应用中的请求追踪(单机同步)
场景: 一个电商系统,用户发起点餐请求,后端日志杂乱无章,无法筛选出“这个用户这次请求”的全部日志。
目标: 为每个HTTP请求分配一个唯一的 requestId,并打印在日志中。
实现方案:
- 声明一个过滤器(Filter),在请求进入时生成 UUID。
- 放入 MDC。
- 在 finally 中移除,防止内存泄漏(因为Tomcat线程是复用的)。
代码演示(Spring Boot + Logback):
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;
@Component
public class MDCFilter implements Filter {
public static final String REQUEST_ID = "requestId";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 1. 生成请求ID
String requestId = UUID.randomUUID().toString().replace("-", "");
// 2. 放入MDC
MDC.put(REQUEST_ID, requestId);
try {
// 3. 放行请求
chain.doFilter(request, response);
} finally {
// 4. 必须移除,避免线程复用导致上下文错乱
MDC.remove(REQUEST_ID);
}
}
}
日志配置(Logback pattern):
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId}] %-5level %logger{50} - %msg%n</pattern>
效果: 在日志文件中,你会看到:
2023-10-01 10:15:30.123 [http-nio-8080-exec-3] [f3a9b2c1d4e5] INFO com.example.OrderService - User login success
2023-10-01 10:15:30.456 [http-nio-8080-exec-3] [f3a9b2c1d4e5] DEBUG com.example.OrderService - Query DB
2023-10-01 10:15:30.789 [http-nio-8080-exec-3] [f3a9b2c1d4e5] INFO com.example.OrderService - Order created
你可以直接用 grep 'f3a9b2c1d4e5' app.log 拉出这个请求的完整链路。
异步线程池中的 MDC “丢失”问题(核心难点)
场景: 上面的服务接到订单后,需要发送短信通知,通常我们会使用 @Async 注解或 ExecutorService 处理,子线程中的 MDC.get("requestId") 返回 null。
问题原因: MDC 底层使用 ThreadLocal,数据是线程私有的,子线程不会继承父线程的 ThreadLocal 数据。
解决方案: 使用 装饰器模式 包装任务,显式传递MDC上下文。
代码演示(自定义 TaskDecorator):
import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 1. 获取主线程的MDC上下文快照
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
// 2. 在子线程中设置MDC
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
try {
// 3. 执行原任务
runnable.run();
} finally {
// 4. 清理子线程MDC,避免线程池复用污染
MDC.clear();
}
};
}
}
配置(线程池配置):
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
// 注入我们的装饰器!
executor.setTaskDecorator(new MdcTaskDecorator());
executor.initialize();
return executor;
}
}
效果:
即使使用 @Async 发送短信,日志中依然会带上主线程的 requestId,实现了跨线程的链路追踪。
微服务之间的链路追踪(传递 Header)
场景: 服务A调用服务B(Feign/RestTemplate),如果B打印日志,无法知道B的这次处理是由“A的哪个requestId”触发的。
目标: 将MDC中的 traceId 放入HTTP请求头,传到下游服务;下游服务读取Header并放入自己的MDC。
实现方案(拦截器):
上游服务(服务A)— 发送请求时注入Header:
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
@Component
public class FeignRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 假设你的MDC key 叫 traceId
String traceId = MDC.get("traceId");
if (traceId != null) {
template.header("X-Trace-Id", traceId);
} else {
// 如果是非HTTP入口(如MQ消费者),则生成新的
template.header("X-Trace-Id", UUID.randomUUID().toString());
}
}
}
下游服务(服务B)— 接收请求时读取Header:
在服务B的拦截器(如 HandlerInterceptor)中:
import org.slf4j.MDC;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class TraceIdInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null || traceId.isEmpty()) {
// 如果上游没传,则生成新的
traceId = UUID.randomUUID().toString();
}
MDC.put("traceId", traceId);
return true;
}
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
// 请求结束后清理,防止线程池串号
MDC.remove("traceId");
}
}
低频日志的“性能开关”场景
场景: 某个接口调用量巨大,但只有特定用户(内测用户、VIP会员)或特定订单号需要打印DEBUG级别的详细报文,生产环境是INFO级别。
解决方案: 结合MDC + 动态日志级别。
代码演示:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
public class OrderService {
private static final Logger logger = LoggerFactory.getLogger(OrderService.class);
public void processOrder(String orderId, boolean isVIP) {
if (isVIP) {
// 将某个标志放入MDC
MDC.put("userType", "VIP");
}
try {
// 正常业务逻辑
// 这里打DEBUG日志
logger.debug("Processing order: {}", orderId);
// ... 业务处理
} finally {
MDC.remove("userType");
}
}
}
Logback 配置(使用过滤器实现动态级别):
<turboFilter class="ch.qos.logback.classic.turbo.MDCValueLevelFilter">
<!-- 当MDC中 userType 为VIP时,将该日志等级降为DEBUG打印 -->
<Key>userType</Key>
<Value>VIP</Value>
<Level>DEBUG</Level>
</turboFilter>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
业务隔离(租户系统)
场景: “多租户”SaaS系统,两个租户(如A公司、B公司)同时使用,他们的日志混在一起。
解决方案:
在登录或鉴权后,将 tenantId 放入MDC:
MDC.put("tenantId", user.getTenantId());
在日志配置中加入租户前缀:
<pattern>%X{tenantId} | %d{HH:mm:ss.SSS} | %-5level | %msg%n</pattern>
这样,运维人员可以很容易地通过脚本将不同租户的日志分离到不同文件,便于审计和排查。
总结与注意事项
| 关键点 | 解释 |
|---|---|
| 核心原理 | 基于 ThreadLocal,线程私有。 |
| 必须清理 | 在线程复用(Tomcat/线程池)场景下,必须在 finally 中 MDC.remove() 或 MDC.clear(),否则会导致请求ID串号(A用户请求的日志出现在B用户的日志里)。 |
| 异步传递 | 子线程不会自动继承MDC。必须通过 TaskDecorator、ThreadFactory 或手动传递 Map。 |
| 父子线程 | 就算是Java的 ForkJoinPool(并行流),默认也不传递,需要额外处理。 |
| 生产建议 | 日志格式中尽量包含 [%thread] 和 [%X{requestId}],这是排查分布式问题的基石。 |
MDC是日志治理中最廉价但最有效的工具,能极大提升排查问题的效率。