MDC traceId 全链路日志追踪:Spring Boot 3.5 里把日志串成一条线
导读:线上排查问题最烦的就是日志东一条西一条,同一个请求的日志散落在各个线程里,靠时间戳硬猜谁先谁后。我用了 MDC + traceId 把一条链路的所有日志串起来,排查效率直接翻倍。这篇记录我在 qkl-boot 脚手架里的落地过程,以及线程池场景下踩的坑。
先说现象。之前排查一个用户投诉:"我提交了简历,但公司那边说没收到"。我看后端日志,接口调用记录都在,但完全看不出这次提交经历了哪些步骤------服务里既有同步调用又有异步任务,日志混在一起,我只能凭时间戳和关键字去猜。后来加了 traceId,一条链路一个 ID,从入口到出口所有日志都带上它,grep 一下 ID,整条调用链一目了然,问题十分钟定位。

核心思路
MDC(Mapped Diagnostic Context)是 SLF4J 提供的线程上下文容器,本质就是 ThreadLocal。我在每个请求进来时生成一个全局唯一的 traceId 放进去,logback 的 pattern 里输出它,请求结束清掉。这样同一次请求的所有日志都带同一个 ID。
qkl-boot 用的 Spring Boot 3.5 + Java 17,实现起来分三步:过滤器生成 ID、logback 输出、异步线程传递。
第一步:过滤器生成并清理 traceId
用一个 OncePerRequestFilter,请求进来先查有没有上游传过来的 traceId(网关或者调用方带的),没有就自己生成:
java
@Component
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
private static final String TRACE_ID = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String traceId = request.getHeader(TRACE_ID_HEADER);
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put(TRACE_ID, traceId);
response.setHeader(TRACE_ID_HEADER, traceId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove(TRACE_ID); // 必须清理,不然线程池复用会串 traceId
}
}
}
注意 finally 里的 MDC.remove,这行不能省。后面踩坑部分细说。
第二步:logback 输出 traceId
logback-spring.xml 的 pattern 里加上 [%X{traceId}]:
xml
%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} [%X{traceId}] - %msg%n
配完重启,日志长这样:
2026-09-27 10:15:03.123 [http-nio-8080-exec-3] INFO c.q.xxx.ResumeController [a3f2e1c8d9b04f5e] - 收到简历提交请求
2026-09-27 10:15:03.456 [http-nio-8080-exec-3] INFO c.q.xxx.ResumeService [a3f2e1c8d9b04f5e] - 简历解析完成,匹配度 87%
同一个 ID,直接串起来。
第三步:异步线程传递 traceId
问题来了。我把简历解析丢到线程池异步执行后,异步线程里的日志 traceId 是空的。原因很简单:MDC 是 ThreadLocal,新线程拿不到父线程的上下文。
解决方案是给线程池加 TaskDecorator,提交任务时把父线程的 MDC 拷贝进子线程:
java
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("biz-async-");
executor.setTaskDecorator(runnable -> {
Map contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
runnable.run();
} finally {
MDC.clear();
}
};
});
executor.initialize();
return executor;
}
}
用 MDC.getCopyOfContextMap() 拷贝,子线程执行完 MDC.clear(),避免污染线程池里下一个任务。
踩坑记录
坑一:线程池复用导致 traceId 串线
现象:A 请求的 traceId 出现在 B 请求的日志里。排查发现是自定义线程池没做 MDC 清理,线程执行完任务后 MDC 里残留上一个任务的 traceId,下一个任务复用这个线程时读到了旧值。
定位:在日志里 grep 那个诡异的 traceId,发现它横跨了两个完全无关的请求。解决:TaskDecorator 里 finally 必须 MDC.clear(),同时过滤器的 finally 也要 MDC.remove()。两个清理缺一不可。
坑二:异步任务里 MDC 为空
现象:@Async 方法里打日志,[%X{traceId}] 是空的。原因就是默认线程池没有 TaskDecorator。网上很多教程只写了过滤器和 pattern,没提线程池这步,实际项目只要用了 @Async 必然踩。
坑三:logback pattern 里 %X{traceId} 写错变量名
现象:日志里 %X{traceId} 原样输出,没被替换。检查发现是 MDC.put 的 key 和 pattern 里的名字不一致------一个写 traceId 一个写 trace-id。这种低级错误排查了半小时,建议把 key 定义成常量,两处引用同一份。
可直接复用
- 过滤器生成 traceId,
finally里MDC.remove()清理 - logback pattern 加
[%X{traceId}],key 用常量统一管理 - 线程池必须加 TaskDecorator,拷贝父线程 MDC,子线程执行完
clear() - 调用下游服务时在 RestTemplate/Feign 拦截器里透传
X-Trace-Id头,跨服务才能串起来 - 日志量大的时候,把 traceId 建索引或者按天分文件,grep 才快
这套东西看着简单,但"过滤器 + pattern + 线程池传递"三件套缺一个,链路就是断的。qkl-boot 脚手架里已经是完整实现,直接拿来用就行。