做线上问题排查的兄弟应该都经历过这种痛苦:接口报了个错,你想把这条请求完整的前因后果翻出来,结果发现网关入口日志有、业务逻辑日志有、调用下游服务的日志也有,但它们之间没有任何一个共同字段能串起来,只能靠时间戳在几千万条日志里硬猜。SpringBoot 项目里这种“日志全链路追踪”的缺失,往往是线上定位慢的最大元凶。我在实际治理日志过程中,最常用、性价比最高的一招就是用 MDC + TraceId 这套轻量方案:通过 SLF4J 的 MDC 把一次请求的 TraceId 注入线程上下文,再配合日志模板占位符输出,让同一请求贯穿整条调用链的所有日志片段。这篇文章会把接入方法、异步线程的坑、跨服务传递细节完整复盘一遍,适合那些暂时不想引入重量级链路追踪平台、只想把日志快速串起来的后端同学。
1. 为什么单靠时间戳拼日志会把人逼疯
先还原一下真实的排查场景。你负责一个订单服务,用户反馈“下单偶尔失败”,报错日志里能看到一个 NullPointerException,但问题是这个异常只保留了堆栈,没有带任何请求维度信息。你想知道:当前这笔订单是谁的、前端传了什么参数、有没有调库存服务、扣减结果是什么。
于是你去日志平台搜索异常关键字,搜出来几百条一模一样的 NPE。你按时间筛选“最近5分钟”,发现这些异常来自不同用户的请求,日志交织在一起,根本分不清哪几条日志属于同一个请求。你切到库存服务的日志,想把时间对齐看看有没有关联调用,结果又发现两个服务日志时间存在毫秒级偏差,肉眼对齐几乎不可行。
这种痛苦本质上不是日志格式问题,而是日志缺少“一次请求的统一标识”。
1.1 日志系统的核心痛点:上下文不互通
单体时代这个问题不突出。一个请求进来,执行过程基本分布在同一个 JVM 的同一个线程里,从头到尾日志按时间顺序落盘。你只要打开一个文件,从入口 ExceptionHandler 往下翻,基本能还原现场。但进入微服务和异步化之后,一次请求的执行路径至少会跨越三个维度:
- 线程维度:业务逻辑从 Tomcat 工作线程提交到线程池,日志打点落在另一个线程;
- 服务维度:服务 A 通过 HTTP 调用服务 B,服务 B 再调用服务 C,日志落在各自进程;
- 异步维度:请求触发消息推送后立即返回,真正处理放在 MQ 消费线程,和原请求线程已经毫无关系。
这三个维度一旦交叉,单靠时间戳和线程名根本拼不回去。
1.2 为什么是 MDC + TraceId,而不是直接上链路平台
很多团队一提“全链路追踪”,第一反应是要不要上 SkyWalking 或者 Zipkin。但这里有一个很现实的问题:重量级链路追踪平台解决的不只是日志关联,还包括调用拓扑、Span 耗时、异常聚合、采样分析。这些能力很强,但部署维护成本、探针侵入风险、学习门槛也是真实存在的。如果核心诉求只是“线上日志能按请求搜出来,快速定位问题”,那么更务实的路径是先落一套基于日志上下文的轻量方案。
这套轻量方案的核心就两个角色:MDC 和 TraceId。
MDC 的全称是 Mapped Diagnostic Context,翻译过来是“映射诊断上下文”。它本质上是日志框架提供的一个线程级上下文容器,你可以往里面塞 key-value,然后在日志格式里通过占位符动态取出。Spring Boot 默认集成了 SLF4J + Logback,天然支持 MDC。
TraceId 是“链路追踪标识”,代表一次完整业务请求的唯一编号。从用户请求进入系统的第一道门开始生成,后续无论这个请求在内部如何跳转、如何跨服务,都要带着这同一个编号打日志。
一句话概括这套方案的思路:在请求入口为每个请求分配 TraceId 放入 MDC,在日志输出时打印出来,在跨线程、跨服务的边界负责传递,最后在请求结束时清理。 它不解决调用拓扑可视化的问题,但能把 90% 的线上日志排查问题解决掉,而且实现成本极低,全部改造在一个公共模块内完成,业务代码几乎零侵入。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 入口 Filter 的三个关键动作:取头、放 MDC、finally 清理
在 SpringBoot 项目里,最自然的陷阱就是:全链路日志的第一环必须是一个入口过滤器,确保“每个请求进来都能拿到 TraceId”。这里不能放在业务 Service 里做,也不能放在 Controller 层做,因为一旦 Controller 或 Service 抛出异常,你根本来不及预处理这块逻辑。
建议直接基于 OncePerRequestFilter 实现一个 TraceIdFilter,放进公共模块。它要做的事情总结成三个动作。
2.1 拿到 TraceId:优先透传上游,没有则生成
先看代码,再解释为什么这样写。
java复制@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
private static final String TRACE_ID_MDC_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Map<String, String> previousContext = MDC.getCopyOfContextMap();
String traceId = request.getHeader(TRACE_ID_HEADER);
if (!StringUtils.hasText(traceId)) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put(TRACE_ID_MDC_KEY, traceId);
response.setHeader(TRACE_ID_HEADER, traceId);
try {
filterChain.doFilter(request, response);
} finally {
if (previousContext != null) {
MDC.setContextMap(previousContext);
} else {
MDC.clear();
}
}
}
}
这里有几个容易被问到的细节。
第一,为什么先把请求头里的 X-Trace-Id 读出来?因为如果这个请求是从网关或者其他上游服务转发过来的,上游已经生成了 TraceId,你不能在入口重新生成一个,否则整条链路就断成了两截。正确的做法是:有上游 ID 就沿用,没有才自己生成。
第二,TraceId 格式我建议直接用去掉横线的 UUID,32 位十六进制。不要用用户 ID、订单 ID 这类业务字段充当 TraceId,同一用户在高并发场景下可以同时发起多笔请求,业务字段不具备唯一性。也不要为了“好看”做成雪花 ID 或者纳秒时间戳拼接,没必要增加复杂度。32 位随几十六进制足够满足日志关联场景。
第三,response.setHeader 是很多人忽略的。把本次请求生成的 TraceId 通过响应头带回给调用方,让前端开发或者外部接入方提供问题单时能直接附上这串编号。这个体验在实际运维中非常加分。
2.2 OncePerRequestFilter 的注册顺序不能乱
@Component 注册的 Filter 在 Spring Boot 中默认会被 Servlet 容器自动发现并纳入过滤器链。但如果你项目里还有 Spring Security,注意不要把它的执行顺序排在 TraceIdFilter 前面。Security 的过滤器链内部也有大量日志输出,如果 TraceId 还没有放入 MDC,Security 这一段日志可能就是空的,排查认证问题时又会缺上下文。
我一般会在 TraceIdFilter 上明确标注 @Order(Ordered.HIGHEST_PRECEDENCE),保证它尽可能靠前执行。如果项目采用手动注册 Filter 的方式,则要显式配置 FilterRegistrationBean 并设置 setOrder(-100),确保它在安全过滤器之前被加载。
2.3 为什么 finally 里不要只调用 MDC.remove
很多半路教程会教你 finally 里写一句 MDC.remove("traceId"),这个做法在简单场景没问题,但不够严谨。原因有两个。
第一,MDC 是请求线程的上下文容器,里面可能已经被其他组件塞入了业务字段,比如租户 ID、用户 ID、语言区域等。如果粗暴地只移除 traceId,其他字段会残留在线程池线程上,下一个任务如果没清理,就会读到上一个任务的脏数据。我在新代码里会做一个完整的上下文快照恢复,进入 Filter 前把当前 MDC 状态存下来,finally 里恢复原状,没有上下文就 clear。
第二,Tomcat 的工作线程默认是有复用的。线程处理完请求 A 之后不会销毁,而是回收到线程池继续处理请求 B。如果请求 A 在 Filter 里放了 traceId 却不在 finally 清理,线程在处理请求 B 时,MDC 里可能还残留请求 A 的 traceId。等到请求 B 的日志打出来,前面串着 A 的 ID,日志检索立刻乱套。
这条经验是压测时踩过的坑:某个接口线上正常,但压测一上量,就发现同一个 traceId 的日志里同时出现两种完全不同的业务动作。最终定位到原因就是线程复用导致 MDC 上下文串了。所以这里一定要形成肌肉记忆:入口 Filter 放 traceId,就一定在 finally 恢复或者清空。
3. 让日志真正带上 TraceId:Logback 格式改造
Filter 把 TraceId 放进了 MDC,但如果日志模板不认识这个字段,打出来的日志一样没有 ID。这一步改造集中在 logback 配置上,工作量不大,但很多人会在这上面犯格式对不齐的毛病。
3.1 在 Pattern 中输出 MDC 字段
假设你的项目用的是经典的 logback-spring.xml,只需要在 encoder 的 pattern 中加入 %X{traceId}:
xml复制<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %5p [%15.15thread] %-40.40logger{39} [%X{traceId}] - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
这样一行日志就会变成:
code复制2025-01-06 14:23:45.123 INFO [http-nio-8080-exec-3] c.example.order.service.OrderService [a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6] - 开始查询订单,订单号=10086
前面是常规日志信息,中间方括号里那串 32 位字符就是 traceId。我习惯把 %X{traceId} 放在 logger 名称和 - 之间,这样视觉上比较清楚,日志清洗脚本也好分割。
如果你用 Logstash 或者文件收集后进入 ES,在 JSON 日志格式下场景更简单。logstash-logback-encoder 输出 JSON 日志时,默认会把 MDC 中的字段并入 JSON 顶层,下面这段配置的日志里自然会出现 "traceId": "a1b2...",检索系统里直接按 traceId 字段过滤即可:
xml复制<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
3.2 没放 TraceId 的场景,日志格式如何自保
接入这套方案后一定会遇到空值情况:定时任务线程、MQ 消费线程、手动 new 出来的线程里,MDC 里没有 traceId。此时 %X{traceId} 会输出空字符串,日志变成两个中括号紧挨着:
code复制14:23:45.123 INFO [pool-6-thread-1] c.example.order.task.CloseOrderTask [] - 处理超时订单
这种格式不致命,但如果你写日志解析脚本的时候预期 [traceId] 和 [业务线程] 是两个不可缺失的字段,空值会造成解析错位。严谨的解析规则建议:日志检索关键字是 包含 traceId 而不是位置固定,或者日志清洗时对空值做兜底替换。也可以考虑在定时任务这类非 HTTP 入口手动放入一个进程级 UUID,让每轮任务单独成链,这点在第 6 节的清单里会再提。
3.3 日志格式不只要加,还要全团队统一
这个点没什么高深技术,但非常影响实际效果。如果订单服务日志带了 traceId,库存服务日志格式里却没有,你依然没法用一个 ID 跨服务搜日志。所以工具公共模块里通常要把 TraceIdFilter 和日志模板规范一起下发,所有接入方必须使用同一套日志 pattern。团队内部检查时,可以直接用一个生产日志样例,grep 一下有没有 traceId 字段,格式统一了后续搜集检索才有意义。
4. 线程池和 @Async 是 MDC 头号杀手:传递与防串线
入口 Filter 解决的是 Tomcat 工作线程上的日志关联,但它管不到异步线程。这是 MDC + TraceId 落地时最容易翻车的点,我也确实在这里栽过跟头。
4.1 根因:MDC 底层就是 ThreadLocal
要知道为什么多个线程各有一套 MDC,得先明白 MDC 的实现原理。SLF4J 的 MDC 本身是一个门面接口,在 Logback 下真正的实现类持有的是一个和当前线程绑定的上下文 Map。直白点说,你在线程 A 调用 MDC.put("traceId", "xxx"),这个数据只存在于“线程 A”的私有抽屉里;线程 B 不打开线程 A 的抽屉,自然拿不到任何数据。
当代码里出现下面这种常见写法时,问题就来了:
java复制// 主线程在 Filter 中放好了 traceId
// 这里通过线程池执行,MDC 不会自动带过去
ThreadPoolExecutor executor = new ThreadPoolExecutor(...);
executor.submit(() -> {
// 此时新线程中 traceId 是 null
log.info("这里打不出 traceId");
});
这也能解释为什么很多团队在日志里加了 traceId,异步任务的日志依旧没带上——不是没加,是异步线程压根没继承主线程的 MDC 上下文。
4.2 解决方案一:Spring 线程池的 TaskDecorator
最优雅的解决方式是利用 ThreadPoolTaskExecutor 提供的 setTaskDecorator。Spring 在执行每个提交进来的任务之前,会让装饰器先包装一层,这样我们可以在任务执行前把主线程的快照搬过去,执行完再清理,确保线程池复用时不残留。
先定义一个装饰器:
java复制public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
if (contextMap == null) {
MDC.clear();
} else {
MDC.setContextMap(contextMap);
}
try {
runnable.run();
} finally {
MDC.clear();
}
};
}
}
然后在配置线程池时挂上去:
java复制@Bean("asyncExecutor")
public ThreadPoolTaskExecutor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setTaskDecorator(new MdcTaskDecorator());
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
如果项目里用了 @Async 异步注解,在 @EnableAsync 的基础上,给 @Async("asyncExecutor") 指定这个自定义线程池,同样能带上 traceId。
这里的 finally { MDC.clear(); } 必不可少。因为线程池线程是复用的,任务执行完之后如果不清空上下文,下一个任务执行时 MDC 里还是上一个任务的东西。清空以后,下一个任务如果包含快照,会再次 setContextMap 覆盖,否则就是干净的上下文。
4.3 解决方案二:通用 Runnable/Callable 包装
如果你的代码没有统一走 Spring 线程池,而是自己 new 了一堆线程执行任务,用上面方案就不一定能覆盖到。这时候可以做一个通用工具类,包一层业务任务:
java复制public class TraceRunnable implements Runnable {
private final Runnable delegate;
private final Map<String, String> mdcContext;
public TraceRunnable(Runnable delegate) {
this.delegate = delegate;
this.mdcContext = MDC.getCopyOfContextMap();
}
@Override
public void run() {
if (mdcContext != null) {
MDC.setContextMap(mdcContext);
}
try {
delegate.run();
} finally {
MDC.clear();
}
}
}
使用的时候只需要把原来提交任务的地方包一层:
java复制executor.submit(new TraceRunnable(() -> {
log.info("这里的日志能带上 traceId");
}));
建议直接把这类工具类放到公共模块,团队所有人都使用同一个包装器。如果有场景需要 Future 返回值,再对应写一个 TraceCallable<T> 即可。
4.4 串号问题的复现思路
部署后自己验证有没有串号,有一个很实用的方法:找一个接口,里面用线程池提交两个不同类型的任务,每个任务里 sleep 200 毫秒再打日志,线程池配置最小线程数为 1。然后连续请求这个接口两次,观察日志里线程名相同、traceId 不同的两批日志。如果第二个请求的任务日志打出的 traceId 还是第一个请求的 traceId,说明装饰器缺失或者清理时机不对。
我还要提醒一个坑:有些同学在 TraceIdFilter 的 finally 里已经 MDC.clear() 了,但耗时任务的提交发生在 Filter 内部的某个异步回调里,主线程已经释放,任务在池子里排队。这时候装饰器快照是在 submit 时取的,而不是任务真正开始跑的时候取的。但提交动作发生在请求线程内,所以 submit 时的 MDC 是完整的,恰恰能正确传递。真正要注意的是不要在提交任务前手动调用 MDC.clear(),否则快照拿到的就是空 Map。
5. 跨服务调用:TraceId 靠 HTTP Header 接力跑完整个调用链
异步线程问题解决后,第二条链路是跨服务。如果你的订单服务调库存服务,库存服务再调商品服务,两者日志在两个进程里,MDC 不可能自动同步,必须在 HTTP 调用时通过 Header 把 TraceId 传给下游。
5.1 先定义好团队内部的 Header 约定
通常的做法是约定一个内部 Header 名,比如我这边统一叫 X-Trace-Id。
这里有一条血泪经验:Header 名里不要用下划线。 有些负载均衡器、网关或者 Servlet 容器对 Header 名做了特殊处理,带下划线的字段很可能被过滤或者转换成别的名字,导致你辛辛苦苦塞进去的 ID 到达对端时直接消失。所以务必使用中划线连接的 Header 名。
5.2 RestTemplate 场景
以前项目里大量使用 RestTemplate 调内部服务。如果目标是每次请求都自动带 TraceId,最省事的方式是注册一个 ClientHttpRequestInterceptor。
java复制public class TraceIdClientInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String traceId = MDC.get("traceId");
if (traceId != null) {
request.getHeaders().set("X-Trace-Id", traceId);
}
return execution.execute(request, body);
}
}
使用的时候:
java复制@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setInterceptors(Collections.singletonList(new TraceIdClientInterceptor()));
return restTemplate;
}
如果不想对全项目生效,也可以在创建单个 RestTemplate 实例时绑定这个拦截器,逻辑是一样的。
5.3 OpenFeign 场景
在 Spring Cloud 体系内,OpenFeign 是更常见的内部服务调用方式。Feign 提供了 RequestInterceptor 扩展点,可以在发送请求前修改请求模板。
java复制@Configuration
public class FeignTraceIdConfig {
@Bean
public RequestInterceptor traceIdRequestInterceptor() {
return requestTemplate -> {
String traceId = MDC.get("traceId");
if (traceId != null) {
requestTemplate.header("X-Trace-Id", traceId);
}
};
}
}
这段配置放进公共模块或者 Feign 接口扫描路径上的配置类里,所有 Feign 请求都会自动带上当前线程 MDC 中的 traceId。下游收到后,TraceIdFilter 再去读这个 Header,就能把两个服务的日志串到同一链条上。需要注意,如果下游是外部第三方服务,这个内部协作 Header 是否要透传过去,建议先确认对方要不要。不少团队曾因为把所有内部 Header 一股脑透传给第三方,对方把 X-Trace-Id当作业务参数处理,结果引发过字段冲突之类的诡异问题。
5.4 WebClient 场景
如果你的服务是 Spring WebFlux 技术栈或者使用 WebClient 调用下游,则不能用上面基于 ThreadLocal 的拦截器方案。因为 WebFlux 是响应式链路,跨线程切换更多,通常需要利用 Reactor 的 context 传递机制或在构造请求时手动取上游 Header。这里不做展开,但必须提醒:在 Servlet 技术栈项目中不要盲目引入 WebClient + MDC 的组合,上下文传递方式完全不一样。
5.5 服务端入口如何配合下游
上游只要把 X-Trace-Id 发出来,下游入口的 TraceIdFilter 就已经支持从 Header 读取了。整套流程连起来是这样:
- 请求进入服务 A,TraceIdFilter 从 Header 读取或生成 TraceId,放进 A 的 MDC;
- A 内部调用服务 B 时,Feign/RestTemplate 拦截器从 A 的 MDC 取 TraceId,放进 HTTP Header;
- 请求进入服务 B,B 的 TraceIdFilter 从 Header 读到同一个 TraceId,放进 B 的 MDC;
- B 的日志带上同一个 traceId,落盘后按 traceId 搜索即可拼出完整链路。
这套链路的关键是“每个服务都必须有同名的入口 Filter 和同名的调用侧拦截器”,少了任意一环节,链路就断在日志里。所以公共模块把这三件套(Filter、Feign 拦截、RestTemplate 拦截)做成 starter 依赖是最合理的。
6. 这套方案上线前,我复查过的几个盲区
每次把方案交付给新团队接入后,我都会整理一份自查清单。因为代码看起来很简单,真正部署后跑一跑,总有几个场景因为没有覆盖到位又出洋相。
6.1 定时任务和 MQ 消费场景
定时任务不会经过 HTTP 入口,所以 TraceIdFilter 对它们完全无效。你到了凌晨跑的批次任务,日志里 traceId 一列永远是空的。这时有两个选择:任务量少就直接忽略,让日志行空着;如果希望一次任务执行过程可以整段检索,可以在任务入口包一层装饰器,手动生成一个新的 TraceId 放到 MDC,任务结束清除。Spring 的 @Scheduled 没有内置 MDC 透传机制,需要自己写 AOP 或包装 Runnable。我的建议是只在任务内容比较复杂、又经常出问题需要追溯的任务上加,不必一刀切。
MQ 消费又不一样。很多消息中间件允许生产者发送时带自定义 Header,可以约定 broker 生产端把 TraceId 放进消息 Header,消费者启动时从消息 Header 读出来,然后放入 MDC。这样用户下单产生的 TraceId 可以一直贯穿到异步清关、积分配送等后续动作。
6.2 日志检索系统是否把 traceId 作为字段索引
日志打出了 traceId,如果你的日志平台只是原样全文存储,那按 traceId 搜索时实质走的是全文本搜索,日志量一大性能会很差。上线前要确认:日志采集器是否解析了日志字段,traceId 是否建立为索引字段。否则日志量上来之后,按 traceId 过滤需要扫全文,检索又变慢,体验回到解放前。
文本搜索并不是不能用,我见过很多中小团队初期直接在文件服务器上 grep 日志也维持了一阵子,但一旦服务多、实例多,一定还是要落到日志平台或至少日志文件按服务分目录集中采集。坦白说,这套方案真正价值大不大,取决于检索端能不能快速按 traceId 筛出完整日志流。
6.3 网关层是 WebFlux 时不能直接套 Servlet Filter
不少人把公共模块引入网关后发现没生效,排查半天才想起网关是 Spring Cloud Gateway,底层 WebFlux,根本不走 Servlet 过滤器链。网关这类响应式应用要么写一个 WebFilter,要么简单粗暴在全局过滤器里操作 Reactor Context。所以接入前先分清项目技术栈,Servlet 项目套 Servlet Filter,WebFlux 项目单独处理,不要把 Servlet 的 filter 拿来硬套。
6.4 线上排查时的黄金动作
最后分享一个我几乎是条件反射式的排查动作。线上出现问题时,让用户或者前端把响应头里的 X-Trace-Id 发过来,然后我打开日志平台,直接搜这个 ID,就能看到这条请求在不同服务里留下的全部日志片段。如果某个服务搜不到,说明要么当时下游没被调用,要么 TraceId 传到那个服务时就断了。根据断点位置,能快速判断是 Filter 没生效还是 Feign 拦截器没生效,通常一分钟内可以缩小排查范围。
从个人体感来说,MDC + TraceId 这套东西本身不复杂,但非常吃“落地细节”。它不像引入一个大而全的链路线路平台那样有明确产品界面,所有的正确性都体现在日志片段里。每个团队接完这套方案后,只要把入口过滤器、线程池装饰器、HTTP Header 传递三条链路都完整跑通,再结合日志平台的检索能力,线上排查效率的改善是肉眼可见的。
