在 Spring Boot 项目中排查线上问题时,查看日志是必不可少的步骤。然而,当系统并发量增大或接口调用链路过长时,不同请求的日志混杂在一起,很难快速定位属于某一次特定请求的所有日志。
虽然微服务框架有 Zipkin、OpenTelemetry 等成熟的分布式追踪解决方案,但对于单体或简单的微服务应用,我们可以采用更轻量级的方式:为每个请求分配一个唯一的 traceId。通过这个 traceId,可以轻松地串联起一次请求在系统中的完整执行路径,无论是 HTTP 请求、异步任务还是消息消费,极大地提升了问题排查的效率。
本文将介绍如何利用 Spring Boot 自带的 MDC(Mapped Diagnostic Context)功能来实现 traceId 的透传。MDC 基于 SLF4J/Logback,无需引入额外依赖。文章将涵盖以下典型场景的 traceId 处理:
- HTTP 请求
- MQ(以 RabbitMQ 为例)
- 线程池异步任务
- 定时任务(以 XXL-Job 为例)
MDC实现原理
MDC 是 SLF4J/Logback 提供的一个线程级别的日志上下文存储工具。其内部核心是一个 ThreadLocal<Map<String, String>>,用于保存上下文键值对。
- 当我们在某个线程中执行
MDC.put("traceId", "xxx") 时,这个 traceId 就会被存入当前线程的 ThreadLocal 变量中。
- 日志框架(如 Logback)在输出日志时,会自动从当前线程的 MDC 中读取
traceId 等值,并填充到预设的日志模板中。
- 由于
ThreadLocal 的特性,不同线程的 MDC 上下文是相互隔离的,不会产生干扰。
Logback 配置文件的处理
要让 traceId 出现在日志中,首先需要在 Logback 配置文件中进行相应配置。
控制台输出(开发环境)
修改 logback-spring.xml 文件,在控制台日志输出模式 CONSOLE_LOG_PATTERN 中添加 traceId 的占位符 [traceId:%X{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:7e01902b18ef4c2b9f49609c57d769fa]

文件输出(测试、生产环境)
对于输出到日志文件的 Appender(如 RollingFileAppender),也需要在 JSON 或 Pattern 格式中添加 traceId 字段,以便日志收集系统(如 ELK)能够索引和检索。这里以 JSON 格式为例,在 pattern 部分添加 "traceId": "%X{traceId}"。
全量日志 Appender 配置示例:

错误日志 Appender 配置示例:

HTTP 请求处理
为 HTTP 请求添加 traceId 最直接的方式是使用过滤器(Filter)。我们创建一个 OncePerRequestFilter,在每个请求开始时生成并设置 traceId,在请求结束后清理。
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;
/**
* 日志traceId功能过滤器
*/
@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 {
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put(TRACE_ID, traceId);
filterChain.doFilter(request, response);
} finally {
MDC.remove(TRACE_ID);
}
}
}
线程池异步任务处理
当业务使用线程池执行异步任务时,由于任务会在不同的线程中执行,直接使用 MDC.put 设置的 traceId 无法传递到子线程。我们需要对提交给线程池的 Runnable 或 Callable 任务进行包装,在任务执行前传递父线程的 MDC 上下文,并在执行后清理。
/**
* 异步执行
*
* @param task 任务
*/
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 {
// 恢复线程池线程原来的 MDC,避免影响下一次任务
if (previous != null) {
MDC.setContextMap(previous);
} else {
MDC.clear();
}
}
};
}
在处理多线程和异步编程时,这种上下文传递机制对于保持日志链路的完整性至关重要。
MQ(RabbitMQ)消息处理
对于消息队列,我们需要在消息发送端将当前 traceId 放入消息头,在消费端再从消息头中取出并设置到消费线程的 MDC 中。
消息生产者处理
在发送消息时,通过 MessagePostProcessor 将 traceId 添加到消息的 Header 中。
/**
* 同步发送mq
*/
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);
}
msg.getMessageProperties().getHeaders().put(TRACE_ID, traceId);
return msg;
});
}
消息消费者处理
利用 Spring AMQP 提供的 Advice 机制,我们可以为所有 @RabbitListener 方法添加一个统一的环绕增强,避免在每个 Consumer 方法中重复编写代码。
/**
* 透传MDC的Advice
* sendMq时设置MDC到header中,消费端统一处理
*/
@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);
}
};
}
/**
* 设置自定义的traceIdAdvice到监听器容器工厂
*/
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
ConnectionFactory connectionFactory,
Advice traceIdAdvice) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setAdviceChain(traceIdAdvice);
return factory;
}
通过上述配置,所有基于 @RabbitListener 的消息消费都会自动完成 traceId 的透传,使得与RabbitMQ等消息中间件交互的日志也能被有效追踪。
定时任务(XXL-Job)处理
对于定时任务,我们可以利用 AOP 切面为所有标记了 @XxlJob 注解的方法自动添加 traceId,无需修改业务代码。
/**
* XXL-Job定时任务TraceId切面
*/
@Aspect
@Component
public class XxlJobTraceAspect {
@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);
}
}
private static final String TRACE_ID = "traceId";
}
效果验证
我们可以编写测试接口来验证以上各场景的 traceId 透传效果。
测试接口示例:
@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();
}
请求日志效果:

可以看到,一次 HTTP 请求内部的所有业务日志都带有相同的 traceId。
MQ 消费日志效果:

消息发送端(Controller)和消费端(Consumer)的日志共享同一个 traceId。
线程池异步任务日志效果:

主线程提交的异步任务,其内部日志也成功继承了主线程的 traceId。
定时任务(XXL-Job)日志效果:

每次触发的 XXL-Job 任务都拥有独立的 traceId,方便在日志系统中检索该次任务执行的全部记录。
总结
通过结合 Spring Boot 的 MDC 功能以及对 HTTP、异步任务、消息队列和定时任务等场景的针对性处理,我们实现了一套轻量级但非常实用的全链路日志追踪方案。这套方案能显著提升在复杂调用链路下的问题定位效率,是每个追求可观测性的后台系统应该考虑的基础设施。希望本文的实践对你有所帮助,也欢迎在技术社区交流更多关于系统设计和后端开发的经验。