☰
MDC日志链路追踪实战:用traceId串联日志,解决跨线程与微服务排障难题
2026/10/19 3:22:03 网站建设 项目流程

1. 为什么需要给日志加上 MDC 链路追踪

1.1 没有 traceId 的排障有多痛苦

先讲一个场景。用户在下单之后反馈"支付成功了,但订单一直显示待支付",你作为后端开发去查日志,打开日志文件一看,几万行日志混在一起,各种并发请求的记录交错出现。你只能靠时间戳硬猜,再靠关键字去 grep 订单号,来回翻了几分钟,依然拼不出"这个订单从创建到支付回调"的完整时间线。运气好一点能从业务代码里找到订单号对应的日志,但运气不好时,日志里根本没有打印业务主键,你能做的只有把那段日志拷贝出来反复看,最后靠猜。

这不是运维能力的问题,而是日志先天缺少一个"身份证"。每一个请求、每一次方法调用,理论上都应该是可以独立串联的一组日志,但在默认情况下,日志之间没有任何关联标记。今天几乎所有 Java 后端都会用 slf4j + logback/log4j2 输出日志,却很少有人注意到 slf4j 本身就带了一个解决这个问题的机制:MDC(Mapped Diagnostic Context)。

MDC 的底层实现其实非常朴素,就是一个 ThreadLocal 包着的 Map。你在请求入口处往里放一个 key-value,比如traceId -> abc123,后续这个线程里所有通过 slf4j 打印的日志,在格式化时都会自动把abc123渲染进日志行。你只需要做三件事:在请求进来时往 MDC 里放 traceId,在日志格式里加一个%X{traceId}占位符,在请求结束时把 traceId 从 MDC 里清掉。就这么点改动,日志立刻从"一堆乱麻"变成"一根根可筛选的线"。

1.2 MDC 是什么,一次说透

MDC 的本质是用空间换排查效率。它是斜杠框架层提供的一个诊断上下文容器,不依赖任何 Spring 组件,也不要求你改业务代码的日志调用方式。你只需要理解几个关键点:

第一,MDC 数据是绑定线程的。它基于 ThreadLocal 实现,意味着在线程池复用、异步任务、消息队列消费这些场景下,子线程默认拿不到父线程里放的 traceId,这是后面会重点讲的坑。

第二,日志框架在渲染日志行的时候会读取 MDC。logback 的 pattern 里%X{key}就是从当前线程的 MDC Map 里按 key 取值,取不到就输出空字符串。所以只要你放了 traceId,所有日志行都会带上;一旦某条日志输出的线程里没有 MDC 值,那条日志就失去了关联性。

第三,MDC 的 API 很简单:MDC.put(key, value)、MDC.get(key)、MDC.remove(key)、MDC.clear()、MDC.getCopyOfContextMap()、MDC.setContextMap(map)。实际项目里最常用的是前后四个,后面两个主要用在跨线程传递场景。

懂了这个原理,再看各种"日志链路追踪"方案就都通了——不管是 Spring Cloud Sleuth、SkyWalking 的 traceId 注入,还是今天要讲的纯 MDC 方案,核心思路都一样:找一个入口,生成一个全局唯一的 ID,塞进当前执行上下文,贯穿整个请求生命周期。差别只在于它们把 ID 放到日志里、放到网络调用里、还是放到分布式调用链的 Span 里。

1.3 这套方案适合谁、能解决什么问题

这套 MDC 方案最适合两类团队。第一类是系统还处于单体应用阶段,但接口数量已经不少、并发量上来了、日志已经开始"打架"的团队。第二类是已经拆了微服务,但暂时没有预算或精力部署 APM 组件,先想用最小成本解决"跨服务日志串联"问题的团队。

它能解决的核心问题很明确:把一个请求涉及的所有日志行串联起来。在单体应用里,它让你能按 traceId 拉出一条请求的完整执行路径;在服务调用链里,它通过 HTTP Header 把 traceId 从一个服务传到下一个服务,让整条链路的所有服务日志都使用同一个请求 ID。排障效率的提升是肉眼可见的:之前靠时间戳去翻日志,现在直接grep traceId就能把所有相关日志行拎出来。

当然它也有边界:MDC 方案不提供调用链的可视化界面,不做性能指标统计,不解决"哪一个环节最慢"这类问题。它只做一件事——给每一条日志一个可检索的标签。但恰恰是这件事,解决了 90% 的日常排障痛点。

