痛点:日志都在,但串不起来
一个典型的多服务电商中台里,用户下单要经过网关、订单服务、库存服务、支付服务。线上出了问题,最痛苦的不是没有日志,而是每条日志都是孤岛:订单服务里报了一个空指针,库存服务里报了一个扣减失败,两边时间只差 200 毫秒,但没有任何字段能把它们关联起来。你只能靠时间戳和 IP 去猜,猜错一次,排查时间翻一倍。
解决思路其实很朴素:给每一次请求生成一个全局唯一的 TraceId,并让它在整个调用链路上传递,让所有日志都带上这个 ID。本文不讲 APM 全家桶,只讲用 Spring Boot 自带的 MDC 机制,从零搭一条贯穿 HTTP 请求、异步线程池和跨服务调用的日志链路。
方案设计:MDC + 过滤器 + 线程池装饰器
整体设计分四层:
- 入口生成:在网关或每个服务的入口过滤器里,从请求头读取上游 TraceId;没有就自己生成 UUID。
- 日志携带:把 TraceId 写进 SLF4J 的 MDC,日志 pattern 统一输出。
- 同步透传:调用下游时把 TraceId 放进 HTTP Header,下游继续带。
- 异步传递:线程池执行任务时,把父线程的 MDC 快照拷贝到子线程。
核心就一句话:MDC 是 ThreadLocal 实现的,链路要跨线程,就必须显式地拷贝和清理。
第一步:入口过滤器生成与回收 TraceId
用 OncePerRequestFilter 保证一次请求只执行一次,无论经过多少层 Filter 转发。关键点:finally 里必须 remove,否则 Tomcat 线程复用会让下一个请求串上别人的 TraceId。
@Component
public class TraceIdFilter extends OncePerRequestFilter {
public static final String TRACE_ID_HEADER = "X-Trace-Id";
public static final String TRACE_ID_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String traceId = request.getHeader(TRACE_ID_HEADER);
if (StringUtils.isBlank(traceId)) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put(TRACE_ID_KEY, traceId);
response.setHeader(TRACE_ID_HEADER, traceId); // 回传给调用方,方便对账
try {
chain.doFilter(request, response);
} finally {
MDC.remove(TRACE_ID_KEY); // 必须清理,防止线程复用串号
}
}
}
日志配置里加上 traceId:
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>
第二步:跨服务透传(RestTemplate / Feign / WebClient)
同步调用下游时,把当前线程 MDC 里的 TraceId 塞进请求头。以 RestTemplate 为例,用 ClientHttpRequestInterceptor 全局生效,比每个调用点手动传头靠谱得多:
@Component
public class TraceIdRestInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String traceId = MDC.get(TraceIdFilter.TRACE_ID_KEY);
if (StringUtils.isNotBlank(traceId)) {
request.getHeaders().set(TraceIdFilter.TRACE_ID_HEADER, traceId);
}
return execution.execute(request, body);
}
}
Feign 场景等价写法是实现 RequestInterceptor,WebClient 则是 ExchangeFilterFunction,思路完全一致:从 MDC 取,往 Header 放。
第三步:异步线程池传递 TraceId(最容易踩坑)
业务里大量使用 @Async、MQ 消费者线程池、定时任务线程池。这些线程和请求线程不是同一个,MDC 默认不会跟着走,子线程里打出来的日志 traceId 全是 null,链路又断了。
解法:给线程池加一个 TaskDecorator,任务执行前拷贝父线程 MDC,执行后清理:
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 提交任务时,父线程的 MDC 快照
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
try {
runnable.run();
} finally {
MDC.clear(); // 防止线程池复用导致 traceId 残留
}
};
}
}
自定义线程池时装配进去:
@Bean("bizThreadPool")
public ThreadPoolTaskExecutor bizThreadPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setTaskDecorator(new MdcTaskDecorator());
executor.setThreadNamePrefix("biz-worker-");
executor.initialize();
return executor;
}
踩坑记录
- 坑 1:线程复用串号。Tomcat 工作线程是复用的,过滤器里只 put 不 remove,第二个请求会沿用第一个请求的 TraceId。排查这类问题的特征是:不同用户的操作日志 traceId 一模一样。修复就是
finally清理,没有商量余地。 - 坑 2:异步线程 traceId 为 null。以为装饰器加了就完事,结果发现
@Async用的是 Spring 默认的执行器,它没有经过自定义线程池。要么替换默认执行器,要么在@Async里显式指定@Async("bizThreadPool")。 - 坑 3:MQ 消费者丢了链路。消息里没有带 traceId。生产者在发送消息前把 traceId 塞进消息头,消费者消费时读出来放回 MDC,链路才能跨系统续上。RabbitMQ 用
MessagePostProcessor加 header,消费者在监听器里MDC.put。 - 坑 4:网关没透传。网关层如果不用上面的过滤器方案,下游从 Header 里拿不到 traceId,每个服务都各自生成,链路照样断。网关也要做「读 Header → 没有则生成 → 透传下游」三件事。
- 坑 5:异步回调里 MDC 过期。任务执行完 clear 之后,如果在回调里还想打带 traceId 的日志,需要在回调前重新 put。用 CompletableFuture 时尤其注意。
最佳实践总结
- TraceId 格式:建议 32 位无横杠 UUID 或「时间戳 + 随机数」,不要用带特殊字符的格式,避免日志解析和 Header 传递出问题。
- 统一约定 Header 名:全链路统一
X-Trace-Id,写进团队规范;网关、服务、MQ 消费者都要复用同一个工具类,别每个服务各写一份。 - 清理和生成同样重要:MDC 是 ThreadLocal,凡是手动 put 的地方必须有配套的 remove/clear,否则就是慢性内存泄漏加日志串号。
- 先 MDC 再上 APM:MDC 方案零依赖、五分钟接入,能解决 80% 的排查痛点;等规模大了再引入 SkyWalking 或 Micrometer Tracing 做链路可视化,届时 TraceId 透传设计可以直接复用,不冲突。
- 排查效率立竿见影:接完之后,日常排错从「翻半小时日志猜关联」变成「按 traceId 一行搞定」,配合日志平台按 traceId 聚合,效率提升一个量级。
链路追踪的本质不是技术炫技,而是把「同一件事」的日志粘在一起。MDC 方案虽然朴素,但它是所有高级观测体系的地基,值得每个后端先把这块做扎实。