☰
SpringBoot日志追踪实战:用MDC+拦截器实现全链路TraceId
2026/10/9 5:53:43 网站建设 项目流程

1. 从一次线上事故说起:为什么日志追踪非做不可

今年年初我接手了一个“老带新”的SpringBoot电商项目,四个服务互相调用,平时开发联调问题不大,一上生产就乱了套。有一次商品库存扣减异常,用户反馈“明明支付成功但订单显示未支付”,我打开生产日志按关键字一搜,订单服务、支付服务、库存服务各打各的日志,时间线完全对不上,根本没法判断是哪个服务先出错、异常是在哪一环被吞掉的。最后靠人工对着时间戳一条条捋,耗了整整一个下午。

那次之后我老实了:分布式场景里,TraceId这件事不能靠“有需要再补”,得在最开始就设计进去。用一个全局唯一的标识贯穿一次请求的完整生命周期——客户端发起、网关转发、各微服务处理、最终落库——所有日志统一携带这个ID,排查时就只要grep traceId一把梭,谁先谁后、在哪一环失败,一目了然。

这篇文章我想把SpringBoot里接入TraceId的完整思路和实操过程分享出来,包括MDC的原理、拦截器和过滤器的写法、跨服务传递的实现、跨线程丢失的坑、以及版本不兼容的那些破事。内容不挑框架,不依赖额外组件,纯手写一套轻量级方案,适合所有用过SpringBoot、被排查日志折磨过的后端开发者。你跟着做一遍,以后线上定位问题能快十倍。

2. 拆开TraceId的一生:核心原理与思路设计

2.1 什么是TraceId:给一次请求发一张“身份证”

在单体应用时代,一次请求从进入Controller到返回结果,全程都在一个服务里、一个线程里,日志天然有序,靠Thread.currentThread().getName()都能勉强定位。但微服务化之后,一次用户点击背后可能是“网关 → 订单服务 → 库存服务 → 支付服务”三四个节点接力,每个服务一个线程、一份日志文件,甚至部署在不同机器上。此时如果每条日志没有共同的标记,你看到的只是孤立的碎片。

TraceId就是这样一张“身份证”:一次外部请求进入系统时生成一个全局唯一ID,比如8f2e1c4a9b3d47f6a5c8e1d3b9f2a670,从这个请求衍生出的所有内部调用、所有日志输出,都携带同一个ID。它不代表任何业务语义,目的只有一个:把所有相关日志粘在一起。你可以把它理解成快递单号,一个包裹从发货到签收,所有中转站的扫描记录都挂在同一个单号下。

2.2 MDC机制:日志框架留给我们的一扇暗门

实现TraceId打印最常见的方案是借助日志框架的MDC(Mapped Diagnostic Context,映射诊断上下文)。这个概念听起来高大上,本质就是一个绑定在当前线程上的ThreadLocal<Map>,你可以往里塞键值对,日志框架在打印每条日志时,会主动去这个Map里读取指定key的值,拼到输出格式里。

以Logback为例:

MDC.put("traceId", "8f2e1c4a9b3d47f6a5c8e1d3b9f2a670"); log.info("订单服务开始处理");

然后在logback-spring.xml的pattern里加上%X{traceId},日志就会变成:

2025-01-12 14:23:45.678 [http-nio-8080-exec-3] [8f2e1c4a9b3d47f6a5c8e1d3b9f2a670] INFO c.e.order.OrderServiceImpl - 订单服务开始处理

关键点在于:你不需要在每次打印日志时手动传入TraceId。比如你有50处日志,不用改成log.info("... {}", traceId)这种写法,只要在进入请求时往MDC里放一次,所有日志自动带上。这就是MDC的设计初衷——用一个线程上下文变量,让日志代码零侵入地获得诊断信息。省事,且不易漏。

2.3 全链路追踪的基本套路:生成、存储、透传、打印

把整个方案拆开,其实就四件事:

  • 生成:一次请求进来系统时,创建一个全局唯一的TraceId。如果上游已经传了TraceId(比如网关生成好了),则复用上游的值,避免同一请求在系统里出现多个ID。
  • 存储:把TraceId写入MDC(本质是当前线程的ThreadLocal),让当前线程内所有日志都能引用到它。
  • 透传:跨服务调用时,把TraceId放进HTTP请求头、消息队列消息头或Dubbo的attachment里,传给下一个服务;跨线程异步执行时,通过装饰器把MDC内容复制到子线程。
  • 打印:日志配置里引用MDC中的TraceId字段,输出到每条日志。

