在微服务架构或高并发的 Spring Boot 项目中,排查问题往往面临一个痛点:当请求量巨大或调用链路复杂时,海量的日志交织在一起,难以区分哪些日志属于同一个请求链路。
虽然 Spring Cloud 生态中有 Zipkin、SkyWalking 等成熟的分布式链路追踪方案,但对于单体应用或轻量级微服务,引入这些组件显得过于厚重。为了解决日志追踪问题,我们可以利用 TraceId(追踪ID)将一次请求全链路的日志串联起来。
本文将深入探讨如何利用 Spring 自带的 MDC(Mapped Diagnostic Context)工具,在不引入额外繁重依赖的情况下,实现以下场景的全链路 TraceId 透传:
MDC 实现原理
MDC 是 SLF4J 和 Logback 提供的一种线程级日志上下文存储机制。其核心原理是利用 ThreadLocal<Map<String, String>> 来保存当前线程的上下文信息。
- 写入:当我们在代码中执行
MDC.put("traceId", "xxx") 时,TraceId 会被存入当前线程的 ThreadLocal Map 中。
- 读取:日志框架(如 Logback)在输出日志时,会自动从 MDC 中查找对应的 Key(如 traceId),并将其填充到日志模板的指定位置。
- 隔离:由于基于 ThreadLocal,不同线程的 MDC 数据是完全隔离的,互不干扰。
Logback 配置文件适配
要让 TraceId 显示在日志中,我们需要修改 Logback 的配置文件,在日志输出格式(Pattern)中添加占位符。
1. 控制台输出(开发环境)
在 logback-spring.xml 文件中,找到控制台日志的 property 配置。我们需要在 pattern 中添加 [traceId:%X{traceId}]。其中 %X{traceId} 即代表从 MDC 中获取名为 traceId 的值。
<property name="CONSOLE_LOG_PATTERN"
value="${DEFAULT_CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} [traceId:%X{traceId}] %clr(${LOG_LEVEL_PATTERN:-%5p})
%clr(${PID:- }){magenta} %clr(---){faint} %clr([%t]){faint} %clr(%-40.40logger{39}){cyan}
%clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
修改后,控制台输出将包含 TraceId,如下图所示:

2. 文件输出(测试/生产环境)
对于输出到文件的日志(通常包含 info 和 error 级别),同样需要在 pattern 标签中添加 TraceId 的配置。例如在 JSON 格式日志中添加 "traceId": "%X{traceId}",或者在普通文本格式中添加 %X{traceId}。


