Spring Cloud 用 MDC 搞全链路 traceId,再也不用手动传参啦!

160 阅读10分钟

做微服务开发的兄弟,肯定都踩过同一个坑:线上出问题要排查日志,一个请求从网关到业务服务,再到数据库服务,每个服务的日志散在不同地方,想串起来比找对象还难!之前我傻呵呵在每个方法参数、DTO 里都塞个 traceId,手动往下传 —— 不仅代码里全是这种 “无关字段”,还总因为同事忘传,导致排查时对着一堆日志发呆。今天就把压箱底的办法分享给大家:用 Slf4j 的 MDC 搞全链路 traceId,代码几乎不用改,爽到直接减少一半排查时间~

一、先唠唠为啥要搞统一 traceId?

单体应用时代多省心啊,一个请求的日志全在一个线程里,顺着看就知道流程。但微服务一拆分,事儿就多了:比如用户下单,要调订单服务创建订单,再调支付服务扣钱,最后调库存服务减库存,每个服务的日志各存在自己的服务器上。

之前我是这么干的:订单服务生成个 traceId,调用支付服务时把它当参数传过去,支付服务再传给库存服务... 结果呢?

  • 业务代码里全是 traceId 这个 “多余参数”,比如 createOrder(String orderNo, String traceId),看着就烦,还容易和业务字段混在一起;
  • 万一新来的同事加了个服务调用,忘了传 traceId,线上出问题时,你就会发现:订单服务有日志,支付服务没对应 traceId,直接卡壳;
  • 更坑的是,只要用了 @Async 搞异步(比如发通知、算积分),traceId 直接就丢了 —— 同步日志还能看到,异步日志里啥都没有,排查时直接断片。

这活儿干久了谁不崩溃?所以必须找个 “不用手动传,还能跟着请求跑” 的方案!

二、救星来了:MDC 到底是个啥?

其实 Slf4j 早给咱们准备好工具了 ——MDC(Mapped Diagnostic Context),翻译过来叫 “映射诊断上下文”,听着挺玄乎,实际就是个 “线程级别的小仓库”。

它的原理特简单:基于 ThreadLocal 实现,每个线程都有自己的独立空间,你往里面塞个 traceId,这个线程里所有打印日志的地方,都能自动拿到这个 traceId。

最香的是不用改业务代码!只要在日志配置里加个占位符,日志就会自动带上 MDC 里的 traceId—— 比如原来日志是 2025-10-28 10:00:00 - 订单创建成功,加完之后就是 2025-10-28 10:00:00 - traceId=123456 - 订单创建成功,一目了然,简直是懒人福音~

三、网关(Gateway)层:统一生成 traceId,从入口把控全链路

如果项目用了 Spring Cloud Gateway 作为网关,强烈建议在网关层统一生成 traceId!这样下游所有业务服务(订单、支付、库存)都不用自己生成,直接从请求头拿就行,避免同一个请求在不同服务出现不同 traceId 的情况。

3.1 Gateway 为啥适合生成 traceId?

Gateway 是所有请求的 “入口”,不管是用户直接访问,还是服务间调用,都要经过网关(除非配置了忽略路由)。在这一层生成 traceId,有两个好处:

  1. 统一:整个微服务体系的 traceId 都由网关生成,格式、规则完全一致;
  1. 省力:下游服务不用判断 “有没有上游传 traceId”,直接拿请求头里的用就行。

3.2 Gateway 配置:用 GlobalFilter 生成并传递 traceId

Gateway 是基于 WebFlux 实现的(响应式编程),不能用 Spring MVC 的 HandlerInterceptor,得用 GlobalFilter 来拦截请求,生成 traceId 并塞到请求头里。

第一步:写 Gateway 全局过滤器

@Component
public class GatewayTraceIdFilter implements GlobalFilter, Ordered {
    private static final String TRACE_ID = "traceId";
    // 过滤器优先级,设高一点(数字越小优先级越高),确保在其他过滤器之前执行
    private static final int ORDER = -100;
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1. 从请求头拿 traceId,有就用(比如跨系统调用时上游传的),没有就生成
        String traceId = exchange.getRequest().getHeaders().getFirst(TRACE_ID);
        if (StringUtils.isEmpty(traceId)) {
            // 生成 UUID 并去掉横线,看着干净
            traceId = UUID.randomUUID().toString().replaceAll("-", "");
        }
        // 2. 把 traceId 塞到请求头里,传给下游服务
        // 注意:Gateway 的请求头是只读的,得用 mutate() 复制一份再改
        ServerHttpRequest modifiedRequest = exchange.getRequest()
                .mutate()
                .header(TRACE_ID, traceId)
                .build();
        // 3. 把 traceId 塞到 MDC 里,方便网关自己的日志打印(比如路由日志、异常日志)
        MDC.put(TRACE_ID, traceId);
        // 4. 继续执行链路,把修改后的请求传下去
        // 注意:响应结束后要清除 MDC,避免内存泄漏(响应式编程用 doFinally 处理)
        return chain.filter(exchange.mutate().request(modifiedRequest).build())
                .doFinally(signalType -> {
                    // 请求处理完,清除 MDC
                    MDC.remove(TRACE_ID);
                });
    }
    @Override
    public int getOrder() {
        return ORDER;
    }
}