2. 链路追踪的整体设计与方案选型

2.1 为什么先用 MDC 而不是直接上 APM

很多团队一提到链路追踪,第一反应是上 SkyWalking、Zipkin 这类重量级方案。这些工具确实强大,能给出完整的调用拓扑、耗时分布、错误率统计,但它们的引入成本也不低:要么需要部署 Agent 探针,要么需要改造接入层,还要搭建独立的存储和展示端。对于一个日志混乱问题还没到"必须上治理平台"程度的团队,这是典型的杀鸡用牛刀——你会花大量时间在部署和运维组件上,而不是在解决业务问题。

MDC 方案的性价比在这里就体现出来了:不引入任何外部组件,不加 Agent,不改 Spring Boot 启动方式,连依赖都不用额外加,因为 slf4j-api 早就在你的 classpath 里了。你写的代码只有一个过滤器类加一行 logback pattern 配置。这部分改造十分钟内能完成,效果却是立竿见影的。将来团队真要上 APM,MDC 的 traceId 设计也不会白做——大多数 APM 工具都兼容自定义 traceId 接入,或者在 Header 层面可以直接对齐。

我个人的建议是:先花十分钟把 MDC 链路追踪做上,用一段时间体验一下"按 ID 捞日志"的爽感,再判断是否需要上完整 APM。绝大多数团队做到这一步就已经解决了最痛的问题,后面的可视化需求可能要到系统规模和问题复杂度都上来了才会真正需要。

2.2 核心设计:一个 Filter 搞定请求入口

整个 MDC 方案里,最关键的设计决策是"在哪一个位置把 traceId 塞进 MDC"。从请求生命周期来看,候选位置有三个:Servlet Filter、Spring MVC Interceptor、AOP 切面。

我推荐用 Servlet Filter 而不是后两者。原因有三点。第一,Filter 是 Servlet 规范层面的组件,执行时机比 Interceptor 更早,能覆盖到 Spring MVC Handler 执行之前的日志,包括请求参数解析、HandlerMethod 参数绑定、过滤器链中后续组件的日志。第二,Filter 能天然覆盖不经过 DispatcherServlet 的请求路径,比如静态资源、健康检查端点,避免出现"部分接口有 traceId、部分接口没有"的割裂感。第三,Filter 与 Spring 解耦,即使你换框架、换 Spring Boot 版本,这个类的核心逻辑都不用动。

在 Filter 里做的事情可以归纳为四步:从 Header 里尝试读取上游传来的 traceId;读到了就用,没读到就自己生成一个;把 traceId 放进 MDC;在finally块里清掉 MDC 值。第三步和第四步的顺序是硬规定,尤其是第四步——MDC 不清会导致严重的"串号"问题,后面会专门讲。

2.3 traceId 从哪来:生成规则与优先级

traceId 的生成规则直接影响到后续排查体验。这里有两种主流做法。

第一种是用UUID.randomUUID().toString().replace("-", "")生成 32 位无横线 UUID。优点是无脑、简单、碰撞概率低到可以忽略;缺点是字符串偏长,日志行会被拉长。我早期项目里就是这个方案,日志读起来"很长一条",但搜索定位时用完整 ID 反而更精确。如果你日志量很大,可以把 UUID 截前 8 位或 16 位,但要注意:8 位 UUID 在日均百万级请求量下碰撞概率会显著上升,再叠加日志留存时间长,同一 traceId 可能出现在多条链路里,反而干扰排查。我的建议是保留完整 32 位,日志长一点没关系,精度最重要。

第二种是参考业界成熟实现,用类似雪花算法生成的 64 位数字 ID。这种 ID 短、趋势递增、适合在分布式环境下保证全局唯一,但需要自己实现或引入工具类,成本稍高。

不管用哪种生成方式,关键规则只有一条:上游如果传了 traceId,下游必须无条件复用。这个优先级不能反,否则网关生成一个、服务 A 再生成一个、服务 B 又生成一个,链路就断了。

2.4 日志格式怎么改

MDC 值最终要落到日志行里,靠的是 logback pattern 里的%X{traceId}占位符。最基本的控制台输出格式是这样:

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n</pattern> </encoder> </appender>

