文章 557
评论 5
浏览 200224
@Async 线程上下文丢失:MDC 链路追踪断裂?TransmittableThreadLocal 透传实战

@Async 线程上下文丢失:MDC 链路追踪断裂?TransmittableThreadLocal 透传实战

公司排查一个异步任务异常,在 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 里不再断层。



标题:@Async 线程上下文丢失:MDC 链路追踪断裂?TransmittableThreadLocal 透传实战
作者:jiangyi
地址:http://jiangyi.space/articles/2026/07/04/1783147753354.html
公众号:服务端技术精选

服务端开发博客:后端架构、高并发、性能优化与微服务实战教程

取消