分类:后端开发 · 作者:管理员 · 发布于 2026-10-11 · 阅读 0 · 标签:Spring Boot / TraceId / MDC / 日志追踪 / 链路追踪

痛点:日志都在,但串不起来

一个典型的多服务电商中台里,用户下单要经过网关、订单服务、库存服务、支付服务。线上出了问题,最痛苦的不是没有日志,而是每条日志都是孤岛:订单服务里报了一个空指针,库存服务里报了一个扣减失败,两边时间只差 200 毫秒,但没有任何字段能把它们关联起来。你只能靠时间戳和 IP 去猜,猜错一次,排查时间翻一倍。

解决思路其实很朴素:给每一次请求生成一个全局唯一的 TraceId,并让它在整个调用链路上传递,让所有日志都带上这个 ID。本文不讲 APM 全家桶,只讲用 Spring Boot 自带的 MDC 机制,从零搭一条贯穿 HTTP 请求、异步线程池和跨服务调用的日志链路。

方案设计:MDC + 过滤器 + 线程池装饰器

整体设计分四层:

  • 入口生成:在网关或每个服务的入口过滤器里,从请求头读取上游 TraceId;没有就自己生成 UUID。
  • 日志携带:把 TraceId 写进 SLF4J 的 MDC,日志 pattern 统一输出。
  • 同步透传:调用下游时把 TraceId 放进 HTTP Header,下游继续带。
  • 异步传递:线程池执行任务时,把父线程的 MDC 快照拷贝到子线程。

核心就一句话:MDC 是 ThreadLocal 实现的,链路要跨线程,就必须显式地拷贝和清理。

第一步:入口过滤器生成与回收 TraceId

用 OncePerRequestFilter 保证一次请求只执行一次,无论经过多少层 Filter 转发。关键点:finally 里必须 remove,否则 Tomcat 线程复用会让下一个请求串上别人的 TraceId。

@Component
public class TraceIdFilter extends OncePerRequestFilter {

    public static final String TRACE_ID_HEADER = "X-Trace-Id";
    public static final String TRACE_ID_KEY = "traceId";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String traceId = request.getHeader(TRACE_ID_HEADER);
        if (StringUtils.isBlank(traceId)) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        MDC.put(TRACE_ID_KEY, traceId);
        response.setHeader(TRACE_ID_HEADER, traceId); // 回传给调用方,方便对账
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove(TRACE_ID_KEY); // 必须清理,防止线程复用串号
        }
    }
}

日志配置里加上 traceId:

<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>

第二步:跨服务透传(RestTemplate / Feign / WebClient)

同步调用下游时,把当前线程 MDC 里的 TraceId 塞进请求头。以 RestTemplate 为例,用 ClientHttpRequestInterceptor 全局生效,比每个调用点手动传头靠谱得多:

@Component
public class TraceIdRestInterceptor implements ClientHttpRequestInterceptor {
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                        ClientHttpRequestExecution execution) throws IOException {
        String traceId = MDC.get(TraceIdFilter.TRACE_ID_KEY);
        if (StringUtils.isNotBlank(traceId)) {
            request.getHeaders().set(TraceIdFilter.TRACE_ID_HEADER, traceId);
        }
        return execution.execute(request, body);
    }
}

Feign 场景等价写法是实现 RequestInterceptor,WebClient 则是 ExchangeFilterFunction,思路完全一致:从 MDC 取,往 Header 放。

第三步:异步线程池传递 TraceId(最容易踩坑)

业务里大量使用 @Async、MQ 消费者线程池、定时任务线程池。这些线程和请求线程不是同一个,MDC 默认不会跟着走,子线程里打出来的日志 traceId 全是 null,链路又断了。

解法:给线程池加一个 TaskDecorator,任务执行前拷贝父线程 MDC,执行后清理:

public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        // 提交任务时,父线程的 MDC 快照
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        return () -> {
            if (contextMap != null) {
                MDC.setContextMap(contextMap);
            }
            try {
                runnable.run();
            } finally {
                MDC.clear(); // 防止线程池复用导致 traceId 残留
            }
        };
    }
}

自定义线程池时装配进去:

@Bean("bizThreadPool")
public ThreadPoolTaskExecutor bizThreadPool() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(16);
    executor.setQueueCapacity(200);
    executor.setTaskDecorator(new MdcTaskDecorator());
    executor.setThreadNamePrefix("biz-worker-");
    executor.initialize();
    return executor;
}

踩坑记录

  • 坑 1:线程复用串号。Tomcat 工作线程是复用的,过滤器里只 put 不 remove,第二个请求会沿用第一个请求的 TraceId。排查这类问题的特征是:不同用户的操作日志 traceId 一模一样。修复就是 finally 清理,没有商量余地。
  • 坑 2:异步线程 traceId 为 null。以为装饰器加了就完事,结果发现 @Async 用的是 Spring 默认的执行器,它没有经过自定义线程池。要么替换默认执行器,要么在 @Async 里显式指定 @Async("bizThreadPool")。
  • 坑 3:MQ 消费者丢了链路。消息里没有带 traceId。生产者在发送消息前把 traceId 塞进消息头,消费者消费时读出来放回 MDC,链路才能跨系统续上。RabbitMQ 用 MessagePostProcessor 加 header,消费者在监听器里 MDC.put。
  • 坑 4:网关没透传。网关层如果不用上面的过滤器方案,下游从 Header 里拿不到 traceId,每个服务都各自生成,链路照样断。网关也要做「读 Header → 没有则生成 → 透传下游」三件事。
  • 坑 5:异步回调里 MDC 过期。任务执行完 clear 之后,如果在回调里还想打带 traceId 的日志,需要在回调前重新 put。用 CompletableFuture 时尤其注意。

最佳实践总结

  1. TraceId 格式:建议 32 位无横杠 UUID 或「时间戳 + 随机数」,不要用带特殊字符的格式,避免日志解析和 Header 传递出问题。
  2. 统一约定 Header 名:全链路统一 X-Trace-Id,写进团队规范;网关、服务、MQ 消费者都要复用同一个工具类,别每个服务各写一份。
  3. 清理和生成同样重要:MDC 是 ThreadLocal,凡是手动 put 的地方必须有配套的 remove/clear,否则就是慢性内存泄漏加日志串号。
  4. 先 MDC 再上 APM:MDC 方案零依赖、五分钟接入,能解决 80% 的排查痛点;等规模大了再引入 SkyWalking 或 Micrometer Tracing 做链路可视化,届时 TraceId 透传设计可以直接复用,不冲突。
  5. 排查效率立竿见影:接完之后,日常排错从「翻半小时日志猜关联」变成「按 traceId 一行搞定」,配合日志平台按 traceId 聚合,效率提升一个量级。

链路追踪的本质不是技术炫技,而是把「同一件事」的日志粘在一起。MDC 方案虽然朴素,但它是所有高级观测体系的地基,值得每个后端先把这块做扎实。