异步调用链里 traceId 为什么会串?我踩过的上下文传递坑和修法

在异步调用链里 traceId 串了,九成是因为你把“线程局部变量”当成了“请求上下文”在用。线程一切换,ThreadLocal 里的值要么丢了,要么被复用线程上的旧值污染,日志自然就串到别的请求上去了。这个问题不是玄学,是上下文传递机制选错了载体。

下面我会把异步场景下 traceId 串掉的几种典型路径拆开讲,包括线程池复用、CompletableFuture 切换、响应式流线程跳跃,以及跨服务时 header 没传对的情况。每一种我都会给出能直接落地的修法,以及为什么那样修能稳住。

线程池复用是串 traceId 的最大来源

先说一个我实际排查过的案例:一个下单接口内部用线程池并发调库存和优惠券服务,压测时发现日志里 traceId 经常对不上,A 请求的库存日志里混着 B 请求的 traceId。查了半小时,问题出在 MDC(Mapped Diagnostic Context)上——MDC 底层就是 ThreadLocal,而线程池里的线程是复用的。

场景是这样的:请求 A 进入线程 1,把 traceId 放进 MDC,提交任务到线程池后返回。线程池里的 worker 线程 2 执行完 A 的任务,MDC 里还残留着 A 的 traceId。紧接着请求 B 的任务被线程 2 捡起来执行,如果任务开头没有重新设置 traceId,线程 2 的 MDC 里就还是 A 的值。于是 B 的日志全打成了 A 的 traceId,甚至可能出现 A 和 B 的日志在同一个 traceId 下交错出现。

修法不是“记得在每个任务开头 put 一次”这么简单,因为人总会忘。正确的做法是在线程池层面做上下文透传,让 traceId 随任务一起进入线程池,任务结束再清理。Java 里最直接的方案是用 TransmittableThreadLocal(TTL),阿里开源的 transmittable-thread-local 库,当前版本 2.14.x。它专门解决“线程池复用导致 ThreadLocal 值丢失或串值”的问题。

用法是两步:先把 MDC 的适配器注册给 TTL,再用 TTL 的线程池包装器包住原有线程池:

import com.alibaba.ttl.threadpool.TtlExecutors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

ExecutorService rawPool = Executors.newFixedThreadPool(8);
ExecutorService ttlPool = TtlExecutors.getTtlExecutorService(rawPool);

// 提交任务前,MDC 里有 traceId
MDC.put("traceId", "trace-123");
ttlPool.execute(() -> {
    // 这里 MDC 里能拿到 trace-123,且不会被其他请求污染
    log.info("async task running");
});

关键在 TtlExecutors.getTtlExecutorService:它在提交任务时捕获当前线程的 TTL 值,在任务执行前恢复到 worker 线程,执行后再清理。这样即使 worker 线程被复用,也不会把上一个请求的 traceId 漏给下一个任务。我建议把 MDC 适配器在应用启动时注册一次:

import com.alibaba.ttl.TransmittableThreadLocal;
import org.slf4j.MDC;

TransmittableThreadLocal<String> ttlTraceId = new TransmittableThreadLocal<>();
// 注册 MDC 适配器,让 TTL 的捕获/恢复动作联动 MDC

实际上 SLF4J 的 MDC 和 TTL 的配合有官方适配:com.alibaba.ttl.threadpool.agent.TtlAgent 或者直接引入 transmittable-thread-local 后,用 TtlMDCAdapter 替换默认 MDC 适配器。具体是启动参数加 -javaagent:transmittable-thread-local-2.14.4.jar 或者代码里 MDC.getMDCAdapter() 强制替换。我个人更倾向 javaagent 方案,少写代码,升级也省心。

CompletableFuture 的链式切换比线程池更隐蔽

线程池的问题好理解,因为线程切换是显式的。但 CompletableFuture 的坑在于:你经常不知道当前代码跑在哪个线程上。比如下面这段:

CompletableFuture.supplyAsync(() -> {
    log.info("step 1");  // 在 ForkJoinPool 线程,MDC 可能没有 traceId
    return fetchData();
}).thenApply(data -> {
    log.info("step 2");  // 可能在同一线程,也可能换了线程
    return process(data);
});

如果 supplyAsync 没有指定线程池,默认走 ForkJoinPool.commonPool(),那个池子里的线程跟请求线程毫无关系,MDC 里的 traceId 根本带不过去。thenApply 更麻烦:如果前一个 future 已经完成,它会直接在调用线程执行;如果未完成,则在完成前一个 future 的线程执行。两种路径的线程可能完全不同,traceId 时有时无。

