生产日志按 traceId 检索时,异步任务常会突然断链。本文从 MDC 的线程绑定原理出发,给出 Servlet 入口、线程池上下文复制、异常记录与监控字段设计的完整实践。

问题背景:一次请求,日志却被分成了两段

生产环境中,一个接口往往会经过参数校验、数据库访问、远程调用和异步任务。为了把这些日志串起来,常见做法是在请求入口生成 traceId,写入 SLF4J 的 MDC,再让日志格式自动输出它。

同步代码通常没有问题:

[traceId=7f3a...] 开始创建订单
[traceId=7f3a...] 订单写入成功

但代码一旦提交到线程池,日志可能变成:

[traceId=] 开始发送通知

这不是日志框架偶尔失效,而是 MDC 的使用边界没有覆盖线程切换。故障发生后,工程师能看到主流程成功,也能看到后台任务报错,却无法确认两者是否属于同一次请求。更麻烦的是,线程池线程会被反复复用;如果上下文只设置不清理,还可能把上一个请求的 traceId 带到下一个任务中,形成误导。

核心原理:MDC 默认不会跨线程传递

MDC(Mapped Diagnostic Context)可以理解为与当前线程关联的一组键值。业务代码调用:

MDC.put("traceId", traceId);

日志格式中的 %X{traceId} 就能读取该值。它适合保存 traceId、租户标识、调用来源等日志上下文,但不应承载业务状态。

关键在于“当前线程”。Servlet 线程把任务交给线程池后,执行任务的是另一个线程,它看不到提交线程中的 MDC。即使某些底层实现涉及可继承线程本地变量,也不能依赖它解决线程池传播:池中线程通常早已创建,而且会持续复用。

可靠的处理过程应当是:

  1. 提交任务时复制调用线程的 MDC;
  2. 执行任务前,把副本设置到工作线程;
  3. 执行结束后,恢复工作线程原有上下文或清空;
  4. 无论任务成功还是抛异常,清理动作都必须发生。

在 HTTP 入口建立 traceId

下面示例适用于使用 Jakarta Servlet API 的 Spring Boot 3.x 应用。若项目仍使用 Spring Boot 2.x,需要把 jakarta.servlet 包名换成 javax.servlet

package com.example.logging;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;
import java.util.regex.Pattern;

@Component
public class TraceIdFilter extends OncePerRequestFilter {
    public static final String TRACE_ID = "traceId";
    private static final Pattern SAFE_TRACE_ID =
            Pattern.compile("[A-Za-z0-9._-]{1,64}");

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String traceId = resolveTraceId(request.getHeader("X-Trace-Id"));
        MDC.put(TRACE_ID, traceId);
        response.setHeader("X-Trace-Id", traceId);

        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.remove(TRACE_ID);
        }
    }

    private String resolveTraceId(String candidate) {
        if (candidate != null && SAFE_TRACE_ID.matcher(candidate).matches()) {
            return candidate;
        }
        return UUID.randomUUID().toString().replace("-", "");
    }
}

入口允许沿用上游传入的标识,便于跨服务检索,但不能原样信任任意请求头。限制字符和长度可以避免换行等内容污染日志。finally 中的清理同样不可省略,因为处理 HTTP 请求的容器线程也会复用。

Logback 的文本格式可以加入 MDC 字段:

logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [traceId=%X{traceId:-none}] %logger{36} - %msg%n

用 TaskDecorator 传播线程池上下文

Spring 的 ThreadPoolTaskExecutor 支持 TaskDecorator,可在任务真正执行前包装 Runnable。这是集中处理 MDC 的合适位置,避免每个业务方法手工复制。

package com.example.logging;

import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable task) {
        Map<String, String> callerContext = MDC.getCopyOfContextMap();

        return () -> {
            Map<String, String> workerContext = MDC.getCopyOfContextMap();
            try {
                if (callerContext == null) {
                    MDC.clear();
                } else {
                    MDC.setContextMap(callerContext);
                }
                task.run();
            } finally {
                if (workerContext == null) {
                    MDC.clear();
                } else {
                    MDC.setContextMap(workerContext);
                }
            }
        };
    }
}