第二步:配置 Gateway 日志格式(可选)

如果想让 Gateway 自己的日志也显示 traceId,和业务服务一样,改一下 Gateway 的日志配置(比如 logback.xml):

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

这样 Gateway 的日志里就会有 gateway-traceId=xxxx,能和下游服务的 traceId 对应上,排查网关层问题也方便。

3.3 下游业务服务:不用再生成,直接拿网关传的 traceId

之前业务服务的拦截器(TraceIdInterceptor)里,已经有 “从请求头拿 traceId” 的逻辑了,现在有了 Gateway 之后,业务服务就不用自己生成了 —— 直接用 Gateway 传过来的 traceId 就行,拦截器代码不用改!

举个例子:用户请求经过 Gateway,Gateway 生成 traceId=abc123 并塞到请求头,然后请求传到订单服务,订单服务的 TraceIdInterceptor 从请求头拿到 abc123,塞到 MDC 里,订单服务的日志就会带这个 traceId;之后订单服务调支付服务,Feign 拦截器把 abc123 塞到请求头,支付服务也用这个 traceId—— 全链路统一,完美!

四、服务内部怎么玩?三步搞定!

先从单个服务入手,让同一个请求的所有日志(不管是同步方法,还是异步线程)都带上同一个 traceId。

4.1 第一步:配日志格式,让 traceId 显示出来

首先得让日志能打印出 traceId,我用的是 logback(Spring Boot 默认就是它),其他日志框架(比如 log4j2)也差不多。

先确认项目里有这两个依赖(一般 Spring Boot 项目不用额外加,默认就带):

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
</dependency>

然后改 logback.xml(没这个文件就新建一个,放 resources 目录下),在日志格式里加个 %X{traceId},比如这样:

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
        <!-- 关键就是加了 traceId=%X{traceId} 这部分,其他不变 -->
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - traceId=%X{traceId} - %msg%n</pattern>
    </encoder>
</appender>

这样启动服务后,日志里就会多一列 traceId=xxxx,再也不用瞎猜哪个日志对应哪个请求了。

4.2 第二步:请求进来时,自动获取网关传的 traceId

请求刚进服务的时候,直接从请求头拿 Gateway 传的 traceId,塞到 MDC 里(不用自己生成了)。

不用改 Controller 代码,写个拦截器就行,所有请求都会走这个拦截器:

@Component
public class TraceIdInterceptor implements HandlerInterceptor {
    // 统一用 "traceId" 当key,和网关保持一致,不然拿不到
    private static final String TRACE_ID = "traceId";
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 从请求头拿网关传的 traceId(现在几乎不会为空,除非绕开网关调用)
        String traceId = request.getHeader(TRACE_ID);
        // 防止极端情况(比如本地测试没走网关),还是加个判断,为空就生成一个
        if (StringUtils.isEmpty(traceId)) {
            traceId = UUID.randomUUID().toString().replaceAll("-", "");
        }
        // 塞到 MDC 里,后面所有日志都能拿到
        MDC.put(TRACE_ID, traceId);
        return true;
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 特别重要!请求结束后一定要清掉 MDC,不然线程池复用会导致 traceId 串了
        // 比如线程A处理请求1,没清 MDC,再处理请求2时,会带着请求1的 traceId
        MDC.remove(TRACE_ID);
    }
}

然后把拦截器注册到 Spring 里,让它生效(不然写了也白写):

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private TraceIdInterceptor traceIdInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 所有请求都走这个拦截器,不用一个个接口加
        registry.addInterceptor(traceIdInterceptor).addPathPatterns("/**");
    }
}

这样一来,不管你调哪个接口(比如 /order/create、/pay/callback),请求进来就有 traceId 了,日志里自动带上,爽!

4.3 第三步:异步线程不丢 traceId