修法有两个层面。第一层是显式用 TTL 包装过的线程池来执行异步任务:

ExecutorService ttlPool = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(8));

CompletableFuture.supplyAsync(() -> {
    log.info("step 1");  // traceId 正确透传
    return fetchData();
}, ttlPool).thenApplyAsync(data -> {
    log.info("step 2");  // 注意要用 thenApplyAsync 并指定同一个 ttlPool
    return process(data);
}, ttlPool);

第二层是避免 thenApply 这种“可能同步可能异步”的方法,统一用 thenApplyAsync 并显式传线程池。这样执行线程就是确定的,TTL 包装器能在每次任务执行前恢复上下文。如果你用的是 Spring 的 @Async,同理要自定义 AsyncConfigurer 把线程池换成 TTL 包装过的。

不过说实话,CompletableFuture 链一旦长起来,每个阶段都要记得传同一个线程池,代码会变得很啰嗦。如果你的项目里异步调用链超过两三层,我建议直接用 TtlRunnable.get() 包裹整个任务,或者用 TTL 的 javaagent 模式全局自动增强。javaagent 模式对 CompletableFuture 的 runAsync/supplyAsync 也能生效,省掉逐个传线程池的麻烦。

响应式编程里 traceId 串掉的根因是“线程跳跃”

WebFlux 或 Reactor 的异步链,traceId 串的问题比线程池更复杂。原因是响应式流的执行线程在订阅时才会确定,而且 flatMappublishOn 等操作符会切换线程。如果你在 Controller 入口把 traceId 放进 ThreadLocal,进入响应式链后线程可能已经换了,后续日志里 traceId 就没了;更糟糕的是,如果响应式链在多个请求之间共享同一个线程,ThreadLocal 里的旧值会被误读。

修法核心是:响应式编程不能用 ThreadLocal 存上下文,要用 Reactor Context。Reactor 的 Context 是随响应式链传递的,不依赖线程。

在 Spring WebFlux 里,traceId 通常由 Spring Cloud Sleuth(现在叫 Micrometer Tracing)自动放进 Reactor Context。如果你自己写过滤器或拦截器,要这样手动放:

Mono.just("hello")
    .contextWrite(ctx -> ctx.put("traceId", "trace-123"))
    .flatMap(msg -> {
        // 从 Context 里拿 traceId,而不是从 ThreadLocal
        return Mono.deferContextual(ctx -> {
            String traceId = ctx.get("traceId");
            log.info("traceId in reactive chain: {}", traceId);
            return Mono.just(msg);
        });
    });

注意 contextWrite 的时机:它只影响上游,不影响下游。所以要在链的最末端(最靠近订阅源的位置)调用 contextWrite,让整个链都能读到。另外,日志框架要配置成从 Reactor Context 里取 traceId 输出。Logback 可以用 %X{traceId} 配合 Spring Cloud Sleuth 的自动配置;如果自己管理,需要写一个 MDC 桥接,在响应式链里监听 Context 变化并同步到 MDC,这样日志模式才能统一。

我之前在一个 WebFlux 项目里踩过这个坑:拦截器里把 traceId 放进 ThreadLocal,然后在 flatMap 里打日志,发现 30% 的日志没有 traceId。原因是 flatMap 内部切换到了 Reactor 的调度线程,ThreadLocal 值没跟过去。改成 Reactor Context 后,traceId 丢失率降到零,但要注意 Context 里的 key 命名和日志系统保持一致,否则日志里还是取不到。

跨服务时 traceId 串了,多半是 header 传播没配对

前面说的都是单服务内部异步线程串值,但实际系统里更常见的是跨服务调用后 traceId 对不上。比如服务 A 调服务 B,A 的日志里 traceId 是 trace-123,B 的日志里却是另一个值,或者 B 生成的 traceId 和 A 的完全没有关联。

根因在 header 传播。traceId 在跨服务时是靠 HTTP header 传的,比如 X-B3-TraceId(Zipkin B3)或 traceparent(W3C Trace Context)。如果 A 调 B 时没有把 traceId 放进 header,B 收到请求后就会自己生成一个新的 traceId,日志自然串不起来。

修法分两步。第一步是确保 HTTP 客户端在发出请求时自动带上 traceId header。如果你用 RestTemplate,要加一个拦截器;用 WebClient,要加 ExchangeFilterFunction;用 Feign,要加 RequestInterceptor。这些在 Spring Cloud Sleuth / Micrometer Tracing 里都有自动配置,前提是引入对应依赖并且不要自己乱覆盖。