这里的难点集中在“存储”和“透传”两步。存储要选对拦截时机,太晚的话早期的日志(比如Filter过滤器里的日志)拿不到ID;透传要覆盖所有发起调用的组件,漏了任何一个,链路就断了。

2.4 为什么不用“日志里加参数”这种笨办法

我见过有些同事图省事,直接定义private String traceId;,然后所有日志都写log.info("用户下单 traceId={}", traceId)。短期能跑,但隐患不小:你有10个类、50个方法,就得传参50处;只要有一个方法漏传,那一段日志就悬空了;如果这50个方法里有一个是新线程,这个参数还得想办法穿过去。日志代码和业务代码严重耦合,后面看代码想死的心都有。

MDC方案的核心优势是解耦:业务代码里该干嘛干嘛,日志格式和链路ID的获取完全交给框架层处理。你只需要在“请求入口”和“调用出口”这两个横切点上动手,这正好是SpringBoot拦截器和过滤器擅长的领域。

3. 动工前的准备:版本选择与思路定调

3.1 版本问题真的很恶心,先把这个坑填平

很多人在网上搜教程,一搜“TraceId SpringBoot”就出来一堆Sleuth相关的文章,照着配发现根本跑不起来,为什么?版本不匹配。

SpringCloud Sleuth是早期微服务链路追踪的标配方案,但它在Spring Boot 3.x时代被移除了,官方转向Micrometer Tracing(也就是Micrometer的trace模块,配合OpenTelemetry或者Zipkin)。如果你用Spring Boot 2.x,Sleuth是亲儿子;用Spring Boot 3.x,Sleuth直接不给用,强行引入反而导致启动报错或链路ID不生效。热词里有人吐槽“SpringBoot版本太高”,其实不是版本高的问题,是你拿老方案套新版本。

我的建议是:不要一上来就引入Sleuth或Zipkin这类重量级组件。TraceId的核心价值是“日志可串联”,为了这个目标,自己手写一个拦截器加工具类,三五十行代码就能搞定,在任何版本下都不会翻车。如果你以后要对接SkyWalking、OpenTelemetry这类专业链路追踪系统,再切换到Micrometer Tracing也不迟——因为TraceId的生成、存储、透传原理是通用的,你亲手写一遍之后,看那些框架的源码文档会特别轻松。

3.2 各版本下的推荐方案对比

我整理了一个选型对照表,方便你根据自己的项目情况对号入座:

项目情况推荐方案理由
Spring Boot 2.x,已有Sleuth保留Sleuth无缝集成,自动生成TraceId和SpanId
Spring Boot 2.x,无Sleuth自写拦截器+MDC轻量,无版本风险,代码完全可控
Spring Boot 3.x,刚新建自写拦截器+MDC 或 Micrometer TracingMicrometer Tracing功能更全,但需要额外学习成本
已有多个服务,网关透传自写方案+请求头透传灵活性最高,对网关侵入小
需要上报链路拓扑Micrometer Tracing + Zipkin可可视化服务调用链,比单纯日志串联更进一步

我的主力框架选的是“自写拦截器+Feign/RestTemplate拦截器”,这套方案不挑Spring Boot版本,甚至Spring MVC和Vert.x都能借鉴。老规矩,先讲原理再给代码。

4. 实操:SpringBoot中接入TraceId的完整过程

4.1 第一步:写一个入口拦截器,生成TraceId并写入MDC

我选择用Spring MVC的HandlerInterceptor作为入口,拦截所有HTTP请求。为什么不用Filter?我认为对于大多数内部服务来说,HandlerInterceptor的时机足够早,能覆盖Controller及以下所有层的日志,而且能拿到HandlerMethod信息,后面要做灰度、鉴权也顺手。如果你的网关或过滤器里有日志需要打印,那就额外加一层Filter,把生成逻辑往上提,后面会专门说。

先定义一个工具类,负责TraceId的生成和MDC读写,方便多个地方复用:

public class TraceIdUtils { public static final String TRACE_ID = "traceId"; private static final String HEADER_NAME = "X-Trace-Id"; public static String getTraceId() { return MDC.get(TRACE_ID); } public static String createTraceId() { return UUID.randomUUID().toString().replace("-", ""); } public static void putTraceId(String traceId) { MDC.put(TRACE_ID, traceId); } public static void removeTraceId() { MDC.remove(TRACE_ID); } public static String getHeaderName() { return HEADER_NAME; } }

