使用 TtlExecutors 解决线程池中的 ThreadLocal 上下文丢失问题

使用 TtlExecutors 解决线程池中的 ThreadLocal 上下文丢失问题

前言

在 Web 系统中,我们经常会把当前登录用户、traceId、租户 ID 等信息放到 ThreadLocal 中。

比如一次 HTTP 请求进入系统后,拦截器会解析 token,然后把登录用户保存到上下文中:

bash 复制代码
UserContext.set(loginUser);

后续 Controller、Service、DAO 都可以通过:

bash 复制代码
UserContext.getUserId();

拿到当前用户。

这种方式在同步调用里没有问题,因为一次请求通常由同一个 Tomcat 工作线程处理。但一旦进入线程池,例如异步执行、并行检索、批量任务、模型调用、消息处理,就会遇到一个常见问题:

bash 复制代码
HTTP 线程中有 UserContext
线程池工作线程中却拿不到 UserContext

原因很简单:ThreadLocal 是线程隔离的。你把数据放到了 HTTP 线程的 ThreadLocal,线程池里的工作线程是另一个线程,自然读不到。

为了解决这个问题,可以使用 Alibaba 的 TransmittableThreadLocal,简称 TTL。

技术原理

1. 普通 ThreadLocal 为什么在线程池中失效

假设当前请求由 http-nio-8080-exec-1 处理:

bash 复制代码
http-nio-8080-exec-1:
  UserContext = user-1001

此时如果直接提交一个任务到线程池:

bash 复制代码
executor.execute(() -> {
    System.out.println(UserContext.getUserId());
});

任务实际可能由另一个线程执行:

bash 复制代码
mcp_batch_executor_1:
  UserContext = null

因为 ThreadLocal 的数据是绑定在线程上的,不会自动从 HTTP 线程复制到线程池线程。

InheritableThreadLocal 也不能完全解决这个问题,因为线程池里的线程通常是提前创建并复用的,不是每次提交任务时重新创建子线程。它只能在线程创建时继承父线程变量,无法解决线程池复用场景。

2. 引入 TransmittableThreadLocal

依赖示例:

bash 复制代码
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>2.14.5</version>
</dependency>

实际版本建议以项目统一依赖管理为准。

用户上下文可以这样定义:

bash 复制代码
public final class UserContext {

    private static final TransmittableThreadLocal<LoginUser> CONTEXT =
            new TransmittableThreadLocal<>();

    public static void set(LoginUser user) {
        CONTEXT.set(user);
    }

    public static LoginUser get() {
        return CONTEXT.get();
    }

