公司排查一个异步任务异常,在 ELK 里搜对应的 requestId,搜出来的日志只有主线程的。异步线程里的日志一条都没搜到——因为 MDC 里的 requestId 根本没有传到异步线程里。异步线程打出来的日志,requestId 是空的。一整条调用链在异步这个节点断掉了。
问题很直接:@Async 用一个线程池执行任务,但 MDC 是基于 ThreadLocal 的,ThreadLocal 不会自动从主线程传到子线程。异步线程里 MDC.get("requestId") 返回 null,日志里就少了这条 key。
今天聊聊怎么用 TransmittableThreadLocal(TTL)把主线程的上下文透传到异步线程里。
为什么 ThreadLocal 不行,TTL 就行
ThreadLocal 的一个基本特性:子线程拿不到父线程的值。 主线程里 MDC.put("requestId", "abc123"),然后 @Async 开一个新线程去执行,新线程里 MDC.get("requestId") 是 null。
一个直觉的解法是调用线程池时手动传参数。把 requestId 作为参数传给异步方法,然后在异步方法里重新 MDC.put()。但这破坏了异步方法的方法签名——本来一个纯粹的 processOrder(),现在要加一个 String requestId 参数。
TLT 的做法是在提交任务给线程池时,自动把当前线程的 ThreadLocal 拷贝到任务线程里。 对业务代码完全透明——你不需要改方法签名,不需要手动传参。
// 不用改任何业务代码
@Async
public void processOrder(Order order) {
// MDC.get("requestId") 自动有值,因为 TTL 透传了
log.info("异步处理订单: {}", order.getId());
}
TTL 怎么配
两步:
第一步,引入依赖:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.14.4</version>
</dependency>
第二步,把 Spring 的线程池包装成 TTL 线程池:
@Configuration
public class AsyncConfig {
@Bean("ttlExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(200);
executor.initialize();
// ★ 关键:用 TtlExecutors 包装
return TtlExecutors.getTtlExecutor(executor);
}
}
TtlExecutors.getTtlExecutor() 返回的线程池在执行每个任务前,会自动把提交任务时的线程上下文(所有 TTL 变量)拷贝到执行任务的线程里。任务执行完后清理,防止污染。
MDC 适配
MDC 底层用的是普通 ThreadLocal,不是 TTL。需要用 TtlMDCAdapter 做适配:
// 启动时注册
static {
TtlMDCAdapter.getInstance();
}
或者更简单——直接用 Logback 的 MDC + TTL 的组合:
// 在 Filter 里,正常使用 MDC
MDC.put("requestId", requestId);
// @Async 方法里,MDC 自动有值
// 因为 TTL 已经把 ThreadLocal 的值透传了
关键:MDC 本身不用改,改的是线程池。线程池用 TtlExecutors 包一层,MDC 就能透传。
TTL 的原理简述
TTL 在提交任务时,先拍一个当前线程 TTL 变量的快照,然后把快照绑到任务上。任务执行时,先把快照恢复到执行线程里,执行完再清理。
主线程 TTL: {requestId: "abc123"}
│
├─ 提交任务 → TTL 拍快照
│
├─ 异步线程开始执行
│ └─ TTL 恢复快照 → {requestId: "abc123"}
│ └─ log.info() → requestId=abc123 ✅
│ └─ 执行完毕 → TTL 清理
│
└─ 主线程继续
全程不需要业务代码参与。你不需要在异步方法里做任何处理。
不只是 MDC,其他 ThreadLocal 也适用
TTL 不只能传 MDC——你项目里任何基于 ThreadLocal 的上下文都能传:
- 当前登录用户(
UserContext.getCurrentUser()) - 租户 ID(
TenantContext.getTenantId()) - 请求来源(
SourceContext.getSource())
所有这些,只要底层是 TTL(而不是普通 ThreadLocal),都能自动透传到异步线程里。但注意:如果一个变量用的是普通 ThreadLocal,TTL 是管不到的。需要把 ThreadLocal 改成 TransmittableThreadLocal:
// ❌ 普通 ThreadLocal,TTL 管不到
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
// ✅ TransmittableThreadLocal,TTL 自动透传
private static final TransmittableThreadLocal<String> CONTEXT =
new TransmittableThreadLocal<>();
总结
@Async 导致 MDC 断链,不是因为异步本身有问题,而是 ThreadLocal 不传子线程。
TTL 三步搞定:
TtlExecutors.getTtlExecutor(executor)包装线程池- MDC 无需改造,Filter 里正常
MDC.put(),异步方法里自动有值 - 其他 ThreadLocal 改成
TransmittableThreadLocal,同样自动透传
配完之后,异步线程的日志也有完整的 requestId,链路在 ELK 里不再断层。