生成方式我直接用UUID去掉横线,32位十六进制字符串。实际生产里有条件的话也可以用雪花算法,生成64位数字字符串;UUID的好处是无序、碰撞概率极低,缺点是偏长。32位在一行日志里占的篇幅还可以接受,就先这样用。

然后是拦截器本体:

public class TraceIdInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 优先从请求头获取上游传入的TraceId,这是链路串联的关键 String traceId = request.getHeader(TraceIdUtils.getHeaderName()); if (traceId == null || traceId.isEmpty()) { traceId = TraceIdUtils.createTraceId(); } TraceIdUtils.putTraceId(traceId); // 顺手把TraceId放到响应头,方便前端排查或下游调用方继续传递 response.setHeader(TraceIdUtils.getHeaderName(), traceId); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束必须清理,否则线程池复用线程时TraceId会串线 TraceIdUtils.removeTraceId(); } }

这段代码有几个关键点,拿出来细说:

  • 优先取请求头:如果网关或者其他上游服务已经把TraceId传过来了,就直接复用。如果一个请求从头到尾不换ID,日志才是“串”起来的。如果每个服务都自己新生成一个,那TraceId等于白做。
  • 响应头回传TraceId:这是很多人忽略的细节。前端调接口报错时,你把TraceId在错误响应体里带回来,用户报错时直接把ID发给运维,比你拿时间去大海捞针高效太多。
  • afterCompletion里清理MDC:高并发下SpringBoot容器线程是复用的,当前请求如果不把MDC里的TraceId清掉,下一个被同一个线程处理的请求就会沿用上一个的TraceId,排查问题直接蒙圈。清理这步无论如何不能省。

4.2 第二步:把拦截器注册到WebMvcConfigurer

写好了拦截器要让它生效,Spring Boot里通过WebMvcConfigurer注册:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new TraceIdInterceptor()) .addPathPatterns("/**") .order(1); } }

用addPathPatterns("/**")拦所有请求,不用排除任何路径,因为生成TraceId是最基础的横切逻辑,对健康检查、静态资源也无害。order(1)表示拦截器执行顺序,数值越小越靠前,目前只有一个拦截器,写上也行,将来加鉴权拦截器时能明确优先级,比如鉴权放在TraceId拦截器之后。

4.3 第三步:logback配置里加上TraceId输出

如果你的项目用的是Logback(Spring Boot默认),打开src/main/resources/logback-spring.xml,在pattern里加上%X{traceId}。这是我实际在用的控制台输出格式:

<configuration> <appender name="STDOUT" 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> <root level="INFO"> <appender-ref ref="STDOUT"/> </root> </configuration>

关键就是这个[%X{traceId}],它会去当前线程的MDC里取key为traceId的值。如果当前线程没有TraceId,这里会显示为空,输出类似[],不影响日志格式。这里有一个排查问题时的经验:如果日志里看不到TraceId,十有八九是pattern里的key和你MDC.put时的字符串不一致,一个用traceId一个用trace-id,肉眼很难发现。

如果你还要同时打印到文件,建议给文件appender也加上%X{traceId},而且最好单独建一个“全量日志目录”,按天滚动。运维同学用grep "traceId=xxx"就能定位特定请求的全部记录。

4.4 第四步:本地跑起来,验证日志效果

写一个极简的Controller来验证效果:

