使用 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,或者在创建任务时显式保存必要的业务字段。

相关推荐
戮漠summer8 小时前
Missing Semester 计算机教育中缺失的一课 Lecture 01 Shell
开发语言·后端·scala
IT_陈寒10 小时前
Redis的KEYS命令把我搞崩溃了,改用SCAN才活过来
前端·人工智能·后端
长不胖的路人甲10 小时前
SpringCloud 服务雪崩、熔断、降级
后端·spring·spring cloud
爱折腾的小黑牛11 小时前
小程序多人协作的权限设计:三级权限的实现
后端
蓝银草同学11 小时前
Stream 实战:博客列表排序、过滤与分页(AI 辅助学习 Java 8)
java·前端·后端
小小猪的春天12 小时前
AI 编程 30 天实验复盘:效率提升 65%,线上 bug 涨到 11 个,问题出在哪
后端
爱勇宝12 小时前
3位工程师靠“删AI代码”创业,一周收费1万美元:以后最贵的能力,可能不是写代码
前端·后端·架构
不能放弃治疗13 小时前
rule 和 skill
后端
ADIT13 小时前
一、反射的定义与原理
后端