MDC案例

wen java案例 2

本文目录导读:

MDC案例

  1. 案例一:Web 应用中的请求追踪(单机同步)
  2. 案例二:异步线程池中的 MDC “丢失”问题(核心难点)
  3. 案例三:微服务之间的链路追踪(传递 Header)
  4. 案例四:低频日志的“性能开关”场景
  5. 案例五:业务隔离(租户系统)
  6. 总结与注意事项

MDC(Mapped Diagnostic Context,映射诊断上下文)是日志框架(如 Log4j、Logback、SLF4J)中用于在多线程或异步环境中,将特定上下文信息(如请求ID、用户ID、SessionID)与日志事件关联起来的一种机制。

它的核心价值在于:当你打印日志时,无需在每一行日志中手动拼接这些公共信息,而是通过MDC自动将它们注入到日志格式中。

下面我将通过几个经典且具有代表性的案例,从单机同步微服务异步再到多线程池,来演示MDC的实际用法和解决方案。


Web 应用中的请求追踪(单机同步)

场景: 一个电商系统,用户发起点餐请求,后端日志杂乱无章,无法筛选出“这个用户这次请求”的全部日志。

目标: 为每个HTTP请求分配一个唯一的 requestId,并打印在日志中。

实现方案:

  1. 声明一个过滤器(Filter),在请求进入时生成 UUID。
  2. 放入 MDC
  3. 在 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。必须通过 TaskDecoratorThreadFactory 或手动传递 Map
父子线程 就算是Java的 ForkJoinPool(并行流),默认也不传递,需要额外处理。
生产建议 日志格式中尽量包含 [%thread][%X{requestId}],这是排查分布式问题的基石。

MDC是日志治理中最廉价但最有效的工具,能极大提升排查问题的效率。

抱歉,评论功能暂时关闭!