注意[%X{traceId}]这一段的写法。如果当 traceId 没有值时,logback 会输出空字符串,结果日志里[]显得很突兀,还容易被误认为一个空标签。推荐写成[%X{traceId:-}],后面的:-表示默认值,这里默认值留空,日志就会变成[]中间什么都没有。这样在同一个日志文件里,有 traceId 的日志和无 traceId 的日志一眼就能区分开,甚至可以直接用"搜索空 traceId"来找系统里没有接入链路追踪的漏网日志。

如果项目用的是 log4j2,写法变成了%X{traceId}或者%mdc{traceId}的形式,但主流的 Spring Boot 项目默认还是 logback,所以这里先用 logback 举例。改完 pattern 后重启项目,第一个请求进来,日志就会自动带上 traceId。

3. 核心代码实操:从 Filter 到日志模板

3.1 编写 TraceIdFilter 并注册

先看完整的 Filter 代码,这是整个方案的基石:

@Component public class TraceIdFilter implements Filter { private static final String TRACE_ID_KEY = "traceId"; private static final String TRACE_ID_HEADER = "X-Trace-Id"; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String traceId = httpRequest.getHeader(TRACE_ID_HEADER); if (StringUtils.isBlank(traceId)) { traceId = UUID.randomUUID().toString().replace("-", ""); } MDC.put(TRACE_ID_KEY, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(TRACE_ID_KEY); } } }

这段代码里有几个细节值得展开说说。

@Component注解就是把 Filter 注册进 Spring Boot 应用的最简方式,不需要额外写FilterRegistrationBean,也不需要在启动类加@ServletComponentScan。Spring Boot 会自动发现这个 Filter 并把它加到过滤器链里。如果你需要控制它的执行顺序(比如要在某个登录鉴权 Filter 之前拿到 traceId),可以再写一个FilterRegistrationBean并用setOrder调整,但九成场景下默认顺序已经够用。

MDC.put和MDC.remove的配对非常重要。remove写在finally里意味着无论后续过滤器链发生什么——异常、超时、用户直接断开连接——都会执行到清理逻辑。如果只写try/catch/finally中的前半部分而漏掉清理,下面这种事故就会找上门:应用服务器的线程池是复用的,线程处理完请求 A 之后回到池子里,下一个请求 B 被分配到同一线程时,线程的 ThreadLocal 里还可能残留着请求 A 的 traceId,于是请求 B 的所有日志都会打上请求 A 的 traceId。排查问题时你会被这种"幽灵 traceId"活活误导。

3.2 一个更健壮的写法:请求响应头都带上 traceId

基础的 Filter 已经能工作,但我建议你再往前做一步:在响应头里把 traceId 返回出去。改造很简单:

@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; String traceId = httpRequest.getHeader(TRACE_ID_HEADER); if (StringUtils.isBlank(traceId)) { traceId = UUID.randomUUID().toString().replace("-", ""); } MDC.put(TRACE_ID_KEY, traceId); httpResponse.setHeader(TRACE_ID_HEADER, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(TRACE_ID_KEY); } }

加上这一行的收益非常大。前端同事排查接口问题时,打开浏览器开发者工具看一眼响应头,就能把 traceId 直接甩给你。测试同学在自动化用例里也能轻松拿到 traceId,配合日志系统实现"用例失败自动关联日志链路"。这个设计的成本只有一行代码,但把排查问题的协作成本降低了一个量级。

3.3 logback 模板配置示例

假定项目用的是 Spring Boot 默认的 logback,完整的 logback-spring.xml 里除了控制台 appender,通常还会配一个文件 appender。两个 appender 的 pattern 都要加上[%X{traceId:-}],否则就会出现控制台有 traceId、落盘日志没有的尴尬情况:

<?xml version="1.0" encoding="UTF-8"?> <configuration> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId:-}] %-5level %logger{50} - %msg%n</pattern> </encoder> </appender> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_PATH:-logs}/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>${LOG_PATH:-logs}/app.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId:-}] %-5level %logger{50} - %msg%n</pattern> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="FILE"/> </root> </configuration>

这里有个容易被忽略的细节:encoder里的 pattern 会被 logback 预编译成一种高效的日志输出格式,每一条日志都按固定顺序渲染。%X{traceId:-}这个占位符在运行时执行的是从当前线程 MDC 取值的动作,开销极小。所以加 traceId 对日志输出性能的影响可以忽略,真正影响性能的是日志量本身,不是这一个字段。

