线程池中如何正确传递 traceId?

作者:old wang 发布时间: 2025-05-23 阅读量:7 评论数:0

为什么 InheritableThreadLocal 在线程池中不可靠?

在 Java Web 项目中,我们经常会把一些请求级上下文信息放到 ThreadLocal 中,例如:

  • traceId

  • userId

  • tenantId

  • 登录用户信息

  • 日志 MDC 上下文

这样做的好处是,业务代码不需要层层传参,就可以在当前线程中随时获取上下文。

但问题也随之而来:一旦代码中使用了线程池、@Async、CompletableFuture,这些上下文信息就很容易丢失。

很多人第一反应是使用 InheritableThreadLocal,因为它看起来可以解决“父线程向子线程传值”的问题。但在线程池场景下,InheritableThreadLocal 不仅不可靠,甚至可能引发上下文串数据的问题。

一、ThreadLocal 为什么不能跨线程传递?

ThreadLocal 的核心设计是线程隔离。

每个线程内部都有自己的 ThreadLocalMap,ThreadLocal 中保存的数据实际上是存在当前线程对象里的。

简单示例:

ThreadLocal<String> traceId = new ThreadLocal<>();

traceId.set("trace-001");

new Thread(() -> {
    System.out.println(traceId.get());
}).start();

这段代码中,子线程打印结果通常是:

null

原因很简单:traceId.set("trace-001") 设置的是父线程中的数据,而子线程有自己的 ThreadLocalMap,两者互不共享。

这在日志链路追踪中就会出现问题。

例如请求入口设置了 traceId,主线程日志正常打印:

[trace-001] receive request

但异步线程中的日志可能变成:

[null] send message async

这会导致异步任务日志无法和主请求链路串起来。


二、InheritableThreadLocal 能解决吗?

JDK 提供了 InheritableThreadLocal,它可以在创建子线程时,把父线程中的上下文复制到子线程。

示例:

InheritableThreadLocal<String> traceId = new InheritableThreadLocal<>();

traceId.set("trace-001");

new Thread(() -> {
    System.out.println(traceId.get());
}).start();

输出:

trace-001

看起来问题解决了。

但它有一个关键限制:

InheritableThreadLocal 的复制动作只发生在线程创建时。

它的核心逻辑可以理解为:

Thread parent = currentThread();

if (parent.inheritableThreadLocals != null) {
    this.inheritableThreadLocals = ThreadLocal.createInheritedMap(
        parent.inheritableThreadLocals
    );
}

也就是说,只有在 new Thread() 的时候,子线程才会从父线程复制一份上下文。

这个设计在普通父子线程场景下没问题,但在线程池中就会出问题。

三、为什么 InheritableThreadLocal 在线程池中不可靠?

线程池的核心特点是:线程会复用。

线程池中的 worker 线程通常在池创建或首次提交任务时创建,后续多个任务会复用同一个线程执行。

这意味着:

  • 第一次提交任务时,可能会复制父线程上下文;

  • 第二次提交任务时,worker 线程已经存在,不会重新创建;

  • 因此也不会重新触发 InheritableThreadLocal 的复制逻辑。

示例:

ExecutorService pool = Executors.newSingleThreadExecutor();

InheritableThreadLocal<String> traceId = new InheritableThreadLocal<>();

traceId.set("trace-A");

Future<?> f1 = pool.submit(() -> {
    System.out.println(traceId.get());
});

f1.get();

traceId.set("trace-B");

pool.submit(() -> {
    System.out.println(traceId.get());
});

你可能期望输出:

trace-A
trace-B

但实际可能输出:

trace-A
trace-A

原因是第二个任务复用了第一个任务的 worker 线程,而这个线程没有重新创建,也就没有重新复制父线程的上下文。

更准确地说,InheritableThreadLocal 在线程池中的表现取决于 worker 线程的创建时机:

  • 如果 worker 线程创建时父线程上下文为空,后续任务中可能一直读不到值;

  • 如果 worker 线程创建时继承了某个上下文,后续复用时可能继续读到旧值;

  • 如果线程池扩容创建了新线程,又可能表现出不同结果。

这也是它在线程池场景下最危险的地方:问题不一定稳定复现。

如果上下文里放的是 traceId,最多是日志链路混乱。

但如果放的是 userId、tenantId、权限上下文,就可能出现更严重的问题。

例如:

