生产日志按 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。即使某些底层实现涉及可继承线程本地变量,也不能依赖它解决线程池传播:池中线程通常早已创建,而且会持续复用。
可靠的处理过程应当是:
- 提交任务时复制调用线程的 MDC;
- 执行任务前,把副本设置到工作线程;
- 执行结束后,恢复工作线程原有上下文或清空;
- 无论任务成功还是抛异常,清理动作都必须发生。
在 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 解决的不是分布式追踪的全部问题,它只是让日志具备稳定的关联线索。真正可靠的方案包含明确的入口生成规则、线程切换时的复制、所有出口的清理、结构化业务字段,以及与低基数监控指标的配合。把这些边界处理好,异步任务就不会再成为生产日志中的断点。