接口日志里还能搜到 traceId,到了异步方法,只剩下一截空白。线程也确实从 http-nio-... 换成了 trace-worker-...,任务没有丢,查日志的线索却断了。

这种情况先看提交任务的那个瞬间:调用方的 MDC 里到底有没有值?如果有,再看异步任务用了哪个线程池,以及这个线程池有没有把 MDC 带过去。在线程池里临时补一个 MDC.put,很容易把“没有 ID”修成“串了别人的 ID”。

本文讨论 Java 17/21、Spring Boot 3.5 / Spring Framework 6.2,使用 SLF4J 2 和 Logback 的普通线程池场景。前提是项目已经在请求入口放入自定义的 traceId,并在请求结束时清理。这里只处理日志关联字段;如果 traceId 由 Micrometer Tracing 等链路组件管理,先看文末的处理范围。

先分清:值没传过去,还是日志没打出来

在调用异步方法之前、异步方法刚进入时,分别临时记录 MDC.get("traceId"),同时看线程名。

提交前异步方法内先查哪里
就是空值也是空值请求入口有没有设置、键名是否一致、是否提前清理
有值空值实际执行器有没有传递 MDC
两边都有正确值日志字段仍为空当前生效的 appender 和输出格式
异步里出现上一次请求的 ID值不对应线程复用时是否漏了清理或恢复

使用 Boot 默认的 Logback 文本格式时,可以在现有 application.yml 的 logging 节点里合并这项配置:

logging:
  pattern:
    level: "traceId=%X{traceId} %5p"

这里的 traceId 要和代码里的键名完全一致。项目如果自定义了 logback-spring.xml,或者输出 JSON 日志,就去查实际使用的编码器和字段配置。改这行 YAML 不代表所有输出都会变化。Spring Boot 日志文档说明了默认文本格式如何通过 logging.pattern.level 加入 MDC 字段。

如果连线程都没有切换,先处理 @Async 仍在当前线程执行,再回来查上下文传递。

在线程切换的地方带上 MDC

Logback 的 MDC 跟当前线程关联。调用线程里放了一个值,工作线程不会因为“这项任务是它提交的”就自动拿到。Logback 官方手册也专门说明了线程池场景:在提交方取副本,等任务执行时再装入工作线程。

Spring 的 TaskDecorator正好提供了任务执行前后加一层处理的入口。下面这个类放在项目的 demo.async 包中,包名按实际项目调整:

package demo.async;

import java.util.Map;
import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;

public final class MdcTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable task) {
        // 此时仍在提交线程,先留下这一刻的副本。
        Map<String, String> submitted = MDC.getCopyOfContextMap();

        return () -> {
            Map<String, String> previous = MDC.getCopyOfContextMap();
            try {
                replaceContext(submitted);
                task.run();
            } finally {
                replaceContext(previous);
            }
        };
    }

    private static void replaceContext(Map<String, String> context) {
        if (context == null || context.isEmpty()) {
            MDC.clear();
        } else {
            MDC.setContextMap(context);
        }
    }
}

这段代码有三处容易抄错。

取副本要在返回的 lambda 外面。 放进 lambda,就变成任务开始后从工作线程取值,已经错过了提交方的上下文。

提交方没有 MDC,也要显式清空本次任务的执行环境。 不能因为 submitted == null 就什么也不做,否则工作线程上原本残留的值可能继续出现在日志里。

结束时恢复进入任务前的上下文。 普通工作线程通常没有原上下文,恢复时会清空;遇到嵌套调用或由调用线程执行任务的策略,恢复可以保住外层还在使用的值。只在结尾一律 clear(),可能把外层的日志线索也清掉。

getCopyOfContextMap() 允许返回 null,setContextMap() 会复制传入的 Map。这些行为可在 SLF4J MDC API核对。这里复制的是字符串 Map,没有把 HTTP 请求对象或整个用户对象塞进队列。

确认 @Async 用的就是这个执行器

装饰器写好却没挂到实际线程池上,日志仍然会断。

下面是一个独立的配置示例。三个类放在应用组件扫描范围内;如果项目已有异步线程池,就把装饰器接到现有 Bean 上,保留原来的容量和拒绝策略,避免出现两套名字相近的配置。

package demo.async;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("traceExecutor")
    public ThreadPoolTaskExecutor traceExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("trace-worker-");
        executor.setTaskDecorator(new MdcTaskDecorator());
        return executor;
    }
}

线程数和队列长度只是示例值,不能直接当作生产容量建议。这个 Bean 由 Spring 管理初始化和关闭,不需要在业务代码里再创建一份线程池。setTaskDecorator 的适用行为见 ThreadPoolTaskExecutor API。

异步方法再明确指定它:

package demo.async;

import java.util.concurrent.CompletableFuture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class AuditWorker {
    private static final Logger log =
            LoggerFactory.getLogger(AuditWorker.class);

    @Async("traceExecutor")
    public CompletableFuture<Void> record(String eventId) {
        log.info("eventId={}, traceId={}", eventId, MDC.get("traceId"));
        return CompletableFuture.completedFuture(null);
    }
}

由另一个 Spring Bean 注入 AuditWorker 后调用 record,不要在同一个类里用 this.record(...) 绕开代理。@Async("traceExecutor") 指向指定执行器的规则,可查 Spring 异步执行文档。

还有一处常见断点:外层已经用了这个线程池,里面又调用 CompletableFuture.runAsync(...),却没有传执行器。对标准 CompletableFuture 而言,这类调用通常进入公共线程池,并不会自动沿用外层的 Spring 执行器。需要第二次异步调度时,也要明确选用已经配置好的执行器。JDK 21 API列出了默认执行规则。

复测时,要让线程真的复用一次

只看到一条日志带回了 traceId,还不足以证明没有串号。可以在测试环境按下面的顺序查:

  1. 通过项目现有请求入口分别产生 req-a、req-b 两个自定义 ID,提交异步任务,确认两项任务各自对应正确的 ID。
  2. 等前两项任务完成,再从没有 MDC 的调用方提交一项任务,确认里面没有沿用前两个 ID。为了稳定观察线程复用,可临时把测试线程池设为一个工作线程。
  3. 让其中一项测试任务抛异常,再提交下一项,检查清理是否仍然发生;同时检查调用方原有的 MDC 是否保留。
  4. 最后恢复实际容量,发起并发请求,按业务事件 ID 对照日志,确认没有把不同请求拼到一起。

这些是建议执行的复测步骤,本文没有把它们包装成某个线上项目的实测结果。线程池排队、拒绝和延迟另看 Java 线程池打满怎么查。

装饰器里的 finally 用来恢复上下文,不承担业务异常处理。尤其提交的是 Future 包装任务时,异常可能保存在 Future 中;调用方仍要观察完成结果,不能把日志里没看到异常当成任务成功。

已经接了链路追踪,就沿着原来的方案查

如果 traceId 来自链路追踪组件,单独把同名字符串复制到 MDC,只能让日志看起来接上了,不能据此判断 span、采样信息或下游调用也接上了。

Spring 6.1 起提供 ContextPropagatingTaskDecorator,可以配合 Micrometer Context Propagation 处理日志或观测上下文。采用它时,还要核对项目依赖、已有装饰器以及相应的 ThreadLocalAccessor 注册;它不会自动扫描并复制任意 ThreadLocal。Micrometer 的用途说明区分了 ThreadLocal、快照和注册表的职责。

先在提交前确认 ID 存在,再沿着每一次线程切换检查执行器。把复制、安装和恢复放在同一个任务边界里,之后再看并发复测,日志才能持续对应到正确的请求。