3.4 RestTemplate 与 Feign 的调用透传

日志改造到这一步,单机内已经可以按 traceId 串联了。但现在的系统几乎没有完全单机的,哪怕内部调用另一个服务的接口,traceId 也应当跟着 HTTP 请求一起传过去,否则一条跨服务链路在服务 A 的日志里叫abc,到服务 B 里变成def,链路又断了。

RestTemplate 的透传方式比较直接,在发起请求前先从当前线程的 MDC 里取 traceId,塞进请求头:

public String callRemote(String url) { String traceId = MDC.get("traceId"); HttpHeaders headers = new HttpHeaders(); if (StringUtils.isNotBlank(traceId)) { headers.add("X-Trace-Id", traceId); } HttpEntity<String> entity = new HttpEntity<>(headers); ResponseEntity<String> response = restTemplate.exchange( url, HttpMethod.GET, entity, String.class); return response.getBody(); }

用 Feign 的话更优雅,实现一个RequestInterceptor就全局生效:

@Configuration public class FeignTraceIdConfig { @Bean public RequestInterceptor traceIdRequestInterceptor() { return template -> { String traceId = MDC.get("traceId"); if (StringUtils.isNotBlank(traceId)) { template.header("X-Trace-Id", traceId); } }; } }

这个拦截器会在 Feign 每次发请求之前执行,自动把当前线程 MDC 里的 traceId 写进 Header。下游服务自己的TraceIdFilter读到这个 Header,会直接把上游的 traceId 放进自己的 MDC,整条链路就串起来了。实际项目中,我建议团队把 Header 的名字统一约定成X-Trace-Id或X-Request-Id,并且写进团队 API 规范文档里,避免各服务各起各的名字,链路又断。

4. 跨线程场景的链路透传方案

4.1 为什么线程池一用,traceId 就没了

上一章讲的方案有一个隐含前提:请求从进入 Filter 到响应返回,全程都在同一个线程里执行。但现实中的业务经常会打破这个前提。最常见的三类场景:

第一类是显式使用线程池。比如订单创建后异步发送短信、异步生成对账文件,代码里直接executor.submit(() -> ...)。这会在新线程里执行任务,而新线程的 ThreadLocal 是全新的,完全看不到父线程 MDC 里的 traceId。

第二类是 Spring 的@Async注解。它背后的执行器是 Spring 管理的线程池,任务进入线程池执行时同样面临 MDC 丢失问题。

第三类是消息队列消费者。生产者发送 MQ 消息时,如果没把 traceId 塞进消息体或消息头,消费者消费消息时就是一个全新的线程上下文,链路在消息边界处断开。

这些问题不解决,就会出现一个很分裂的现象:请求主链路上 traceId 一直在,但异步任务的日志里全是空的[]。排查问题的时候,你查完主链路日志,还得再去无 traceId 的日志堆里面捞异步部分。

4.2 TaskDecorator:最优雅的线程池透传方案

解决线程池 MDC 透传,最推荐的方式是 Spring 提供的TaskDecorator。它可以在任务真正执行前对Runnable做一层包装,正好适合用来"劫持"上下文。

先写一个通用的装饰器:

public class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { Map<String, String> contextMap = MDC.getCopyOfContextMap(); return () -> { if (contextMap != null) { MDC.setContextMap(contextMap); } try { runnable.run(); } finally { MDC.clear(); } }; } }

这段代码的逻辑是先调用MDC.getCopyOfContextMap()把当前线程的 MDC 上下文完整复制一份,然后在子线程执行任务之前调用MDC.setContextMap(contextMap)把上下文灌进去,任务结束后MDC.clear()清空。这里clear()也很关键——子线程同样会被线程池复用,不清理的话,下一个任务会带着上一个任务的 traceId,造成跨任务串号。

配置自定义线程池时把它接上:

@Bean public ThreadPoolTaskExecutor bizExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setTaskDecorator(new MdcTaskDecorator()); executor.setThreadNamePrefix("biz-pool-"); executor.initialize(); return executor; }

注意setTaskDecorator这一行,Spring 的ThreadPoolTaskExecutor原生支持这个属性。如果你项目里用的是 JDK 原生的ThreadPoolExecutor,也可以用ExecutorService的包装方式:

ExecutorService wrapper = new ThreadPoolExecutor(...) { @Override public void execute(Runnable command) { super.execute(mdcTaskDecorator.decorate(command)); } };