用户 A 的请求上下文残留在线程池线程中
用户 B 的异步任务复用了该线程
异步任务读取到了用户 A 的上下文

如果这个上下文参与了数据权限、租户过滤或日志审计,就可能引发生产事故。

四、手动传参是否可行?

从正确性上讲,手动传参当然是安全的。

例如:

String traceId = TraceContext.get();

pool.submit(() -> {
    doSomething(traceId);
});

或者:

public void asyncProcess(String traceId, Long userId) {
    // ...
}

这种方式的优点是直观、明确,没有隐式上下文。

但缺点也很明显:

  1. 对业务代码侵入性强;

  2. 每个异步方法都要额外增加上下文参数;

  3. 上下文种类变多后,方法签名会越来越复杂;

  4. 对日志 MDC、链路追踪、多租户框架这类通用能力不友好。

所以在简单业务里,手动传参可以接受。

但在中大型项目中,如果上下文传递是一个基础设施问题,更常见的做法是使用专门的上下文传递工具。

五、TransmittableThreadLocal 是怎么解决这个问题的?

TransmittableThreadLocal,简称 TTL,是阿里开源的一个线程上下文传递工具。

它专门解决线程池场景下的 ThreadLocal 上下文传递问题。

它的核心思路是:

不在线程创建时复制上下文,而是在任务提交时捕获上下文,在任务执行时恢复上下文,执行结束后再还原现场。

大致分为三步。

1. capture:提交任务时捕获上下文

当主线程提交任务时,TTL 会捕获当前线程中的上下文。

例如当前请求的 traceId 是:

trace-001

那么提交任务时,TTL 会把这个值保存下来。

2. replay:任务执行前恢复上下文

线程池 worker 线程真正执行任务前,TTL 会把刚才捕获到的上下文设置到当前线程中。

这样异步线程中就可以正常读取到:

TraceContext.get();

3. restore:任务执行后还原上下文

任务执行完成后,TTL 会恢复 worker 线程原本的上下文,避免污染后续任务。

这一步非常关键。

如果没有 restore,线程池复用时依然可能出现上下文残留。

六、TTL 的基本使用方式

引入依赖后,可以使用 TransmittableThreadLocal 替代普通 ThreadLocal。

TransmittableThreadLocal<String> traceId = new TransmittableThreadLocal<>();

使用 TtlRunnable 包装任务:

ExecutorService pool = Executors.newFixedThreadPool(2);

TransmittableThreadLocal<String> traceId = new TransmittableThreadLocal<>();

traceId.set("trace-001");

pool.submit(TtlRunnable.get(() -> {
    System.out.println(traceId.get());
}));

traceId.set("trace-002");

pool.submit(TtlRunnable.get(() -> {
    System.out.println(traceId.get());
}));

输出:

trace-001
trace-002

相比 InheritableThreadLocal,TTL 的关键区别是:

  • InheritableThreadLocal 在线程创建时复制;

  • TransmittableThreadLocal 在任务提交时捕获;

  • TTL 更适合线程池场景。


七、更推荐的方式:统一包装线程池

实际项目中,不建议每次提交任务时都手动写:

TtlRunnable.get(...)

更好的方式是统一包装线程池。

例如:

ExecutorService executorService = Executors.newFixedThreadPool(8);

ExecutorService ttlExecutorService =
        TtlExecutors.getTtlExecutorService(executorService);

之后直接使用包装后的线程池:

traceId.set("trace-001");

ttlExecutorService.submit(() -> {
    System.out.println(traceId.get());
});

这样业务代码的侵入性会小很多。

八、Spring @Async 中如何接入 TTL?

在 Spring Boot 项目中,如果使用 @Async,建议统一配置异步线程池,并通过 TaskDecorator 包装任务。

示例:

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("ttlAsyncExecutor")
    public Executor ttlAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("biz-async-");

        executor.setTaskDecorator(runnable -> TtlRunnable.get(runnable));

        executor.initialize();
        return executor;
    }
}

业务代码中使用:

@Async("ttlAsyncExecutor")
public void asyncProcess() {
    String traceId = TraceContext.get();
    // ...
}

这样可以保证通过该异步线程池提交的任务,在执行时能够读取到提交任务时的 TTL 上下文。

需要注意的是,项目中不要一部分线程池包装了 TTL,另一部分线程池没有包装。否则异步链路中仍然可能出现上下文丢失。

