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

相关推荐
泡海椒1 小时前
JQuick-Excel 项目定位与适用场景:声明式 Java Excel 的边界
java·开发语言·excel
明月_清风1 小时前
干了 6 年前端,我是怎么一步步转型到 AI 的?
前端·后端·ai编程
L@ncor1 小时前
第六章可能出现的问题:依赖冲突与成本乘法
python·依赖管理·langgraph·agentscope·避坑
AC赳赳老秦1 小时前
财报附注表格精准提取:OpenClaw 从 PDF 年报附注挖掘隐藏明细,补齐财务分析维度
java·汇编·c++·python·青少年编程·deepseek·openclaw
顽疲1 小时前
Java vue 养老系统源码详解:楼宇床位 care_space 与房态图 Spring Boot 实践
后端
小小张说故事1 小时前
CatBoost 入门指南:类别特征为什么不用 One-Hot?Python 实战与 5 个坑
python·机器学习
箓维1 小时前
线程的优缺点,与进程的关联和差异
java·服务器·笔记
曹牧1 小时前
Spring MVC:@RequestMapping
java·spring·mvc
Lyy1 小时前
DevOps平台 — 第十三篇:规划的设计与实现
后端·devops