Spring Boot 全链路日志追踪实战——TraceId 贯穿微服务的完整方案
Spring Boot 全链路日志追踪实战——TraceId 贯穿微服务的完整方案
适读人群:有微服务开发经验、需要解决分布式系统日志追踪问题的工程师 | 阅读时长:约18分钟 | 核心价值:从零实现一套 TraceId 全链路追踪方案,覆盖 HTTP、异步线程、消息队列三个传递场景
一、找 BUG 找到崩溃的那一次
某同学跟我说过他的一次血泪经历。他们公司微服务架构,某天用户反馈下单失败,他需要排查原因。系统有 5 个服务:网关 → 订单服务 → 库存服务 → 支付服务 → 通知服务。
他打开 Kibana,开始找日志。问题来了:每个服务都有几千条日志,它们只有时间戳作为关联依据,没有任何统一的请求标识。他只知道用户下单时间是下午 2:35,于是开始对比五个服务在 2:34~2:36 之间的日志,试图根据时间顺序和参数匹配出一条完整的调用链路。
找了将近两个小时,还是没有 100% 确认,因为那两分钟同时有上百个请求,日志完全混在一起,根本分不清哪条是这个用户的请求。
这就是没有链路追踪的代价。如果每个请求都有唯一的 TraceId,一行命令就能把整条链路的日志捞出来:grep "traceId=abc123" *.log,5 秒解决。
这篇文章我把 TraceId 全链路追踪从零实现,覆盖同步 HTTP 调用、异步线程、消息队列三个传递场景。
二、方案设计
请求进入网关
↓ 生成 TraceId(如果请求头里没有),放入请求头 X-Trace-Id
服务 A 接收请求
↓ 从请求头取 TraceId,放入 MDC
↓ 所有日志自动带上 TraceId(logback 配置)
↓ 调用服务 B 时,把 TraceId 放入 HTTP 请求头
↓ 发 MQ 消息时,把 TraceId 放入消息头
↓ 使用 @Async 异步任务时,把 TraceId 传递到子线程
服务 B 接收请求
↓ 同服务 A 的处理流程三、实现 TraceId 上下文
3.1 TraceId 工具类
package com.example.trace;
import org.slf4j.MDC;
import java.util.UUID;
/**
* TraceId 工具类。
* 使用 SLF4J 的 MDC(Mapped Diagnostic Context)存储 TraceId,
* MDC 本质上是一个 ThreadLocal<Map>,当前线程的所有日志都能自动带上 MDC 里的值。
*/
public class TraceContext {
public static final String TRACE_ID_KEY = "traceId";
public static final String TRACE_HEADER = "X-Trace-Id";
/**
* 生成并设置新的 TraceId。
*/
public static String generateAndSet() {
String traceId = generateTraceId();
set(traceId);
return traceId;
}
/**
* 设置指定的 TraceId 到当前线程。
*/
public static void set(String traceId) {
MDC.put(TRACE_ID_KEY, traceId);
}
/**
* 获取当前线程的 TraceId。
*/
public static String get() {
return MDC.get(TRACE_ID_KEY);
}
/**
* 清除当前线程的 TraceId(线程归还到线程池时必须清除)。
*/
public static void clear() {
MDC.clear();
}
/**
* 生成 TraceId:取 UUID 去掉横线,共 32 字符。
* 实际生产中可以用雪花算法 ID,更短且包含时间信息。
*/
public static String generateTraceId() {
return UUID.randomUUID().toString().replace("-", "");
}
}3.2 Spring MVC 拦截器——HTTP 请求入口
package com.example.trace;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* TraceId 拦截器:在请求进入时设置 TraceId,请求结束时清除。
* 如果请求头里有 X-Trace-Id(来自上游服务),使用上游的 TraceId,保持链路连通。
* 如果没有(请求来自外部用户),生成新的 TraceId。
*/
@Component
public class TraceInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull Object handler) {
// 从请求头获取 TraceId(来自上游服务)
String traceId = request.getHeader(TraceContext.TRACE_HEADER);
if (!StringUtils.hasText(traceId)) {
// 没有则生成新的
traceId = TraceContext.generateTraceId();
}
TraceContext.set(traceId);
// 把 TraceId 放入响应头,方便前端或调用方记录
response.setHeader(TraceContext.TRACE_HEADER, traceId);
return true;
}
@Override
public void afterCompletion(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull Object handler, Exception ex) {
// 请求结束,清除 MDC,防止 TraceId 污染后续请求(线程池线程复用)
TraceContext.clear();
}
}注册拦截器:
package com.example.config;
import com.example.trace.TraceInterceptor;
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 WebMvcConfig implements WebMvcConfigurer {
private final TraceInterceptor traceInterceptor;
public WebMvcConfig(TraceInterceptor traceInterceptor) {
this.traceInterceptor = traceInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(traceInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/actuator/**", "/static/**");
}
}3.3 logback 配置——日志自动带 TraceId
<!-- logback-spring.xml -->
<configuration>
<springProperty scope="context" name="appName" source="spring.application.name"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- %X{traceId} 从 MDC 里取 TraceId,自动附加到每行日志 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{50} - %msg%n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/logs/${appName}/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/logs/${appName}/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<!-- JSON 格式方便 Kibana/ELK 解析 -->
<pattern>{"time":"%d{yyyy-MM-dd HH:mm:ss.SSS}","level":"%-5level","traceId":"%X{traceId}","thread":"%thread","logger":"%logger{50}","msg":"%msg"}%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>四、异步线程中的 TraceId 传递
这是最容易被遗漏的场景。@Async 或 ThreadPoolExecutor 提交的任务在新线程里执行,MDC(ThreadLocal)里的 TraceId 不会自动传递。
package com.example.trace;
import org.slf4j.MDC;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.Map;
import java.util.concurrent.Callable;
/**
* 支持 MDC 传递的线程池 TaskExecutor。
* 继承 ThreadPoolTaskExecutor,重写任务提交方法,在提交时捕获当前 MDC,
* 在任务执行时恢复 MDC,执行完清除。
*/
public class MdcAwareTaskExecutor extends ThreadPoolTaskExecutor {
@Override
public void execute(Runnable task) {
// 提交任务时,捕获当前线程的 MDC 快照
Map<String, String> mdcSnapshot = MDC.getCopyOfContextMap();
super.execute(() -> {
try {
// 执行前,恢复 MDC
if (mdcSnapshot != null) {
MDC.setContextMap(mdcSnapshot);
}
task.run();
} finally {
// 执行后,清除 MDC,防止污染线程池的下一个任务
MDC.clear();
}
});
}
@Override
public <T> java.util.concurrent.Future<T> submit(Callable<T> task) {
Map<String, String> mdcSnapshot = MDC.getCopyOfContextMap();
return super.submit(() -> {
try {
if (mdcSnapshot != null) {
MDC.setContextMap(mdcSnapshot);
}
return task.call();
} finally {
MDC.clear();
}
});
}
}在 AsyncConfig 里使用 MdcAwareTaskExecutor 代替 ThreadPoolTaskExecutor:
@Bean("defaultAsyncExecutor")
public Executor getAsyncExecutor() {
MdcAwareTaskExecutor executor = new MdcAwareTaskExecutor(); // 替换这里
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("async-default-");
executor.initialize();
return executor;
}五、HTTP 调用链传递 TraceId
服务 A 调用服务 B 时,需要把 TraceId 放入 HTTP 请求头:
package com.example.trace;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
/**
* RestTemplate 拦截器:自动在 HTTP 请求头里附加 TraceId。
* 注册到 RestTemplate 后,所有出去的 HTTP 请求都会自动携带 TraceId。
*/
public class TraceRestTemplateInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String traceId = TraceContext.get();
if (traceId != null) {
request.getHeaders().add(TraceContext.TRACE_HEADER, traceId);
}
return execution.execute(request, body);
}
}六、踩坑实录
坑1:线程池线程复用导致 TraceId 污染
现象:日志里某些请求的 TraceId 和另一个请求的 TraceId 混在一起,明明是两个不同的请求。
原因:请求结束后没有调用 TraceContext.clear(),导致线程 A 的 TraceId 还留在 MDC 里,线程池把这个线程拿给请求 B 复用时,请求 B 的日志里就带了请求 A 的 TraceId。这个坑我也踩过,排查时以为是日志系统的 bug,其实是 MDC 没清。
解法:在 HandlerInterceptor.afterCompletion() 里强制清除 MDC,以及在 MdcAwareTaskExecutor 的 finally 块里清除,两个地方都要确保清除。
坑2:Spring Cloud Gateway 不走 Spring MVC 的拦截器
现象:网关层的 TraceId 注入没有生效,后续服务收不到 TraceId。
原因:Spring Cloud Gateway 基于 WebFlux,不走 Spring MVC 的 HandlerInterceptor。需要用 WebFilter 来处理。
解法:在网关里实现 WebFilter,在响应式链路里注入 TraceId 到请求头。
坑3:MQ 消费者没有传递 TraceId
现象:通过 MQ 触发的消费逻辑,日志里的 TraceId 是空的,无法追踪完整链路。
原因:消息生产者发消息时没有把 TraceId 放入消息头,消费者也没有读取消息头里的 TraceId。
解法:发消息时主动附加 TraceId 到消息头,消费者在处理消息的第一行读取消息头里的 TraceId 并设置到 MDC:
// 生产者
message.getHeaders().put(TraceContext.TRACE_HEADER, TraceContext.get());
// 消费者
@RabbitListener(queues = "order.queue")
public void onMessage(Message message) {
String traceId = (String) message.getMessageProperties()
.getHeaders().getOrDefault(TraceContext.TRACE_HEADER,
TraceContext.generateTraceId());
TraceContext.set(traceId);
try {
// 处理消息
} finally {
TraceContext.clear();
}
}七、完整方案总结
这套 TraceId 方案覆盖了三个传递场景:
- HTTP 入口:拦截器生成/接收 TraceId,放入 MDC
- 异步线程:MdcAwareTaskExecutor 在任务提交时快照 MDC,执行时恢复
- MQ 消费:生产者在消息头里携带 TraceId,消费者读取并设置
日志格式统一输出 traceId 字段,配合 ELK 或 Loki,一行命令就能检索整条调用链路的所有日志,排查问题从小时级变成分钟级。