这里没有只写一个 MDC.clear(),而是先保存工作线程原有上下文,最后恢复它。多数普通线程池任务中原上下文为空,但“保存—设置—恢复”的写法边界更完整,也能应对任务包装层嵌套的情况。

接着配置业务线程池:

package com.example.config;

import com.example.logging.MdcTaskDecorator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
public class ExecutorConfig {
    @Bean("bizExecutor")
    public Executor bizExecutor(MdcTaskDecorator taskDecorator) {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("biz-");
        executor.setTaskDecorator(taskDecorator);
        executor.initialize();
        return executor;
    }
}

线程数和队列容量只是示例,生产值应根据任务耗时、外部依赖承载能力和拒绝策略评估,不能直接照抄。业务调用可以显式指定该执行器:

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

    public OrderService(@Qualifier("bizExecutor") Executor bizExecutor) {
        this.bizExecutor = bizExecutor;
    }

    public CompletableFuture<Void> createAndNotify(long orderId) {
        log.info("order_create_started orderId={}", orderId);

        return CompletableFuture.runAsync(() -> {
            long start = System.nanoTime();
            try {
                sendNotification(orderId);
                long elapsedMs = (System.nanoTime() - start) / 1_000_000;
                log.info("order_notify_succeeded orderId={} elapsedMs={}",
                        orderId, elapsedMs);
            } catch (RuntimeException ex) {
                log.error("order_notify_failed orderId={}", orderId, ex);
                throw ex;
            }
        }, bizExecutor);
    }

    private void sendNotification(long orderId) {
        // 调用真实的通知组件
    }
}

如果使用 @Async,应写成 @Async("bizExecutor"),确保任务进入配置了装饰器的执行器。直接使用未指定执行器的 CompletableFuture.runAsync 会进入公共线程池,上述传播逻辑不会生效。

日志能关联,还不等于可监控

traceId 用于定位单次调用,不适合作为 Micrometer 指标标签。它的取值数量几乎随请求增长,会造成高基数问题。指标标签应选择有限集合,例如操作名、结果、异常类别;订单号、用户号和 traceId 留在日志中。

建议关键日志至少包含:

  • 稳定的事件名,如 order_notify_failed
  • 业务定位字段,如 orderId
  • 结果和耗时,如 elapsedMs
  • 完整异常对象,而不是只记录 ex.getMessage()
  • 自动附加的 traceId 和线程名。

告警应优先建立在失败率、延迟、线程池活跃度和队列积压等指标上,日志负责提供故障现场。只对某个错误文本做告警,容易因文案修改而失效,也难以表达整体趋势。

常见坑

1. 只传播,不清理

这是最危险的错误。线程复用后会发生上下文串线,让排障人员沿着错误的 traceId 得出错误结论。设置和清理必须位于同一个 try/finally 边界。

2. 认为所有异步框架都能自动适配

TaskDecorator 只作用于对应的 Spring 执行器。自行创建的 ExecutorService、公共 ForkJoinPool、消息消费线程以及 Reactor 流都需要各自的上下文方案,不能因一个线程池验证成功就认为全链路完成。

3. 异步异常无人观察

CompletableFuture 中抛出的异常会保存在结果里。如果调用方既不返回、也不 join、不注册 whenComplete,业务可能失败却没有形成正确处置。日志记录不能代替异常传播、重试或补偿。

4. 在 MDC 中塞入过多内容

MDC 会随每条日志输出。放入大段请求体、令牌或敏感信息,不仅增加存储成本,还可能产生安全风险。上下文应当短小、稳定、可检索。

实践建议与总结

上线前可以写一个集成测试:入口设置固定 traceId,提交多个并发任务,分别断言任务内能够读取对应值,并在线程复用后验证 MDC 已清空。生产排查时则按“告警指标定位时间窗口—事件名缩小范围—traceId 串联单次请求—业务主键核对最终状态”的顺序进行。

MDC 解决的不是分布式追踪的全部问题,它只是让日志具备稳定的关联线索。真正可靠的方案包含明确的入口生成规则、线程切换时的复制、所有出口的清理、结构化业务字段,以及与低基数监控指标的配合。把这些边界处理好,异步任务就不会再成为生产日志中的断点。

最后修改:2026 年 08 月 12 日
如果觉得我的文章对你有用,请随意赞赏