使用 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、 定时任务 、调度线程再次提交,那么它捕获到的就是那个提交线程的上下文。对于这类场景,仍然需要手动 set 和 clear,或者在创建任务时显式保存必要的业务字段。