链路追踪TraceId传递MDC

wen java案例 1

本文目录导读:

链路追踪TraceId传递MDC

  1. 核心原理:为什么要把 TraceId 塞进 MDC?
  2. 主流框架中的实现方案
  3. MDC 跨线程传递(核心难点)
  4. 最佳实践总结
  5. 关键注意事项(避坑指南)

关于链路追踪中 TraceId 传递到 MDC(Mapped Diagnostic Context,映射诊断上下文),这是实现分布式日志串联的关键步骤,下面我会从核心原理、主流框架实现及最佳实践三个方面来详细说明。

核心原理:为什么要把 TraceId 塞进 MDC?

MDC 是 SLF4J/Logback/Log4j 等日志框架提供的一个线程上下文的容器,它是一个 ThreadLocal 实现的 Map。

  • 目的:将 TraceId 存入 MDC 后,在日志输出模式(PatternLayout)中通过 %X{traceId} 占位符,自动 在每个日志行中打印 TraceId,无需在每个方法中显式传递。
  • 挑战:跨线程(如线程池、异步调用)和跨网络(RPC、HTTP)时,MDC 无法自动传递,需要手动处理。

主流框架中的实现方案

基于 Spring Boot 3.x + Micrometer Tracing(推荐)

这是目前最现代化的方案,Micrometer Tracing 已经内置了 MDC 的支持,并且自动与 OpenTelemetry 集成。

依赖(Maven):

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-integration-test</artifactId>
    <scope>test</scope>
</dependency>

配置(application.yml):

# 开启 MDC 自动注入
logging.pattern.level: "%5p [%X{traceId:-},%X{spanId:-}]"
# 或者自定义格式
# logging.pattern.console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{traceId}] %msg%n"

核心原理: TracingFilterObservationAwareSpanCustomizer 会自动将当前 Trace 的 traceIdspanId 放入 MDC。

优点: 零侵入,同步请求全覆盖;异步任务(如 @Async)需要通过 ExecutorServiceTraceableExecutorService 包装。

基于 Dubbo 微服务(使用 RpcContext)

如果使用 Dubbo 作为 RPC 框架,可以在 Filter 中手动操作 MDC。

Provider 端(服务提供方):

import org.apache.dubbo.rpc.*;
import org.slf4j.MDC;
public class TraceIdProviderFilter implements Filter {
    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        // 从 RpcContext 中获取上游传递的 TraceId
        String traceId = RpcContext.getContext().getAttachment("traceId");
        if (traceId == null) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        MDC.put("traceId", traceId);
        try {
            return invoker.invoke(invocation);
        } finally {
            MDC.remove("traceId"); // 必须清理,防止内存泄漏
        }
    }
}

Consumer 端(服务消费方):

public class TraceIdConsumerFilter implements Filter {
    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        // 将本地的 MDC 传入 RpcContext
        String traceId = MDC.get("traceId");
        if (traceId != null) {
            RpcContext.getContext().setAttachment("traceId", traceId);
        }
        return invoker.invoke(invocation);
    }
}

基于 HTTP RestTemplate / Feign 调用

  • RestTemplate: 通过 ClientHttpRequestInterceptor 添加 Header。
  • Feign: 通过 RequestInterceptor 添加 Header。
@Component
public class FeignTraceInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        String traceId = MDC.get("traceId");
        if (traceId != null) {
            template.header("X-Trace-Id", traceId);
        }
    }
}

Web 端接收 Header 侧(如网关或 Spring MVC Filter):

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String traceId = request.getHeader("X-Trace-Id");
        if (traceId == null || traceId.isEmpty()) {
            traceId = UUID.randomUUID().toString();
        }
        MDC.put("traceId", traceId);
        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.clear(); // 清理,避免线程池复用导致污染
        }
    }
}

MDC 跨线程传递(核心难点)

MDC 默认基于 ThreadLocal,子线程或线程池任务无法直接获取父线程的 MDC。

方案 1:使用 MDC.getCopyOfContextMap()

// 提交任务前获取父线程 MDC 上下文
Map<String, String> contextMap = MDC.getCopyOfContextMap();
executorService.submit(() -> {
    // 在子线程中设置 MDC
    if (contextMap != null) {
        MDC.setContextMap(contextMap);
    }
    try {
        // 执行业务逻辑
        doWork();
    } finally {
        MDC.clear(); // 清理子线程 MDC
    }
});

方案 2:使用 HystrixHystrixConcurrencyStrategy 或自定义线程池包装

对于 Spring 的 @Async,可以自定义 AsyncConfigurer 或使用 ThreadPoolTaskExecutorTaskDecorator

@Bean
public Executor asyncExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(20);
    // 核心:装饰任务,自动传递 MDC
    executor.setTaskDecorator(runnable -> {
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        return () -> {
            try {
                if (contextMap != null) {
                    MDC.setContextMap(contextMap);
                }
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    });
    executor.initialize();
    return executor;
}

方案 3:使用 TransmittableThreadLocal(TTL,阿里巴巴开源)

TTL 可以自动解决线程池复用导致的问题,无需每次都手动拷贝。

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>2.14.5</version>
</dependency>

用法:在创建线程池时包装一下 TtlRunnable 即可。

最佳实践总结

场景 推荐方案
新项目 Spring Boot 3.x 使用 Micrometer Tracing,配置 logging.pattern.level,原生支持 MDC。
传统 Spring Boot + Sleuth 使用 Spring Cloud Sleuth(已进入维护期),它会自动处理。
Dubbo 微服务 使用自定义 RpcContext Filter,上下游通过 attachment 传递。
异步/线程池 使用 TaskDecoratorTTL 包裹任务。
手动配置 使用 Filter/Interceptor 在请求入口处设置和清理 MDC。

关键注意事项(避坑指南)

  1. 必须清理 MDC:无论是 Filter 还是线程任务,finally 块中必须调用 MDC.clear()MDC.remove("traceId"),否则线程池复用会导致日志串号。
  2. 区分 TraceId 与 SpanId
    • traceId:全局唯一的流水号,整个调用链不变。
    • spanId:每个服务或步骤的 ID,会变化。
    • MDC 中建议都放,配合 UI(如 Zipkin、SkyWalking)使用。
  3. 标准 Header 名称:建议使用 W3C 标准 traceparent(格式:00-traceId-spanId-01)或 X-B3-TraceId(Brave 标准),便于与 OpenTelemetry、Zipkin 集成。
  4. 性能影响:MDC 操作(put/remove)是轻量级的,但如果日志量极大(如数百万/秒),频繁的 putclear 也会有一定开销,可考虑在 Filter 层面只对 traceId 操作,不操作整个 contextMap

如果涉及具体的框架集成(如 gRPC、RocketMQ 消息队列、@KafkaListener),需根据其拦截器或监听器机制做类似的处理。

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