    public static String getUserId() {
        LoginUser user = CONTEXT.get();
        return user == null ? null : user.getUserId();
    }

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

这里的关键点是:

bash 复制代码
TransmittableThreadLocal<LoginUser>

它不是普通 ThreadLocal,而是可以被 TTL 在线程池任务提交时捕获、在线程池任务执行时恢复的上下文容器。

3. 使用 TtlExecutors 包装线程池

原始线程池:

bash 复制代码
ThreadPoolExecutor executor = new ThreadPoolExecutor(
        CPU_COUNT,
        CPU_COUNT << 1,
        60,
        TimeUnit.SECONDS,
        new SynchronousQueue<>(),
        ThreadFactoryBuilder.create()
                .setNamePrefix("mcp_batch_executor_")
                .build(),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

不要直接返回原始线程池,而是返回 TTL 包装后的 Executor:

bash 复制代码
@Bean
public Executor mcpBatchExecutor() {
    ThreadPoolExecutor executor = new ThreadPoolExecutor(
            CPU_COUNT,
            CPU_COUNT << 1,
            60,
            TimeUnit.SECONDS,
            new SynchronousQueue<>(),
            ThreadFactoryBuilder.create()
                    .setNamePrefix("mcp_batch_executor_")
                    .build(),
            new ThreadPoolExecutor.CallerRunsPolicy()
    );

    return TtlExecutors.getTtlExecutor(executor);
}

TtlExecutors.getTtlExecutor(executor) 做的事情可以理解为:

bash 复制代码
给原始线程池套一层代理

业务代码调用:

bash 复制代码
mcpBatchExecutor.execute(runnable);

实际会变成 类 似:

bash 复制代码
rawExecutor.execute(TtlRunnable.get(runnable));

也就是说,它会把普通 Runnable 包装成 TtlRunnable

4. 完整数据流转过程

可以把整个过程拆成四步。

第一步,Spring 注入包装后的线程池。

bash 复制代码
return TtlExecutors.getTtlExecutor(executor);

此时注入到业务代码里的不是原始 ThreadPoolExecutor,而是 TTL 包装后的 Executor。它内部仍然持有原始线程池,只是在提交任务时会做额外处理。

第二步,HTTP 拦截器设置用户上下文。

bash 复制代码
UserContext.set(loginUser);

此时登录用户保存在当前 HTTP 请求线程中:

bash 复制代码
HTTP 线程:
  UserContext.CONTEXT = loginUser

第三步,提交任务时捕获上下文。

bash 复制代码
mcpBatchExecutor.execute(() -> {
    String userId = UserContext.getUserId();
});

因为 mcpBatchExecutor 是 TTL 包装过的,所以 execute() 时会先把任务包装成 TtlRunnable

包装过程中会捕获提交线程的上下文:

bash 复制代码
capture:
  UserContext.CONTEXT -> loginUser

注意,捕获发生在"提交任务的那一刻",不是任务真正执行的那一刻。

第四步,工作线程执行任务前回放上下文。

线程池工作线程真正执行任务时,TTL 会先执行:

bash 复制代码
replay(captured)

也就是把刚才捕获到的 loginUser 临时设置到当前工作线程的 ThreadLocal 中。

于是业务代码在线程池中执行时:

bash 复制代码
UserContext.getUserId();

就可以正常拿到当前用户。

任务执行完成后,TTL 会在 finally 中执行:

bash 复制代码
restore(backup)

也就是恢复工作线程原来的 ThreadLocal 状态,避免线程池复用时出现用户上下文串号。

完整链路可以总结为:

bash 复制代码
HTTP 请求进入
    ↓
拦截器 UserContext.set(loginUser)
    ↓
业务代码提交任务到 TTL 包装线程池
    ↓
TtlRunnable 捕获提交线程中的 UserContext
    ↓
线程池工作线程执行任务
    ↓
replay,把 loginUser 设置到工作线程
    ↓
业务代码读取 UserContext
    ↓
任务结束,restore 恢复现场

5. TTL 并不是遍历所有 ThreadLocal

这里有一个容易误解的点。

TTL 并不是扫描 JVM 当前线程中的所有普通 ThreadLocal。它主要传递的是:

bash 复制代码
TransmittableThreadLocal 中保存的值

也就是说,只有你使用了:

bash 复制代码
new TransmittableThreadLocal<>()

并且在当前线程中设置过值,TTL 才能在线程池提交任务时捕获到它。

所以它并不是"认识 LoginUser",而是因为:

bash 复制代码
LoginUser 被放进了 TransmittableThreadLocal
TransmittableThreadLocal 被 TtlRunnable 捕获

总结

ThreadLocal 适合保存一次请求内的上下文,但普通 ThreadLocal 无法自动跨线程池传递。

TtlExecutors.getTtlExecutor(executor) 的作用是包装线程池,让任务提交时自动捕获当前线程的 TransmittableThreadLocal,任务执行时再回放到工作线程中,任务结束后恢复现场。

它解决的是:

bash 复制代码
HTTP 线程有上下文,线程池工作线程拿不到上下文

这类问题非常适合用于登录用户、traceId、租户 ID、链路追踪上下文等场景。

不过需要注意:TTL 捕获的是"提交任务那一刻"的上下文。如果任务不是从 HTTP 线程直接提交,而是经过 MQ、 定时任务 、调度线程再次提交,那么它捕获到的就是那个提交线程的上下文。对于这类场景,仍然需要手动 setclear,或者在创建任务时显式保存必要的业务字段。

相关推荐
阿祖zu2 小时前
别再优化 RAG 了,适配 Agent 的 LLM Wiki 知识库理念
前端·后端·aigc
昵称为空C2 小时前
手撸一个动态 SQL 执行引擎:不重启服务,在线增删改查任意数据库
spring boot·后端
用户8356290780513 小时前
用 Python 自动化 PowerPoint 演讲者备注添加
后端·python
神奇小汤圆3 小时前
科研神器再升级!Claude Code 全套 Skills,16 大科研场景全覆盖!
后端
tyung3 小时前
Go 手写有界 SPSC 环形队列:无 CAS、无锁、Cache 友好的无锁模型
后端·go
咕白m6253 小时前
使用 C# 在 Excel 中应用多种字体样式
后端·c#
Java编程爱好者3 小时前
放弃 Spring AI?这 3 个开源框架,才是让 SpringBoot 玩转 AI Agent 的正解
后端
二月龙3 小时前
伪类与伪元素深度解析:before/after 实用案例
后端
码事漫谈3 小时前
时序数据库2026盘点:国产数据库如何以“融合多模”走出差异化之路?
前端·后端