TransmittableThreadLocal 线程上下文传递
在传统Java服务中,请求处理通常会经过Controller、Service、DAO、RPC客户端、日志组件等许多层,为了避免在每一个方法参数中都携带请求ID、租户ID、用户信息、灰度标记等上下文数据,我们常把这类数据放进线程局部变量ThreadLocal。但只要业务将任务提交到线程池、CompletableFuture或其他异步执行器,代码的执行线程就会切换,在ThreadLocal中的上下文便会丢失,甚至错误地读到其他请求遗留的数据。阿里巴巴开源的TransmittableThreadLocal库(以下简称TTL)是针对这个问题的轻量解决方案,它在JDK的InheritableThreadLocal基础上增加了任务提交时捕获、任务执行时回放、执行结束后恢复的机制,使线程池等复用线程的执行组件能够正确实现线程局部上下文传递。
项目主页:https://github.com/alibaba/transmittable-thread-local
回顾JDK的ThreadLocal
ThreadLocal<T>可以理解为“以当前线程为键的变量容器”,同一个ThreadLocal对象在不同线程中调用get(),读到的是各线程各自保存的值。JDK实现中,每个Thread维护自己的ThreadLocalMap,其中以ThreadLocal实例为键保存值。
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
TRACE_ID.set("trace-1001");
try {
log.info("当前请求的链路ID:{}", TRACE_ID.get());
} finally {
TRACE_ID.remove();
}
使用ThreadLocal时,一个最佳实践是在线程用完后使用threadLocal.remove()方法清理它。在线程短暂且会自然退出的程序中,线程结束后其ThreadLocalMap也会被回收,但Web容器和业务线程池中,工作线程通常长期存活。如果任务执行结束后没有remove(),后续恰好运行在同一线程的请求就可能读到旧值,这会造成数据串扰,也会让对象长期被线程引用,造成内存泄漏。此外,我们还需要知道,ThreadLocalMap对键使用弱引用,但对值仍是强引用。ThreadLocal实例被回收后,键可能变成null而值暂时滞留,只有后续get、set、remove触发清理时才有机会释放。因此这个键的弱引用并不能代替业务侧的remove()。通常我们都建议将set和remove写在同一处,并以try...finally保证清理。
回顾JDK的InheritableThreadLocal
InheritableThreadLocal会在创建子线程时,将父线程的线程局部变量数据复制给子线程。
private static final InheritableThreadLocal<String> TRACE_ID =
new InheritableThreadLocal<>();
TRACE_ID.set("trace-1001");
new Thread(() -> System.out.println(TRACE_ID.get())).start(); // trace-1001
不过InheritableThreadLocal有两个重要的限制:
- 子线程只能得到创建当刻的快照,父线程之后修改值,已创建的子线程不会同步更新。
- 线程池通常会预先创建并反复复用工作线程。任务提交者与工作线程没有稳定的父子关系,工作线程可能早在本次请求前就被创建,
InheritableThreadLocal要么拿不到值,要么保留了创建工作线程时的陈旧值。
下面的例子在生产环境中就十分危险。
private static final InheritableThreadLocal<String> TRACE_ID =
new InheritableThreadLocal<>();
private static final ExecutorService POOL = Executors.newFixedThreadPool(1);
TRACE_ID.set("request-A");
POOL.submit(() -> System.out.println(TRACE_ID.get()));
TRACE_ID.set("request-B");
POOL.submit(() -> System.out.println(TRACE_ID.get()));
由于唯一的工作线程只会在首次需要时创建,两个任务很可能都输出request-A,而不是分别输出A、B。即使任务执行完毕手工remove(),也只能解决污染,无法让InheritableThreadLocal获得提交时的新上下文。
如何正确传递线程局部变量?
一种比较可靠的思路是这样的,我们在提交前取出上下文,在任务开始时设置,结束后清理或恢复原值。
String capturedTraceId = TRACE_ID.get();
executor.execute(() -> {
String backup = TRACE_ID.get();
try {
TRACE_ID.set(capturedTraceId);
doAsyncWork();
} finally {
if (backup == null) {
TRACE_ID.remove();
} else {
TRACE_ID.set(backup);
}
}
});
上面代码正是TTL的核心思想。不过真实工程往往有多个上下文字段、Runnable/Callable/定时任务等不同入口,且执行线程在运行任务前本身也可能有合法上下文,逐处手写极易漏掉finally、遗漏字段或错误复用旧快照,因此我们需要统一封装。
TTL简介与使用
TTL继承自InheritableThreadLocal,因此API与ThreadLocal非常接近,这里我们添加以下Maven依赖。
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.14.5</version>
</dependency>
实际开发中,我们经常将上下文定义为单例字段,并在请求边界执行设置和清理操作。
public final class RequestContext {
private RequestContext() {
}
public static final TransmittableThreadLocal<String> TRACE_ID =
new TransmittableThreadLocal<>();
public static final TransmittableThreadLocal<String> TENANT_ID =
new TransmittableThreadLocal<>();
}
RequestContext.TRACE_ID.set(traceId);
RequestContext.TENANT_ID.set(tenantId);
try {
service.handle();
} finally {
RequestContext.TENANT_ID.remove();
RequestContext.TRACE_ID.remove();
}
注意:TTL只传递TransmittableThreadLocal实例,普通ThreadLocal和第三方库内部的其他线程变量不会自动被“全量复制”。这是一项有意的隔离设计:传递哪些上下文应当是明确、可审计的决定。
TTL底层原理
关键对象与生命周期
TTL的实现可以概括为以下流程。
sequenceDiagram
participant Submit as 提交线程
participant Worker as 工作线程(可能被复用)
Submit->>Submit: 设置TTL值
Submit->>Submit: 提交任务时 capture()
Note over Submit: 保存任务快照
Submit->>Worker: 提交任务
Worker->>Worker: run() 前 replay(快照)
Worker->>Worker: 执行业务 Runnable/Callable
Worker->>Worker: finally restore(备份)
Note over Submit,Worker: 下一任务不会看到本任务上下文
- 登记:每个
TransmittableThreadLocal在当前线程有值时,会登记到该线程维护的TTL集合;remove()会取消登记。这个集合使框架能找到“本次需要传递的TTL实例”,无需扫描全部ThreadLocal。 - 捕获(capture):调用
TtlRunnable.get()、执行器的execute/submit()包装逻辑,或Agent增强后的提交入口时,TTL遍历当前线程已登记的实例,并调用每个实例的transmitteeValue()生成任务快照。 - 回放(replay):任务真正开始运行前,TTL先备份工作线程原有的TTL状态,再把任务快照写入工作线程;同时会移除工作线程中存在、但任务快照中不存在的TTL值,避免旧任务的值泄漏进来。
- 恢复(restore):无论任务正常返回还是抛出异常,
finally中都会恢复第3步的备份。这样工作线程在任务前已有的状态不会被破坏,任务结束也不会把提交线程的值遗留在线程池中。
对框架集成者,TTL还暴露了TransmittableThreadLocal.Transmitter的低层API,capture()获得快照,replay(captured)切换上下文并返回备份,restore(backup)恢复。不过对于普通业务代码,我们应优先使用TtlRunnable、TtlCallable或TtlExecutors,不要随意自行拼装低层调用,以免遗漏恢复步骤。
transmitteeValue与可变对象
TransmittableThreadLocal默认的transmitteeValue(parentValue)是直接返回原对象引用,也就是“传递引用”,而不是深拷贝。对于不可变对象,如String、UUID、只读的上下文值,这通常没有问题;如果存入Map、List、可变DTO等对象,提交线程与执行线程将共享同一对象,可能产生数据竞争或互相修改。必要时,我们需要覆写该方法以自定义建立快照的逻辑。
private static final TransmittableThreadLocal<Map<String, String>> CONTEXT =
new TransmittableThreadLocal<>() {
@Override
protected Map<String, String> transmitteeValue(Map<String, String> parentValue) {
return parentValue == null ? null : new HashMap<>(parentValue);
}
};
上面代码实现的也只是浅拷贝。当对象图中仍有可变成员时,我们要么进行符合业务语义的深拷贝,要么传递不可变快照,一般来说,最佳实践是不要用TTL隐式共享可变业务状态。
提交快照,而非实时同步
TTL传递的是任务被包装或提交瞬间的快照。提交后再修改父线程TTL,不会改变已经入队任务的上下文;子任务中对TTL的修改也不会反向写回提交线程。这种单向、一次性的语义使线程池复用成为可能。
同一个原始Runnable若在不同上下文中提交多次,必须在每次提交时重新包装。
Runnable task = () -> log.info("traceId={}", RequestContext.TRACE_ID.get());
RequestContext.TRACE_ID.set("A");
executor.execute(TtlRunnable.get(task));
RequestContext.TRACE_ID.set("B");
executor.execute(TtlRunnable.get(task)); // 必须重新get,才能捕获B
如果复用第一次得到的TtlRunnable,它携带的仍是第一次捕获的快照,这不是TTL失效了,而是包装器代表一个已经绑定上下文的任务实例。
在不同执行模型中使用TTL
新建线程
因为TTL本身继承InheritableThreadLocal,新创建的Thread也能读取父线程当时的值。
RequestContext.TRACE_ID.set("trace-1001");
new Thread(() -> log.info("{}", RequestContext.TRACE_ID.get())).start();
但是新线程场景没有线程复用问题,直接使用InheritableThreadLocal通常已经足够,只有需要同时兼容线程池,或希望统一上下文类型时才有必要使用TTL。
线程池
对可控的线程池,我们通常建议在Bean创建或基础设施层统一包装,业务代码仍使用标准ExecutorService接口。
ExecutorService rawExecutor = Executors.newFixedThreadPool(8);
ExecutorService executor = TtlExecutors.getTtlExecutorService(rawExecutor);
RequestContext.TRACE_ID.set("trace-1001");
try {
executor.submit(() -> log.info("async traceId={}", RequestContext.TRACE_ID.get()));
} finally {
RequestContext.TRACE_ID.remove();
}
TtlExecutors还提供getTtlExecutor()和getTtlScheduledExecutorService(),分别适配Executor、ExecutorService、ScheduledExecutorService。
至于类似ScheduledExecutorService.scheduleAtFixedRate()和scheduleWithFixedDelay()等,一个周期任务通常只在调度时捕获一次上下文,随后长期复用该快照,因此不要把请求级TTL带入常驻定时任务,定时任务通常应该显式建立自己的任务上下文,或每次触发时从可靠数据源重新读取。
Spring异步任务
Spring中的@Async、ThreadPoolTaskExecutor、ThreadPoolTaskScheduler最终也会委托给执行器,因此推荐在创建线程池Bean时统一接入TTL,或使用Spring提供的TaskDecorator实现同样的捕获、回放、恢复策略。无论选择哪一种,关键是业务只能注入包装后的执行器,不能绕开配置自行创建Executors.newFixedThreadPool()。
下面例子中,我们直接将TTL包装后的ExecutorService暴露为Bean。
@Bean(destroyMethod = "shutdown")
public ExecutorService applicationExecutor() {
ThreadPoolExecutor raw = new ThreadPoolExecutor(
8, 16, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1_000));
return TtlExecutors.getTtlExecutorService(raw);
}
注意:若项目已有日志MDC、Spring Security上下文、事务上下文等,应分别确认其官方集成方式,不要假设把自定义TTL接入后,所有第三方ThreadLocal都会自动传播。
CompletableFuture
CompletableFuture不显式传入执行器时,异步阶段通常使用ForkJoinPool.commonPool(),我们只包装自建ExecutorService是没用的,最稳妥的做法是显式传入已包装的执行器。
Executor ttlExecutor = TtlExecutors.getTtlExecutor(rawExecutor);
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> {
log.info("trace={}", RequestContext.TRACE_ID.get());
return queryRemoteService();
}, ttlExecutor)
.thenApplyAsync(this::convert, ttlExecutor);
thenApply这类非Async方法由触发完成的线程执行,不能用“它总在调用线程执行”的假设判断上下文;所有会异步调度的阶段都应统一指定执行器。
Spring WebFlux与Reactor
WebFlux/Reactor的响应式链路可以在一次订阅中多次切换线程,且同一工作线程会交错处理多个请求。ThreadLocal把数据绑到物理线程,而响应式上下文应绑定到订阅,两者模型不匹配。因此TTL不是WebFlux中请求上下文传递的主流方案,WebFlux业务上下文通常使用Reactor的Context来传递。
public Mono<ServerResponse> handle(ServerRequest request) {
String traceId = request.headers().firstHeader("X-Trace-Id");
return Mono.deferContextual(context -> {
String currentTraceId = context.get("traceId");
return ServerResponse.ok().bodyValue("trace=" + currentTraceId);
})
.contextWrite(context -> context.put("traceId", traceId));
}
当需要在响应式链路与依赖ThreadLocal的旧SDK、MDC之间桥接时,TTL可以用于从某个Reactive回调显式提交到普通线程池的那一小段边界,但不应该承载整个Reactive请求的上下文。