MDC日志跟踪

MDC 日志跟踪:多线程环境下的日志上下文管理

在复杂的分布式系统或多线程环境中,一条请求可能经过多个组件、线程甚至服务节点,传统日志往往难以串联整个调用链路。MDC(Mapped Diagnostic Context,映射诊断上下文)通过与线程绑定的上下文信息,为日志添加全局唯一标识(如traceId),实现跨线程、跨服务的日志追踪,是排查分布式问题的关键工具。

MDC 的核心原理

基本概念

MDC 是日志框架(Log4j、Logback、JUL)提供的线程级上下文存储机制,本质是一个与当前线程绑定的哈希表(ThreadLocal<Map<String, String>>,支持在日志中嵌入自定义键值对(如traceIduserId)。

工作机制

  • 线程绑定:MDC 通过ThreadLocal将键值对与当前线程绑定,确保同一线程内的所有日志都能访问这些上下文信息;
  • 日志输出:在日志格式中通过%X{key}占位符引用 MDC 中的值(如%X{traceId}输出追踪 ID);
  • 自动清理:线程结束时需手动清除 MDC 内容,避免线程复用(如线程池)导致的上下文污染。

MDC 的核心 API

MDC 的 API 简单直观,主要包含以下方法(以 SLF4J 为例,不同框架方法一致):

方法 说明
MDC.put(String key, String value) 向当前线程的 MDC 中添加键值对
MDC.get(String key) 获取当前线程 MDC 中指定键的值
MDC.remove(String key) 移除当前线程 MDC 中指定键的值
MDC.clear() 清空当前线程 MDC 中的所有键值对
MDC.getCopyOfContextMap() 复制当前线程的 MDC 上下文(用于传递给子线程)
MDC.setContextMap(Map<String, String>) 为当前线程设置 MDC 上下文(子线程接收父线程上下文时使用)

MDC 实战:分布式追踪中的traceId实现

在分布式系统中,traceId是串联整个调用链路的唯一标识,通过 MDC 可轻松实现全链路日志追踪。

1. 生成与传递traceId

(1)拦截器中初始化traceId

在 Web 应用中,通过拦截器(如 Spring 的HandlerInterceptor)为每个请求生成traceId并放入 MDC:

import org.slf4j.MDC;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;

public class TraceIdInterceptor implements HandlerInterceptor {
    // 定义traceId的键名
    public static final String TRACE_ID_KEY = "traceId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 从请求头获取traceId(若有上游服务传递,则复用)
        String traceId = request.getHeader(TRACE_ID_KEY);
        // 若没有,则生成新的traceId(UUID确保唯一性)
        if (traceId == null || traceId.isEmpty()) {
            traceId = UUID.randomUUID().toString().replaceAll("-", "");
        }
        // 将traceId放入MDC
        MDC.put(TRACE_ID_KEY, traceId);
        // 将traceId放入响应头,便于下游服务获取
        response.setHeader(TRACE_ID_KEY, traceId);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, 
                               Object handler, Exception ex) {
        // 请求处理完成后,清除MDC中的traceId(避免线程池复用导致的污染)
        MDC.remove(TRACE_ID_KEY);
    }
}
(2)注册拦截器(Spring Boot 示例)
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 对所有请求生效
        registry.addInterceptor(new TraceIdInterceptor()).addPathPatterns("/**");
    }
}

2. 配置日志格式输出traceId

在日志配置文件(如 Logback 的logback.xml)中,通过%X{traceId}引用 MDC 中的traceId

<!-- Logback配置示例 -->
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <!-- 日志格式:包含级别、时间、traceId、类名、消息 -->
            <pattern>[%level] %d{yyyy-MM-dd HH:mm:ss.SSS} [traceId=%X{traceId}] %logger{36} - %msg%n</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <root level="info">
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

输出日志示例:

[INFO] 2024-05-20 10:30:00.123 [traceId=123e4567e89b12d3a45645678901234] com.example.UserController - 用户登录成功

3. 子线程中传递 MDC 上下文

MDC 默认基于ThreadLocal,子线程无法直接继承父线程的 MDC 上下文,需手动传递:

import org.slf4j.MDC;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class MdcThreadDemo {
    public static void main(String[] args) {
        // 父线程设置MDC
        MDC.put("traceId", "parent-123");
        
        // 获取父线程的MDC上下文
        Map<String, String> parentMdc = MDC.getCopyOfContextMap();
        
        ExecutorService executor = Executors.newFixedThreadPool(1);
        executor.submit(() -> {
            try {
                // 子线程设置父线程的MDC上下文
                if (parentMdc != null) {
                    MDC.setContextMap(parentMdc);
                }
                // 子线程日志会包含父线程的traceId
                System.out.println("子线程日志:" + MDC.get("traceId"));
            } finally {
                // 子线程结束后清除MDC
                MDC.clear();
            }
        });
        
        executor.shutdown();
        // 父线程清除MDC
        MDC.clear();
    }
}

4. 跨服务传递traceId

在分布式系统中,通过 HTTP 请求头传递traceId,确保跨服务调用时链路连贯:

(1)服务 A 调用服务 B 时,在请求头中添加traceId
import org.slf4j.MDC;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.web.client.RestTemplate;

public class ServiceAClient {
    private RestTemplate restTemplate = new RestTemplate();
    
    public void callServiceB() {
        HttpHeaders headers = new HttpHeaders();
        // 将当前MDC中的traceId放入请求头
        headers.add("traceId", MDC.get("traceId"));
        HttpEntity<String> request = new HttpEntity<>(headers);
        // 调用服务B
        restTemplate.postForObject("http://service-b/api", request, String.class);
    }
}
(2)服务 B 通过拦截器获取请求头中的traceId(同步骤 1 的TraceIdInterceptor

MDC 的注意事项

  1. 线程池复用问题
    线程池中的线程会被复用,若线程结束时未清除 MDC(MDC.clear()),下次复用该线程时会携带旧的上下文信息,导致日志混乱。务必在finally块中清除 MDC
  2. 异步任务传递
    子线程或异步任务(如@Async)需手动传递 MDC 上下文(通过getCopyOfContextMap()setContextMap()),否则无法继承父线程的traceId
  3. 性能影响
    MDC 基于ThreadLocal,操作轻量,对性能影响可忽略,但避免存储大量键值对(仅保留必要的追踪信息,如traceIduserId)。
  4. 框架兼容性
    主流日志框架(Logback、Log4j2)均支持 MDC,SLF4J 提供统一 API,切换日志实现时无需修改 MDC 代码。

MDC 的扩展应用

除了traceId,MDC 还可用于记录其他上下文信息:

  • userId:记录当前操作的用户 ID,便于定位用户相关问题;
  • requestId:区分同一traceId下的不同请求(如批量操作);
  • serviceName:微服务名,便于跨服务日志筛选。

总结

MDC 通过与线程绑定的上下文机制,为日志添加全局追踪标识(如traceId),完美解决了多线程和分布式系统中的日志串联问题。核心步骤包括:

  1. 在请求入口(如拦截器)生成并设置traceId
  2. 配置日志格式输出traceId
  3. 跨线程 / 服务传递 MDC 上下文;
  4. 线程结束时清除 MDC,避免污染