两种方式效果一致,按项目现有线程池的类型来选即可。

4.3 @Async 与 MQ 消费场景的 MDC 恢复

@Async的线程池是 Spring 统一管理的。要让异步方法也透传 traceId,最简单的方式是把@Async使用的执行器换成上面配置过的bizExecutor:

@Configuration public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setTaskDecorator(new MdcTaskDecorator()); executor.setThreadNamePrefix("async-"); executor.initialize(); return executor; } }

AsyncConfigurer接口里的getAsyncExecutor()方法决定@Async方法默认使用的线程池,在这里面挂上TaskDecorator,所有异步方法的 MDC 透传问题就统一解决了。

MQ 消费场景要分情况看。以 RocketMQ 为例,消息发送方可以在消息的自定义属性里带上 traceId,消费方在MessageListener里取出 traceId 塞回 MDC:

// 发送方 Message msg = new Message("topic", body); msg.putUserProperty("traceId", MDC.get("traceId")); producer.send(msg);
// 消费方 @Override public void onMessage(Message message) { String traceId = message.getUserProperty("traceId"); if (StringUtils.isNotBlank(traceId)) { MDC.put("traceId", traceId); } try { // 处理业务逻辑 } finally { MDC.clear(); } }

这套做法的本质和线程池透传一样:在上下文切换的边界处,显式地把 traceId 从上游复制到下游自己的上下文里。边界问题解决了,链路就完整了。

5. 常见问题与排查技巧实录

5.1 traceId 为空、丢失的原因排查

接入 MDC 之后,最常被问的问题就是"为什么我的日志里有些有 traceId、有些没有"。排查思路按下面这个顺序来,基本能定位九成问题。

第一步查 Filter 是否真的生效。看应用启动日志里有没有 TraceIdFilter 的注册信息,加个断点调试也行。有些项目用了@WebFilter注解但没加@ServletComponentScan,Filter 根本没被加载,日志自然全空。

第二步查日志格式。确认%X{traceId:-}前后名字一致,key 拼写错一个字母都会取不到值。

第三步查异常分支。看看代码里有没有catch (Exception e)之后直接return,导致没走到 Filter 的finally清理。不过这里要区分两种故障:如果 traceId 空了,那只是日志无标签;如果是 traceId 串了,那才是大问题。

第四步查异步执行。日志里大量[]且集中在异步任务、线程池执行逻辑附近,基本可以断定是 MDC 没有透传到子线程。解决办法就是按第四章的方案给对应线程池加TaskDecorator。

我见过一个比较隐蔽的坑是第三方库内部自己创建的线程。比如某些 HTTP 客户端、定时任务框架会创建自己的线程池,这些线程池你是没法通过TaskDecorator够到的。遇到这种情况,要么在回调方法里手动从某个上下文容器取得 traceId 再塞 MDC,要么接受这些第三方日志没有 traceId 的现实,优先保证自己业务代码的日志链路完整。

5.2 MDC "串号"问题:最坑的一个

MDC 值串到别的请求上,是链路追踪改造之后最容易踩的坑,没有之一。症状是:请求 A 的日志里混进来一段请求 B 的 traceId,或者更诡异的是,明明新进来的请求应该生成新的 traceId,日志里却循着上一次请求的 ID。

产生原因很简单:Tomcat 的工作线程是复用的,一个线程处理完请求后不会销毁,而是回到线程池等待下一个任务。如果 Filter 里只做了MDC.put没有MDC.remove,那么这个线程里残留的旧 traceId 就会被下一个请求继续使用,直到下一次 put 把它覆盖。如果在某一个请求里,业务代码启动异步任务时没有合理处理 MDC 的复制和清理,这种串号还会跨到异步线程里。

规避办法就一条:所有 MDC 的 put 操作必须配一个 finally 里的清理操作。Filter 里这样写,TaskDecorator里这样写,MQ 消费者里也这样写。条件允许的话,还可以在测试环境写一个断言工具,定期扫描日志,发现两条 traceId 同时出现在同一线程的日志记录里就报警,把串号问题扼杀在萌芽。

5.3 性能真相:真的会影响 QPS 吗

有人会担心 UUID 生成和 MDC 操作拖慢请求性能。实测下来,这类担心是多余的。