九、traceId 上下文的一个常见封装

在项目中可以封装一个 TraceContext:

public class TraceContext {

    private static final TransmittableThreadLocal<String> TRACE_ID =
            new TransmittableThreadLocal<>();

    public static void set(String traceId) {
        TRACE_ID.set(traceId);
    }

    public static String get() {
        return TRACE_ID.get();
    }

    public static void clear() {
        TRACE_ID.remove();
    }
}

请求入口可以在过滤器中设置:

@Component
public class TraceFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        String traceId = request.getHeader("X-Trace-Id");

        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString();
        }

        try {
            TraceContext.set(traceId);
            MDC.put("traceId", traceId);

            filterChain.doFilter(request, response);
        } finally {
            TraceContext.clear();
            MDC.remove("traceId");
        }
    }
}

这里有两个重要细节。

第一,TraceContext.clear() 一定要执行。

因为 Web 容器本身也会复用线程。如果请求结束后不清理 ThreadLocal,下一个请求复用同一个线程时,可能会读到上一个请求的残留数据。

第二,MDC.remove("traceId") 也要执行。

MDC 本身也是基于线程上下文实现的,如果不清理,也会有线程复用导致的日志污染问题。

十、TTL 不等于自动传递 MDC

这是一个很容易忽略的点。

TTL 负责传递的是 TransmittableThreadLocal 中保存的值,但它不等价于自动传递日志框架的 MDC。

也就是说,下面这个方法:

public static void set(String traceId) {
    TRACE_ID.set(traceId);
    MDC.put("traceId", traceId);
}

只能保证当前线程中的 TraceContext 和 MDC 都有值。

到了异步线程中,TTL 可以让:

TraceContext.get();

读到正确的 traceId。

但日志框架中的:

%X{traceId}

不一定会自动有值。

如果项目日志格式依赖 MDC,需要单独处理 MDC 的传递和清理。

一种方式是在异步任务执行前,根据 TraceContext.get() 重新写入 MDC:

executor.setTaskDecorator(runnable -> TtlRunnable.get(() -> {
    String traceId = TraceContext.get();

    try {
        if (traceId != null) {
            MDC.put("traceId", traceId);
        }

        runnable.run();
    } finally {
        MDC.remove("traceId");
    }
}));

这样异步任务执行期间,日志中也可以打印出正确的 traceId。

这里要明确区分两件事:

  • TTL 负责传递业务上下文;

  • MDC 负责让日志框架打印上下文。

两者不是同一个东西。

十一、多租户场景下更要谨慎

除了 traceId,另一个常见场景是多租户上下文。

例如:

public class TenantContext {

    private static final TransmittableThreadLocal<Long> TENANT_ID =
            new TransmittableThreadLocal<>();

    public static void set(Long tenantId) {
        TENANT_ID.set(tenantId);
    }

    public static Long get() {
        return TENANT_ID.get();
    }

    public static void clear() {
        TENANT_ID.remove();
    }
}

业务代码中可能依赖 TenantContext.get() 拼接租户条件。

如果异步任务中租户上下文丢失,可能导致:

WHERE tenant_id = ?

没有正确生效。

如果线程池上下文串数据,则更严重,可能出现跨租户数据访问。

所以涉及 tenantId、userId、权限上下文时,比 traceId 更应该谨慎。

traceId 丢了通常只是日志不好查。

tenantId 丢了或者串了,可能就是数据安全问题。

十二、CompletableFuture 中如何传递上下文?

CompletableFuture 也是常见坑点。

例如:

CompletableFuture.supplyAsync(() -> {
    return TraceContext.get();
});

如果没有指定线程池,默认会使用 ForkJoinPool.commonPool()。

这个公共线程池是 JVM 级别共享的,不建议直接用于业务异步任务,也不方便统一做 TTL 包装。

更推荐的写法是显式传入业务线程池:

CompletableFuture.supplyAsync(() -> {
    return doSomething();
}, ttlExecutorService);

或者:

CompletableFuture.runAsync(() -> {
    doSomething();
}, ttlExecutorService);

也就是说,项目中应该尽量避免直接使用:

CompletableFuture.runAsync(() -> {
    // ...
});

而是使用:

CompletableFuture.runAsync(() -> {
    // ...
}, ttlExecutorService);

这样可以保证异步任务使用的是自己可控的、经过 TTL 包装的业务线程池。