刚才说了,用 @Async 搞异步的时候,ThreadLocal 会失效(因为换了线程),MDC 里的 traceId 也会跟着丢 —— 比如下单成功后,异步发通知,通知的日志里没有 traceId,根本没法和下单日志串起来。

这时候得给线程池加个 “装饰器”,把当前线程的 MDC 上下文复制到异步线程里。

先写个装饰器类:

public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        // 先把当前线程的 MDC 上下文 copy 一份,别直接用引用
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        return () -> {
            try {
                // 异步线程里,把 copy 的上下文恢复回去
                if (contextMap != null) {
                    MDC.setContextMap(contextMap);
                }
                // 执行异步任务(比如发通知、算积分)
                runnable.run();
            } finally {
                // 任务结束,清掉异步线程的 MDC,避免串线
                MDC.clear();
            }
        };
    }
}

然后配置异步线程池,把装饰器加上(别用默认线程池,自己定义一个):

@Configuration
@EnableAsync // 别忘了加这个注解,不然 @Async 没用
public class AsyncConfig {
    @Bean
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 线程数根据自己项目调,别瞎设太多
        executor.setCorePoolSize(5); // 核心线程数
        executor.setMaxPoolSize(10); // 最大线程数
        executor.setQueueCapacity(20); // 队列容量
        // 关键:把 MDC 装饰器加上
        executor.setTaskDecorator(new MdcTaskDecorator());
        executor.initialize();
        return executor;
    }
}

之后用 @Async 注解异步方法时,日志里的 traceId 就不会丢了,和主线程的 traceId 一模一样~比如:

// 异步发通知
@Async("asyncExecutor") // 指定咱们配置的线程池
public void sendOrderNotice(String orderNo) {
    log.info("给用户发订单通知,订单号:{}", orderNo); // 日志里会带 traceId
}

五、服务之间怎么传?Feign/RestTemplate 都搞定

单个服务玩明白了,接下来是跨服务调用 —— 比如服务 A(订单)调服务 B(支付),怎么让 B 的日志也用 A 的 traceId?

核心思路很简单:A 调用 B 的时候,把自己 MDC 里的 traceId(也就是网关传的)放到请求头里,B 收到请求后,通过刚才写的拦截器,从请求头里拿到 traceId 再塞到 MDC,完美闭环。

5.1 Feign 调用:加个拦截器就行

Feign 是 Spring Cloud 里最常用的调用工具,不用改调用代码,加个 Feign 拦截器就行 —— 每次发请求前,把 traceId 塞到请求头。

@Component
public class FeignTraceIdInterceptor implements RequestInterceptor {
    private static final String TRACE_ID = "traceId";
    @Override
    public void apply(RequestTemplate template) {
        // 从当前线程的 MDC 里拿 traceId(网关传的),有就加到请求头
        String traceId = MDC.get(TRACE_ID);
        if (StringUtils.isNotEmpty(traceId)) {
            template.header(TRACE_ID, traceId);
        }
    }
}

不用额外配置!Feign 会自动扫描这个拦截器,每次调用都会带上 traceId 头 —— 比如 A 调 B 的 /pay/deduct 接口,请求头里就会有 traceId: abc123(网关生成的),B 收到后,拦截器会自动把这个 traceId 塞到 MDC,B 的日志里就有了。

5.2 RestTemplate 调用:同样加拦截器

如果项目里还用 RestTemplate 调用(比如老项目),也一样,给 RestTemplate 加个拦截器就行:

@Configuration
public class RestTemplateConfig {
    private static final String TRACE_ID = "traceId";
    @Bean
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        // 加个拦截器,往请求头塞 traceId
        restTemplate.setInterceptors(Collections.singletonList((request, body, execution) -> {
            String traceId = MDC.get(TRACE_ID);
            if (StringUtils.isNotEmpty(traceId)) {
                request.getHeaders().add(TRACE_ID, traceId);
            }
            // 继续执行请求,不用改其他逻辑
            return execution.execute(request, body);
        }));
        return restTemplate;
    }
}

之后用这个 RestTemplate 调其他服务,比如:

@Autowired
private RestTemplate restTemplate;
// 调用支付服务
public void callPayService(String orderNo) {
    String url = "http://pay-service/pay/deduct?orderNo=" + orderNo;
    restTemplate.getForObject(url, String.class); // 请求头里会自动带 traceId
}

支付服务的日志里,就能看到同一个 traceId(abc123)了。

这样我们就得到了一个统一traceId的log,不需要在因为日志里没有traceId然后对着好几十万行的日志大海捞针了。