HTTP 请求链路透传
对于 HTTP 请求,最简单的做法是使用过滤器(Filter)。在请求进入时生成或获取 TraceId 并放入 MDC,在请求结束时清理 MDC,防止内存泄漏或污染线程池。
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
/**
* HTTP请求 TraceId 过滤器
*
* @author: Czw
* @create: 2025-11-06 10:31
**/
@Component
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 跳过预检请求(OPTIONS)
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
filterChain.doFilter(request, response);
return;
}
try {
// 实际场景中,优先从 Header 中获取上游传来的 TraceId,若无则生成
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put(TRACE_ID, traceId);
filterChain.doFilter(request, response);
} finally {
// 必须清除,防止线程复用导致 TraceId 混乱
MDC.remove(TRACE_ID);
}
}
}
线程池异步任务透传
由于 MDC 是基于 ThreadLocal 的,子线程无法自动继承父线程的 MDC 上下文。当业务逻辑中使用线程池异步执行任务时,TraceId 会丢失。
解决方案是对 Runnable 和 Callable 进行包装,在任务提交时捕获当前主线程的 MDC 上下文,并在子线程执行任务前将上下文注入,任务结束后清理。
/**
* 异步任务执行器封装
*/
public void execute(Runnable task) {
defaultThreadPoolExecutor.execute(wrap(task, MDC.getCopyOfContextMap()));
}
/**
* 提交带返回值的异步任务
*/
public <T> Future<T> submit(Callable<T> task) {
return defaultThreadPoolExecutor.submit(wrap(task, MDC.getCopyOfContextMap()));
}
/**
* 封装 Runnable,复制 MDC 上下文
*/
private Runnable wrap(Runnable task, Map<String, String> contextMap) {
return () -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
if (contextMap != null) {
MDC.setContextMap(contextMap);
} else {
MDC.clear();
}
try {
task.run();
} finally {
// 恢复子线程原来的 MDC(如果有),避免影响线程池复用
if (previous != null) {
MDC.setContextMap(previous);
} else {
MDC.clear();
}
}
};
}
/**
* 封装 Callable,复制 MDC 上下文
*/
private <T> Callable<T> wrap(Callable<T> task, Map<String, String> contextMap) {
return () -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
if (contextMap != null) {
MDC.setContextMap(contextMap);
} else {
MDC.clear();
}
try {
return task.call();
} finally {
if (previous != null) {
MDC.setContextMap(previous);
} else {
MDC.clear();
}
}
};
}
消息队列(RabbitMQ)透传
MQ 的链路透传分为两步:生产者发送时注入 Header,消费者消费时提取 Header。
1. 生产者发送处理
在发送消息时,从当前 MDC 获取 TraceId,并将其放入消息的 Header 中。
/**
* 发送 MQ 消息,自动携带 TraceId
*/
public <T> void sendMq(MqEnum.TypeEnum typeEnum, MqMessage<T> message) {
rabbitTemplate.convertAndSend(MqEnum.Exchange.EXCHANGE_NAME, typeEnum.getRoutingKey(), message,
msg -> {
String traceId = MDC.get(TRACE_ID);
if (traceId == null) {
traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put(TRACE_ID, traceId);
}
// 将 TraceId 放入消息头
msg.getMessageProperties().getHeaders().put(TRACE_ID, traceId);
return msg;
});
}
2. 消费者统一处理(AOP/Advice)
利用 Spring AMQP 提供的 Advice 机制,可以在消息监听器执行前拦截消息,提取 Header 中的 TraceId 并写入 MDC。这样无需修改具体的 @RabbitListener 业务代码。
/**
* RabbitMQ 消费者切面,用于提取 TraceId
*/
@Bean
public Advice traceIdAdvice() {
return (MethodInterceptor) invocation -> {
Object[] args = invocation.getArguments();
String traceId = null;
for (Object arg : args) {
if (arg instanceof Message message) {
traceId = (String) message.getMessageProperties().getHeaders().get(TRACE_ID);
break;
}
}
if (traceId != null) {
MDC.put(TRACE_ID, traceId);
}
try {
return invocation.proceed();
} finally {
MDC.remove(TRACE_ID);
}
};
}
/**
* 配置 ListenerContainerFactory 以应用 Advice
*/
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
ConnectionFactory connectionFactory,
Advice traceIdAdvice) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setAdviceChain(traceIdAdvice);
return factory;
}
定时任务(XXL-JOB)透传
对于 XXL-JOB 等定时任务,每次执行都是一个新的起点。我们可以利用 AOP 切面拦截 @XxlJob 注解的方法,在任务开始前生成 TraceId。
/**
* XXL-JOB 切面,自动生成 TraceId
*/
@Aspect
@Component
public class XxlJobTraceAspect {
private static final String TRACE_ID = "traceId";
@Pointcut("@annotation(com.xxl.job.core.handler.annotation.XxlJob)")
public void xxlJobMethods() {}
@Around("xxlJobMethods()")
public Object aroundXxlJob(ProceedingJoinPoint joinPoint) throws Throwable {
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put(TRACE_ID, traceId);
try {
return joinPoint.proceed();
} finally {
MDC.remove(TRACE_ID);
}
}
}
最终效果演示
测试接口代码
@GetMapping(value = "/test/traceId/async")
public Result<NullResult> traceId() {
log.info("主 traceId");
// 异步执行
asyncExecutors.execute(() -> log.info("execute traceId"));
// 带返回值的异步执行
asyncExecutors.submit(() -> {
log.info("submit traceId");
return "ok";
});
// 批量任务
List<Runnable> list = new ArrayList<>();
list.add(() -> log.info("execute list traceId"));
asyncExecutors.execute(list);
return Result.buildSuccess();
}
@GetMapping(value = "/test/traceId/mq")
public Result<NullResult> mq() {
log.info("主mq traceId");
MqMessage<String> message = new MqMessage<>();
message.setData(JSON.toJSONString(Collections.emptyList()));
mqSender.sendMq(MqEnum.TypeEnum.PROP_SEND, message);
return Result.buildSuccess();
}
1. HTTP 请求与线程池效果
可以看到,主线程与线程池中的子线程共享了同一个 TraceId。

2. MQ 跨服务透传效果
生产者发送消息时的 TraceId 被消费者成功继承。

3. 复杂线程池场景
四种不同类型的线程池任务均保持了 TraceId 的一致性。

4. 定时任务效果
XXL-JOB 任务启动时自动生成了独立的 TraceId。