TTL 也提供 Java Agent 方式,可以降低业务代码侵入性。但 Agent 属于全局增强,实际效果与 TTL 版本、JDK 版本、执行组件类型有关。

生产环境中,我更推荐优先使用显式传入业务线程池的方式。这样代码可读性更好,问题排查也更直观。

十三、ThreadLocal 是否一定会内存泄漏?

不是一定会。

但在线程池这类长生命周期线程中,如果使用不当,确实可能导致内存泄漏或上下文污染。

ThreadLocalMap 中的 key 是 ThreadLocal 的弱引用,value 是强引用。

如果 ThreadLocal 对象已经不可达,key 可能被 GC 回收为 null,但对应的 value 仍然挂在线程的 ThreadLocalMap 上。

如果这个线程很快结束,问题通常不大。

但在线程池、Web 容器工作线程这类长生命周期线程中,如果没有及时调用 remove(),value 可能长期无法释放,并且还可能影响后续复用该线程的任务。

所以最佳实践是:

try {
    context.set(value);
    // business logic
} finally {
    context.remove();
}

对于 Web 请求,应该在 Filter 或 Interceptor 的 finally 中统一清理。

对于异步任务,也应该在任务执行完成后清理或恢复上下文。

十四、TTL 默认不是深拷贝

还有一个容易被忽略的问题:TTL 解决的是上下文传递问题,不负责解决上下文对象本身的线程安全问题。

例如:

TransmittableThreadLocal<Map<String, Object>> context =
        new TransmittableThreadLocal<>();

如果上下文里放的是一个可变的 Map,TTL 默认传递的是对象引用,不是深拷贝。

这意味着多个线程可能拿到同一个对象引用。

如果不同线程同时修改这个 Map,仍然可能出现并发问题。

因此建议上下文中尽量保存不可变值,例如:

String traceId
Long tenantId
Long userId

不建议直接保存复杂的可变对象。

如果确实需要保存复杂对象,也要确保对象本身是不可变的,或者在传递时自行做好拷贝和线程安全控制。

十五、几种方案对比

方案

是否支持跨线程

是否适合线程池

优点

缺点

ThreadLocal

否

否

简单,线程隔离

不能跨线程传递

InheritableThreadLocal

部分支持

不适合

JDK 原生支持

在线程池中容易失效或串数据

手动传参

支持

支持

明确、安全

侵入性强

TransmittableThreadLocal

支持

支持

适合线程池,业务侵入小

需要额外依赖和统一规范

十六、项目中的实践建议

我的建议是:

  1. 当前线程内使用普通 ThreadLocal 没问题;

  2. 只在简单 new Thread() 场景下,才考虑 InheritableThreadLocal;

  3. 只要涉及线程池、@Async、CompletableFuture,就不要依赖 InheritableThreadLocal;

  4. 需要传递 traceId、tenantId、userId 这类上下文时,可以使用 TTL;

  5. 所有业务线程池都应该统一包装,不要让业务代码各自处理;

  6. 所有上下文都必须在请求结束或任务结束后清理;

  7. 如果日志依赖 MDC,需要单独处理 MDC 的传递和清理;

  8. CompletableFuture 不要直接使用默认 commonPool,应该显式指定业务线程池;

  9. 上下文中尽量保存不可变对象,不要直接保存可变 Map 或复杂对象。

结论

ThreadLocal 解决的是线程内上下文隔离问题,不解决跨线程传递问题。

InheritableThreadLocal 可以在创建子线程时把父线程的值复制过去,但它的复制时机是线程创建时。

在线程池场景下,worker 线程会被复用,后续任务提交时不会重新触发父子线程复制逻辑,因此可能出现上下文丢失、旧值残留或串数据问题。

TransmittableThreadLocal 的核心价值在于把上下文传递时机从“线程创建时”移动到“任务提交时”,并在任务执行前恢复上下文、执行完成后还原现场。

需要注意的是,TTL 传递的是 TransmittableThreadLocal 中保存的上下文值,并不等价于自动传递日志框架的 MDC。如果日志格式依赖 %X{traceId},还需要单独处理 MDC 的传递和清理。

另外,TTL 默认不负责深拷贝上下文对象。上下文中应尽量保存不可变值,例如 traceId、tenantId、userId,避免保存可变 Map 或复杂对象。

评论