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

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

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

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

  • traceId

  • userId

  • tenantId

  • 登录用户信息

  • 日志 MDC 上下文

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

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

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

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

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

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

简单示例:

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,最多是日志链路混乱。

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

例如:

用户 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 = ?

没有正确生效。

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

所以涉及 tenantIduserId、权限上下文时,比 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. 只要涉及线程池、@AsyncCompletableFuture,就不要依赖 InheritableThreadLocal

  4. 需要传递 traceIdtenantIduserId 这类上下文时,可以使用 TTL;

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

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

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

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

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

结论

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

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

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

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

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

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

评论