MDC traceId 全链路日志追踪:Spring Boot 3.5 里把日志串成一条线

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 脚手架里已经是完整实现,直接拿来用就行。

项目源码:https://gitee.com/gzqkl/qkl-boot

相关推荐
考虑考虑11 小时前
JDK26中的Map.ofLazy
java·后端·java ee
云浪12 小时前
Go 并发实战:从零实现一个高并发安全的任务调度器
javascript·后端·go
苏三的开发日记13 小时前
Docker 启动顺序优化(CentOS / Ubuntu)
后端
高频因子挖掘机14 小时前
同一交易日出现重复 K 线:去重前先确认时间、来源和粒度
后端·github·api
鶴哥只手遮天14 小时前
Spring AI 2.0.1 深度解析-第 14 章 MCP 服务端(上):注解式实现与工具来源
java·人工智能·开源
鶴哥只手遮天14 小时前
Spring AI 2.0.1 深度解析-第 15 章 MCP 服务端(下):三种协议形态、Streamable 传输与状态管理
java·人工智能·开源
鶴哥只手遮天14 小时前
Spring AI 2.0.1 深度解析-第 16 章 向量存储(上):`VectorStore` 接口体系、`BatchingStrategy`
java·人工智能·开源
鶴哥只手遮天14 小时前
Spring AI 2.0.1 深度解析-第 12 章 工具循环的流式形态、Usage 累加与扩展体系
java·人工智能·开源
鶴哥只手遮天15 小时前
Spring AI 2.0.1 深度解析-第 23 章 模型供应商实现:从 `OpenAiSetup` 到 Bedrock
java·人工智能·开源
鶴哥只手遮天15 小时前
Spring AI 2.0.1 深度解析-第 06 章 内置 Advisor 全景:13 个实现逐个剖到源码级
java·人工智能·开源