@RestController public class DemoController { private static final Logger log = LoggerFactory.getLogger(DemoController.class); @GetMapping("/order/{orderId}") public String getOrder(@PathVariable String orderId) { log.info("收到查询订单请求,orderId={}", orderId); // 模拟内部业务处理 List<String> skus = querySkus(orderId); log.info("查询到订单商品,共{}个", skus.size()); return "success"; } private List<String> querySkus(String orderId) { log.info("开始从库存服务查询商品"); // 这里原本会远程调用,demo里先代替 return Arrays.asList("1001", "1002"); } }

启动项目,用curl模拟请求:

curl http://localhost:8080/order/10086

观察控制台日志:

2025-01-12 14:30:21.123 [http-nio-8080-exec-1] [a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6] INFO c.e.demo.DemoController - 收到查询订单请求,orderId=10086 2025-01-12 14:30:21.125 [http-nio-8080-exec-1] [a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6] INFO c.e.demo.DemoController - 开始从库存服务查询商品 2025-01-12 14:30:21.126 [http-nio-8080-exec-1] [a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6] INFO c.e.demo.DemoController - 查询到订单商品,共2个

同一个请求的日志全部带上了同样的TraceId,第一步成功了。再试一次会看到不同的TraceId,每条请求都能单独区分。

接下来要解决的是“服务间传递”,这也是TraceId能不能真正发挥作用最关键的一环。

4.5 第五步:跨服务传递,让TraceId在HTTP调用中接力

在微服务架构里,一个请求要经过多个服务,如果订单服务调库存服务时不在请求头里带上TraceId,库存服务的日志就是“无ID”状态,链路到中间就断了。传递的原理特别简单:发起方把当前MDC里的TraceId写入HTTP请求头,接收方从请求头里读出来放回MDC。接收方代码我已经在拦截器里写了,第一步就实现了;现在补发起方。

先看Feign场景,写一个Feign的RequestInterceptor:

@Configuration public class FeignConfig { @Bean public RequestInterceptor requestInterceptor() { return requestTemplate -> { String traceId = TraceIdUtils.getTraceId(); if (traceId != null && !traceId.isEmpty()) { requestTemplate.header(TraceIdUtils.getHeaderName(), traceId); } }; } }

这个配置类会在Feign每次发请求前自动执行,把当前线程MDC里的TraceId塞到请求头的X-Trace-Id上。注意这个TraceIdUtils.getTraceId()拿的是发起调用这条线程的MDC值,而Feign默认的调用线程通常和Controller线程是同一个(除非你给Feign配置了独立的线程池),所以能正确取到。

如果你用的是RestTemplate,则要写一个ClientHttpRequestInterceptor:

public class TraceIdHeaderInterceptor implements ClientHttpRequestInterceptor { @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { String traceId = TraceIdUtils.getTraceId(); if (traceId != null && !traceId.isEmpty()) { request.getHeaders().set(TraceIdUtils.getHeaderName(), traceId); } return execution.execute(request, body); } }

然后在创建RestTemplate时加上这个拦截器:

@Configuration public class RestTemplateConfig { @Bean public RestTemplate restTemplate() { RestTemplate restTemplate = new RestTemplate(); restTemplate.getInterceptors().add(new TraceIdHeaderInterceptor()); return restTemplate; } }

到这里,HTTP形式的服务调用,TraceId就能从上游传到下游了。你用上面拦截器的“优先取请求头逻辑”,下游服务的日志就会自动沿用上游的TraceId。这点很重要:链路是全局唯一的ID,不是每个服务一个新ID。

4.6 额外加餐:异步线程池里的TraceId传递

很多SpringBoot服务为了提高并发,设置了异步线程池,比如用@Async、CompletableFuture、或者自己维护的线程池。但MDC本质是ThreadLocal,子线程默认拿不到父线程的MDC内容。这会导致一个现象:Controller日志有TraceId,异步任务里的日志TraceId全空,排查时又抓瞎了。

解决思路是“装饰器模式”:在提交任务时把当前线程的MDC快照复制一份,放进任务实例里,任务真正执行前把这些KV重新塞到子线程的MDC中,执行完再清理。基于这个思路,我封装了一个TaskDecorator,交给线程池使用:

@Component public class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { // 提交任务时,获取父线程的MDC快照 Map<String, String> contextMap = MDC.getCopyOfContextMap(); return () -> { if (contextMap == null) { MDC.clear(); } else { MDC.setContextMap(contextMap); } try { runnable.run(); } finally { MDC.clear(); } }; } }

用法很简单,在自定义线程池配置里指定TaskDecorator:

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

这样凡是经过asyncExecutor提交的任务,ThreadLocal上下文都会被正确隔离和传递。注意这里的实现思路是:快照在提交时取,恢复在执行时做。如果任务在队列里排了很久,父线程的MDC里有TraceId且一直有效,那没问题;如果你修改了父线程的MDC值,队列里的任务仍是提交时的旧值,严格意义上会有偏差,但在“一次请求一个TraceId”的模型下,这种偏差基本不会影响排查。

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

这部分整理一下我踩过的坑和帮同事排查时遇到的高频问题,直接看表格更清晰:

现象根因解决办法
日志里全是[],没有TraceId拦截器未注册,或MDC key拼写与pattern不一致检查拦截器是否加入WebMvcConfigurer,确认MDC key与%X{...}完全一致
线程池任务里TraceId为空子线程拿不到父线程ThreadLocal使用TaskDecorator在任务执行前恢复MDC快照
下游服务的TraceId和上游对不上调用方没有把TraceId放进HTTP请求头检查Feign配置/RestTemplate拦截器是否正确添加
同一个请求生成了多个TraceId每个服务都从零生成,没有“先读请求头”的步骤统一接收逻辑:优先从X-Trace-Id请求头取,没有才创建
请求处理完成后清洗MDC报错afterCompletion中清理,但异常场景下代码提前返回用try-finally包裹清理逻辑,保证一定执行
日志太多,肉眼找不过来了没有按TraceId聚合的查看工具本地用idea的日志控制台按关键字过滤,线上用Loki、ELK等按traceId检索
引入第三方链路组件后与自写方案冲突两个工具都往MDC写入traceId统一使用一个方案,自写方案和组件方案二选一

下面挑几个容易出问题的细节,单独展开说一下。

第一个是“请求头大小写”的坑。HTTP头名称是不区分大小写的,但你用Tomcat时,request.getHeader("X-Trace-Id")和request.getHeader("x-trace-id")都能取到值。我倒是在一个项目里见过有的人写X-Trace-ID、有的人写X-TraceId,混乱到一度怀疑Tomcat丢头。建议全项目统一使用X-Trace-Id,并且定义成常量,不要散落字符串。

第二个是“网关层面的透传”。如果你的系统有Spring Cloud Gateway,建议在GlobalFilter里优先从上游请求(比如前端、Nginx)读取TraceId,放到MDC,并转发到下游。否则网关本身也会生成自己的TraceId,下游服务拿到的是网关的ID,链路虽然串上了,但和入口处记录的对不上。网关的代码思路和服务端拦截器高度相似,这里就不单独贴了。

第三个是“定时任务的陷阱”。前面讲的所有方案都是针对HTTP请求的,但SpringBoot项目里普遍有@Scheduled定时任务,这些任务入口没有HTTP上下文,MDC天然是空的。此时如果你希望定时任务每次执行也能有TraceId标识,需要在任务方法入口手动生成并放入MDC,执行完毕再清理。我自己一般写一个AOP切面统一处理定时任务的TraceId注入,比在每个方法里手写干净得多。

第四个是“多模块项目”的处理。热词里有人问“多个SpringBoot项目如何一次登录其他不用登录”,这是会话共享的问题,不是TraceId的直接范畴。但多模块项目里公共拦截器和工具类的放置位置要提前想好,我习惯把TraceIdUtils、TraceIdInterceptor、MdcTaskDecorator放在一个独立的common-core模块里,业务模块直接依赖,避免每个服务各写一套。这样规则统一,也方便以后做二方库。

第五个是关于“日志可靠性”的提醒。TraceId方案依赖日志框架正常输出,如果日志级别配置错误或者异步日志丢弃,TraceId再漂亮也看不到。建议日志文件保留时间不要低于30天,配合MAX HISTORY滚动策略,避免磁盘被撑爆;线上环境把logger.info的打印量控制住,一些高频打点改成DEBUG级别,不然排查时几百G日志是真的搜不动。

6. 一些个人的实战体会与建议

这套自写TraceId方案我前前后后在三个项目里落地过,最大的感受是“先理解原理,再决定用什么框架”。网上关于链路追踪的教程一搜一大把,Sleuth、Micrometer Tracing、SkyWalking、OpenTelemetry各有各的生态,但对多数中小团队来说,先花一小时手写一套轻量方案,把MDC、请求头透传、线程上下文传递这几个概念吃透,比直接引入一个重量级组件更有价值。

另外有一个很容易被忽略的好处:让TraceId成为团队的一种“排查语言”。运维同学、客服同学拿着TraceId来反馈问题时,大家不用再用“大概几点几分”“我这边看到这个单号”这种模糊信息,直接一个ID甩过来,开发就能通过日志平台拉全链路明细。我去年还专门在项目的统一异常响应体里加了traceId字段,前端调用失败时把服务端返回的traceId展示在错误提示里,用户带着它来找客服,问题定位时间从半小时压缩到十分钟以内。

最后再分享一个小技巧,如果你用Logback并且日志文件按天滚动,可以在日志文件名里也加上日期和traceId的hash分桶,类似order-20250112.log,配合按业务维度的目录切分(比如每个订单号一个子目录不好用就没必要),能在海量日志里把搜索范围缩得极小。这套东西后续你还可以配合Webhook在服务异常时把TraceId和上下文快照一起推送到IM工具,告警信息里直接带链路入口,运维值班的人会感激你的。

TraceId日志追踪本质上解决的不仅是“日志里多了个ID”的问题,它重新定义了你排查问题的思路。过去的排查是“搜关键字,碰运气”,现在是“按ID拉全链路,看时间线”。这个思路一旦建立,后面不管再上什么监控组件,你的底层理解都不会过时。

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

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

立即咨询