UUID 生成走的是 JDK 自带的算法,单次生成耗时在微秒级别,即便每秒处理几千个请求,生成 traceId 的总开销也占不到 1% 的 CPU。MDCput和remove本质就是一次ThreadLocal上的 Map 操作,比打印一条日志本身的时间还少几个数量级。真正的性能瓶颈从来不在 traceId 上,而在日志输出本身——如果你把每一条日志都打到磁盘,IO 开销才是大头。

如果你的系统日志量特别大,建议把日志 appender 换成 Logback 的AsyncAppender,让日志写入线程与业务线程解耦。这里要提醒一句:AsyncAppender默认会丢弃 MDC 的部分上下文信息,对 traceId 的处理在不同版本上行为不太一致。实际项目中,如果没有特殊需求,我更推荐把 traceId 放进日志内容之后,配套使用 ELK 或云日志服务,让日志采集异步化,而不是在应用层面对 appender 做过度调优。

5.4 实战排查的几个小技巧

最后分享几个我日常排查问题时的习惯,这些都是在项目里踩过坑之后慢慢总结出来的。

技巧一:在日志聚合系统里建立 traceId 索引。不管是 ELK 还是云厂商的日志服务,把traceId字段单独提取出来建索引,排查问题时直接输入一个 traceId 秒级拉出整条链路的全部日志,再也不用grep几十万行文件。

技巧二:把 traceId 打在前面。日志 pattern 里把[%X{traceId:-}]放在前面,肉眼扫日志时一眼就能看到,不用往右看半天。我见过把%X{traceId}放在%msg后面的配置,排查问题时非常难受。

技巧三:用空 traceId 当"漏网之鱼"扫描器。改造完成之后,定期查一下日志里[]开头的日志记录,凡是这种日志都说明某个执行路径没有接入 MDC。把这些路径一个个收编进来,链路完整度会越来越高。

技巧四:团队统一 Header 名。我建议全团队都采用X-Trace-Id这个 Header 名,网关、各服务、前端都统一。这样不管链路经过多少个服务,透传逻辑都不用各自适配不同名字。

6. 顺着 MDC 还能往哪走

6.1 网关统一起始 traceId

如果系统里已经有网关层(比如 Spring Cloud Gateway 或自研网关),强烈建议把 traceId 的生成职责上移到网关。入口网关对每个请求统一生成 traceId,通过 Header 传给下游所有服务。这样做的好处是:无论内部服务怎么调用,traceId 的起点永远只有一个;下游服务如果发现 Header 里已经有 traceId,直接使用即可,不需要在自己内部再走"生成逻辑"。开始做网关统一之后再回头看,最爽的不是代码,而是排查问题的时候终于不再纠结"这单的 traceId 当初是哪个服务生成的"。

网关里生成 traceId 的代码和 Filter 里基本一样,只是把MDC.put换成了exchange.getRequest()相关 API。注意网关自身打印的日志也要在 pattern 里加上%X{traceId:-},否则网关日志反而成了断点。

6.2 业务埋点与耗时统计

MDC 只能解决日志串联问题,不解决耗时分析。但如果想了解单个请求在某个环节耗时多少,可以顺手在关键业务节点打上带耗时的日志。配合 traceId 筛选之后,你按时间顺序阅读一条链路的所有日志,每个环节的耗时就一目了然。这不算是什么高级功能,但确实是我用下来最实用的扩展。比如下单链路里在"库存校验""价格计算""落库""发消息"各打一条耗时日志,按 traceId 拉出来就是一个小型调用链分析。

6.3 与全链路监控的衔接

如果未来真要上 SkyWalking 或类似系统,MDC 这里的 traceId 并不会白做。很多 APM 工具支持自定义业务 ID 关联,或者你能在日志系统里用 traceId 把 APM 的调用链和业务日志关联起来。也就是说,MDC 方案不是一个"临时凑合"的过渡方案,它是阶梯中的第一步。等团队规模变大、排障需求从"捞日志"升级为"看调用拓扑"时,MDC 的痕迹自然变成新方案的基座。

我在实际项目里体会最深的,反而不是技术本身,而是这个改造带来的协作方式变化。以前前端反馈"接口报错了",后面要来回问半天"哪条日志""大概几点""有没有报错截图"。现在前端直接发一个 traceId,我这边一搜就是整条请求的完整日志,从网关到数据库操作全都能按时间排好。这种感觉,用过一次就回不去了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询