第二步是服务端收到请求后,要从 header 里提取 traceId 并放入上下文。Spring Boot 的 TraceFilter 或者 Micrometer 的 ObservationFilter 会做这件事。如果你自己写网关或过滤器,要小心:不要从 ThreadLocal 里拿 traceId 再塞进 header,因为网关层可能已经切换过线程。正确做法是在入口过滤器里从请求 header 提取 traceId,放入一个能跨线程传播的上下文(比如 TTL 或 Reactor Context),再把 header 原样转发。

我见过一个真实故障:网关用自定义过滤器把 traceId 放进 ThreadLocal,然后调下游服务时用 RestTemplate 同步调用。单次请求没问题,但网关内部有一个异步预加载逻辑用了线程池,导致 traceId 在异步任务里丢失,下游收到的 header 里 traceId 是 null,于是下游自己生成了新 traceId。最后日志系统里,网关的日志和下游服务的日志用两个不同的 traceId,排查问题时要靠时间戳人肉对齐。修法就是把网关的上下文传递改成 TTL 包装,并且在调用下游前从 TTL 里取 traceId 放进 header,而不是从 ThreadLocal 取。

修法优先级:先统一上下文载体,再谈透传

把上面这些坑串起来看,核心结论其实只有一条:traceId 串不串,取决于你选了什么当上下文载体。ThreadLocal 是线程绑定的,异步一多必然出问题;Reactor Context 是响应式链绑定的,适合响应式编程;TTL 是“线程局部变量的增强版”,适合传统线程池+CompletableFuture 场景。跨服务则靠 header 传播,但 header 解析后放进的上下文载体必须和内部异步模型匹配。

我的经验是:如果项目里既有同步又有多线程异步,优先上 TTL + javaagent 全局增强,改动最小、覆盖面最广。如果项目是 WebFlux 或 Reactor 技术栈,老老实实用 Reactor Context,别想着把 ThreadLocal 硬塞进去。如果两种都有——比如 Spring MVC 里混用 WebClient——那就得在边界处做桥接:入口过滤器把 traceId 放进 TTL,调用 WebClient 时从 TTL 取出放进 Reactor Context,响应式链内部从 Context 读,回传后再同步回 TTL。

最后提一个排查技巧:当你怀疑 traceId 串了,先把日志里的线程名打印出来(Logback 模式里加 %thread)。如果同一个 traceId 下出现了多个不同的线程名,而且这些线程名属于线程池或调度线程,基本可以确定是上下文透传没做好。定位到是哪个线程池或哪个操作符切换了线程,再去补对应的透传逻辑,比全局盲查高效得多。


常见问题

为什么 MDC 里的 traceId 在异步任务里有时候有、有时候没有?

因为 MDC 基于 ThreadLocal,线程不变时值还在,线程一切换就丢了。CompletableFuture 的 thenApply 如果在调用线程执行,traceId 就还在;如果前一个 future 尚未完成,thenApply 会在完成前一个 future 的线程执行,traceId 就没了。这是“时有时无”的典型原因。

用了 TTL 之后还需要手动在任务开头 put traceId 吗?

不需要。TTL 包装后的线程池会在提交任务时自动捕获当前线程的 TTL 值(包括 MDC 里的 traceId,前提是正确注册了 MDC 适配器),在任务执行前恢复到 worker 线程,执行后清理。手动 put 反而可能覆盖掉 TTL 恢复的值,造成新的混乱。

WebFlux 里能用 TTL 解决 traceId 丢失吗?

不能完全解决。TTL 解决的是线程池场景下的 ThreadLocal 透传,但 WebFlux 的线程切换发生在响应式链内部,不受线程池包装器控制。WebFlux 必须用 Reactor Context 传递 traceId,否则即使 TTL 在个别线程上有效,响应式链一旦经过 publishOnflatMap 切换调度器,值还是会丢。

跨服务调用时下游日志里 traceId 变了,是下游服务的问题吗?

不一定是下游的问题。大多数情况是上游调用时没有把 traceId 放进 HTTP header。下游服务收到请求后,如果 header 里没有 traceId,就会自己生成一个新的,这是符合规范的默认行为。先检查上游的 HTTP 客户端是否正确传播了 traceparentX-B3-TraceId header,再查下游的过滤器是否从 header 正确提取。