Java 全链路日志追踪:MDC 与 TraceId 的完整实现
学完这篇你能拿到一套可以直接抄进项目的方案:一次 HTTP 请求从进门到落库、从同步到异步、从本机到下游服务,日志里都带着同一个 TraceId,出问题按一个 ID 就能捞出全链路日志。
第一步:先搞清楚 MDC 和 TraceId 各干什么
TraceId 是"一次请求的身份证",一个随机字符串,全链路共享。MDC(Mapped Diagnostic Context)是 SLF4J/Logback 提供的线程级诊断上下文,底层就是一个 ThreadLocal 的 Map。把 TraceId 塞进 MDC 后,日志框架在输出每行日志时会自动把这个值填到 pattern 里,你的业务代码一个参数都不用传。
所以整体链路是:**Filter 生成/读取 TraceId → 存入 MDC → 日志 pattern 自动带上 → 请求结束时清理**。
第二步:配置 logback 输出 TraceId
在 `logback-spring.xml` 的 pattern 里加上 `%X{traceId}`:
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
`%X{traceId:-}` 里的 `:-` 是 Logback 的默认值语法,冒号后面为空表示"取不到就输出空字符串"。这样异步线程、定时任务等没有 TraceId 的场景不会打出 `traceId_IS_UNDEFINED` 这种噪音。
第三步:写入口 Filter,生成并清理 TraceId
public class TraceIdFilter extends OncePerRequestFilter {
public static final String TRACE_ID = "traceId";
public static final String HEADER = "X-Trace-Id";
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
FilterChain chain) throws ServletException, IOException {
String traceId = req.getHeader(HEADER);
if (traceId == null || traceId.isEmpty()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put(TRACE_ID, traceId);
resp.setHeader(HEADER, traceId); // 方便前端/网关对账
try {
chain.doFilter(req, resp);
} finally {
MDC.remove(TRACE_ID); // 必须清理,见下方注意
}
}
}
注册时把顺序调到最靠前,保证它包住后面所有 Filter 和 Controller:
@Bean
public FilterRegistrationBean<TraceIdFilter> traceIdFilter() {
FilterRegistrationBean<TraceIdFilter> bean = new FilterRegistrationBean<>(new TraceIdFilter());
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return bean;
}
`finally` 里用 `MDC.remove()` 而不是 `MDC.clear()`:clear 会把当前线程里别的业务埋点(比如 userId、租户号)一起清掉。
注意:Tomcat 的请求线程是复用的。如果漏掉 finally 里的清理,下一个请求进来会继承上一个请求的 TraceId,日志串线,排查时会被带到沟里。
第四步:让异步线程也带上 TraceId
MDC 是 ThreadLocal,线程池里的线程拿不到父线程的上下文,这是最常见的翻车点。Spring 的 `ThreadPoolTaskExecutor` 有现成的钩子:
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> ctx = MDC.getCopyOfContextMap();
return () -> {
if (ctx != null) MDC.setContextMap(ctx);
try {
runnable.run();
} finally {
MDC.clear();
}
};
}
}
// 线程池上挂一下
executor.setTaskDecorator(new MdcTaskDecorator());
注意:`@Async` 如果不指定执行器,走的是 Spring 默认的 `SimpleAsyncTaskExecutor`,TaskDecorator 不生效。要么显式 `@Async("myExecutor")`,要么在容器里定义一个 `ThreadPoolTaskExecutor` 类型的 Bean 让 Spring Boot 自动接管。
第五步:跨服务传递,把 Header 带出去
只要下游也装了同一套 Filter,它就会优先读 `X-Trace-Id` 而不是自己新生成,链路自动接上。
RestTemplate 加拦截器:
restTemplate.getInterceptors().add((request, body, execution) -> {
String traceId = MDC.get("traceId");
if (traceId != null) request.getHeaders().add("X-Trace-Id", traceId);
return execution.execute(request, body);
});
Feign 更简单,加个配置类:
@Bean
public RequestInterceptor traceInterceptor() {
return template -> {
String traceId = MDC.get("traceId");
if (traceId != null) template.header("X-Trace-Id", traceId);
};
}
发 MQ 消息时,把 TraceId 写进消息 Header(比如 RocketMQ 的 userProperty、Kafka 的 header),消费端取出来再 `MDC.put` 回去。网关(Spring Cloud Gateway 或 Nginx)最好统一生成一次 TraceId 并透传,避免各服务各生成一个。
注意:TraceId 别用完整 UUID 带横杠的 36 位,全链路日志量大的时候很占体积。用 32 位无横杠,或者雪花算法、日期前缀 + 短随机串,都能兼顾可读性和长度。
第六步:日志聚合,按 ID 检索
本地只做到"日志里有 TraceId",真正好用的是集中检索。把日志通过 Filebeat / Fluent Bit 送到 ELK 或 Loki,索引里 `traceId` 设为 keyword 字段,排查时直接 `traceId: "xxxx"` 一次查出跨服务的所有日志。这时建议顺带把 `spanId`、`userId`、请求 URI 也塞进 MDC,检索维度会更丰富。
注意:如果项目已经引入了 Micrometer Tracing 或老的 Spring Cloud Sleuth,MDC 里的 `traceId` 和 `spanId` 由它维护,不要自己再生成覆盖,直接复用 `MDC.get("traceId")` 即可。自己造一套会和框架的采样、上报打架。
小结
- TraceId 是请求身份证,MDC 是把它自动写进每行日志的载体,两者配合才不用手写参数。
- 入口 Filter 负责生成、写响应头、`finally` 里 `MDC.remove` 清理,顺序调到最高优先级。
- 线程池必须用 `TaskDecorator` 或手动 `MDC.setContextMap` 传递,`@Async` 要显式指定执行器。
- 跨服务靠 `X-Trace-Id` 请求头透传,RestTemplate/Feign/MQ 三处都别漏。
- 想真正好用,最后的落点是日志聚合平台的 keyword 检索,不是本地 grep。
- 已有 Sleuth / Micrometer Tracing 的项目,复用不要重造。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





