Spring Cloud Gateway 全链路 traceId 传递方案

Spring Cloud Gateway 全链路 traceId 传递方案

概述

在 Spring Cloud Gateway (WebFlux) 微服务架构中,实现 traceId 从网关到下游服务的全链路传递,确保每一处日志都能关联到原始请求。核心挑战在于三个场景的 MDC 丢失问题:

场景 原因 影响
反应式操作符链 flatMap 等操作符在线程间跳转,ThreadLocal 不跟随 网关日志 traceId 丢失
全局异常处理器 框架直接调用,不在反应式链中 异常日志 traceId 丢失
异步线程池 任务在工作线程执行,ThreadLocal 隔离 下游日志 traceId 丢失,Feign 传递中断

目录

  1. 前置知识
  2. [方案一:Reactor 操作符链 MDC 传播](#方案一:Reactor 操作符链 MDC 传播 "#%E6%96%B9%E6%A1%88%E4%B8%80reactor-%E6%93%8D%E4%BD%9C%E7%AC%A6%E9%93%BE-mdc-%E4%BC%A0%E6%92%AD")
  3. [方案二:全局异常处理器 MDC 恢复](#方案二:全局异常处理器 MDC 恢复 "#%E6%96%B9%E6%A1%88%E4%BA%8C%E5%85%A8%E5%B1%80%E5%BC%82%E5%B8%B8%E5%A4%84%E7%90%86%E5%99%A8-mdc-%E6%81%A2%E5%A4%8D")
  4. [方案三:线程池 MDC 传播](#方案三:线程池 MDC 传播 "#%E6%96%B9%E6%A1%88%E4%B8%89%E7%BA%BF%E7%A8%8B%E6%B1%A0-mdc-%E4%BC%A0%E6%92%AD")
  5. 完整代码清单
  6. 全链路数据流
  7. 兼容性说明

前置知识

MDC 的本质

MDC(Mapped Diagnostic Context)是 SLF4J 提供的日志上下文工具,底层是 ThreadLocal<Map<String, String>>

java 复制代码
// org.slf4j.MDC 内部实现(简化)
public class MDC {
    private static final ThreadLocal<Map<String, String>> mdcAdapter = new ThreadLocal<>();

    public static void put(String key, String val) {
        mdcAdapter.get().put(key, val);  // 写入当前线程
    }

    public static String get(String key) {
        return mdcAdapter.get().get(key); // 从当前线程读取
    }
}

每个线程拥有独立的上下文副本,线程之间互不可见。

架构差异:Servlet vs WebFlux

css 复制代码
Servlet(同步阻塞,一个线程处理一个请求):
请求 → [线程A] Filter → [线程A] Controller → [线程A] Service → 响应

WebFlux(异步非阻塞,请求在多个线程间跳转):
请求 → [线程A] Filter → [线程B] 读取Body → [线程C] 业务处理 → [线程A] 响应

Logback 配置

确保日志格式中包含 %X{traceId} 占位符:

xml 复制代码
<property name="LOG_PATTERN"
    value="%d{yyyy-MM-dd HH:mm:ss} [%-5level] [%X{traceId}] -- [%thread] %-40.50class: %msg%n"/>

如果还需要传递客户端类型等额外信息,可扩展:

xml 复制代码
<property name="LOG_PATTERN"
    value="%d{yyyy-MM-dd HH:mm:ss} [%-5level] [%X{traceId}] [%X{clientType}] -- [%thread] %-40.50class: %msg%n"/>

入口 Filter:traceId 的生成与传递

在网关中,需要一个最高优先级的 GlobalFilter 来生成 traceId 并写入请求头:

java 复制代码
@Component
public class TraceFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 生成 traceId
        String traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
        MDC.put("traceId", traceId);

        // 将 traceId 写入请求头,向下游透传
        ServerHttpRequest request = exchange.getRequest().mutate()
                .header("X-Trace-Id", traceId)
                .build();

        return chain.filter(exchange.mutate().request(request).build())
                .doFinally(s -> {
                    MDC.remove("traceId");
                });
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

下游服务通过 Servlet 拦截器或 Filter 从请求头中恢复 traceId 到 MDC:

java 复制代码
public class TraceIdInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) {
        String traceId = request.getHeader("X-Trace-Id");
        if (traceId == null || traceId.isEmpty()) {
            traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
        }
        MDC.put("traceId", traceId);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                Object handler, Exception ex) {
        MDC.remove("traceId");
    }
}

Feign 拦截器从 MDC 读取 traceId 并写入请求头:

java 复制代码
public class TraceIdFeignInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        String traceId = MDC.get("traceId");
        if (traceId != null) {
            template.header("X-Trace-Id", traceId);
        }
    }
}

方案一:Reactor 操作符链 MDC 传播

适用场景

Spring Cloud Gateway 中所有使用反应式操作符的场景,尤其是:

  • DataBufferUtils.join() 读取请求体后的 flatMap 回调
  • WebClient 调用后的 flatMap / map 回调
  • 任何可能发生线程切换的 flatMapconcatMappublishOnsubscribeOn

问题演示

java 复制代码
// 网关中的一个解密 Filter
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    return DataBufferUtils.join(exchange.getRequest().getBody())
            .flatMap(dataBuffer -> {
                // ⚠️ 这个回调可能运行在与 TraceFilter 不同的线程上
                log.info("开始解密");  // ❌ traceId 丢失
                // ...
            });
}

TraceFilter线程A 上调用了 MDC.put("traceId", "abc123"),但 flatMap 回调运行在 线程B 上,线程BThreadLocal 中没有 traceId。

核心思路:快照-恢复

不在线程间传递 MDC,而是将 MDC 快照嵌入到反应式链路中。信号到达时恢复,信号处理完后还原:

复制代码
装配时:拍下当前线程 MDC 的快照,嵌入到操作符链中
执行时:不管运行在哪个线程,先把快照恢复到当前线程,再执行逻辑,最后还原

关键 API

Hooks.onEachOperator:全局拦截 Reactor 链路中每个操作符的装配过程。

Operators.lift :在订阅发生时,用一个包装器替换原始订阅者(CoreSubscriber),拦截所有信号:onSubscribeonNextonErroronComplete

Operators.lift 在 Reactor 3.4.0 中引入,通过 BiFunction<Scannable, CoreSubscriber, CoreSubscriber> 简化了订阅者包装。

实现代码

java 复制代码
import org.reactivestreams.Subscription;
import org.slf4j.MDC;
import org.springframework.context.annotation.Configuration;
import reactor.core.CoreSubscriber;
import reactor.core.Scannable;
import reactor.core.publisher.Hooks;
import reactor.core.publisher.Operators;
import reactor.util.context.Context;

import jakarta.annotation.PostConstruct;
import java.util.Map;

@Configuration
public class ReactorMdcConfiguration {

    @PostConstruct
    public void setupMdcPropagation() {
        Hooks.onEachOperator(Operators.lift(
                (Scannable scannable, CoreSubscriber<? super Object> subscriber) -> {
                    // 订阅发生时,捕获当前线程的 MDC 快照
                    Map<String, String> contextMap = MDC.getCopyOfContextMap();
                    if (contextMap == null || contextMap.isEmpty()) {
                        return subscriber;  // 无 MDC 则不包装,零开销
                    }
                    return new MdcLifter(subscriber, contextMap);
                }
        ));
    }

    /**
     * 订阅者包装器:在信号处理前恢复 MDC 上下文,处理后还原
     */
    private static class MdcLifter implements CoreSubscriber<Object> {

        private final CoreSubscriber<? super Object> delegate;
        private final Map<String, String> contextMap;

        MdcLifter(CoreSubscriber<? super Object> delegate, Map<String, String> contextMap) {
            this.delegate = delegate;
            this.contextMap = contextMap;
        }

        @Override
        public void onSubscribe(Subscription s) {
            withMdc(() -> delegate.onSubscribe(s));
        }

        @Override
        public void onNext(Object o) {
            withMdc(() -> delegate.onNext(o));
        }

        @Override
        public void onError(Throwable e) {
            withMdc(() -> delegate.onError(e));
        }

        @Override
        public void onComplete() {
            withMdc(() -> delegate.onComplete());
        }

        @Override
        public Context currentContext() {
            return delegate.currentContext();
        }

        private void withMdc(Runnable action) {
            Map<String, String> previous = MDC.getCopyOfContextMap();
            if (contextMap != null) {
                MDC.setContextMap(contextMap);
            }
            try {
                action.run();
            } finally {
                if (previous != null) {
                    MDC.setContextMap(previous);
                } else {
                    MDC.clear();
                }
            }
        }
    }
}

执行时序

DataBufferUtils.join().flatMap(...) 为例:

csharp 复制代码
═══════════════════════════════════════════════════════════════════
修复前
═══════════════════════════════════════════════════════════════════
[reactor-http-nio-1] TraceFilter: MDC.put("traceId", "abc123")
[reactor-http-nio-2] DataBufferUtils.join() 读取请求体
[reactor-http-nio-2] flatMap 回调: log.info(...)  // ❌ [] traceId 丢失

═══════════════════════════════════════════════════════════════════
修复后
═══════════════════════════════════════════════════════════════════
[reactor-http-nio-1] TraceFilter: MDC.put("traceId", "abc123")
[reactor-http-nio-1] 框架订阅 Mono 链,Operators.lift 回调执行
                     MDC.getCopyOfContextMap() → {"traceId": "abc123"}
                     new MdcLifter(subscriber, {"traceId": "abc123"})
[reactor-http-nio-2] DataBufferUtils.join() 读取请求体
[reactor-http-nio-2] MdcLifter.onNext() 被调用
                     ├─ MDC.setContextMap({"traceId": "abc123"})  ← 恢复
                     ├─ delegate.onNext() → flatMap 回调
                     │    └─ log.info(...)  // ✅ [abc123] 正常
                     └─ MDC.clear()  ← 还原线程干净状态

并发请求隔离

每个请求有独立的 Mono 链和独立的订阅,MdcLifter 嵌入在各自的链路中,互不干扰。

css 复制代码
请求A: Mono-A 链 → [MdcLifter-A: {"traceId":"aaa111"}] → 操作符 → ...
请求B: Mono-B 链 → [MdcLifter-B: {"traceId":"bbb222"}] → 操作符 → ...

[线程X] 收到请求A的 onNext → MdcLifter-A 恢复 "aaa111" → 执行 → MDC.clear()
[线程X] 收到请求B的 onNext → MdcLifter-B 恢复 "bbb222" → 执行 → MDC.clear()

线程是共享的执行者,MdcLifter 嵌入在信号链路中跟随信号传递,每个信号知道自己的 traceId。

嵌套 flatMap 的正确性

外层 MdcLifter 在触发 flatMap 回调时已恢复 MDC,内层链路订阅时 Operators.lift 再次执行,正确捕获:

css 复制代码
MdcLifter-A (外层) ─── 快照 {"traceId":"aaa111"}
  │
  └─ onNext 触发 flatMap 回调
       │  MDC 已恢复为 {"traceId":"aaa111"}
       │
       └─ 内层 Mono 订阅
            └─ Operators.lift 再次执行
                 MDC.getCopyOfContextMap() → {"traceId":"aaa111"}
                 new MdcLifter-A-inner(subscriber, {"traceId":"aaa111"})  ← 正确

方案二:全局异常处理器 MDC 恢复

适用场景

Spring Cloud Gateway 的 ErrorWebExceptionHandler 实现中,当 GlobalFilter 链抛出异常时需要记录日志。

为什么方案一无法覆盖

ErrorWebExceptionHandler.handle() 由 Spring Cloud Gateway 框架直接调用,不属于反应式操作符链:

  1. 框架截获反应式链中的错误信号
  2. 框架调用 ErrorWebExceptionHandler.handle() 方法
  3. 此时当前线程的 MDC 可能已被 TraceFilter.doFinally 清理,或运行在不同线程

核心思路:从请求头中恢复

入口 Filter 已将 traceId 写入 HTTP 请求头,而请求头存储在 ServerHttpRequest 对象中,不受线程切换影响。异常处理器直接从中读取即可。

实现代码

java 复制代码
import org.slf4j.MDC;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.UUID;

@Component
@Order(-1)
public class GatewayExceptionHandler implements ErrorWebExceptionHandler {

    private static final String TRACE_ID_HEADER = "X-Trace-Id";

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        // ① 从请求头中恢复 traceId(入口 Filter 已写入)
        String traceId = exchange.getRequest().getHeaders().getFirst(TRACE_ID_HEADER);

        // ② 兜底:极端情况下(如入口 Filter 自身抛异常)生成新的 traceId
        if (traceId == null || traceId.isEmpty()) {
            traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
        }

        // ③ 设置到 MDC,后续日志都能看到 traceId
        MDC.put("traceId", traceId);

        ServerHttpResponse response = exchange.getResponse();
        if (response.isCommitted()) {
            // ④ 响应已提交,清理 MDC 后返回错误
            MDC.remove("traceId");
            return Mono.error(ex);
        }

        // ⑤ 日志记录(此时已有 traceId)
        log.error("网关异常 traceId={}", traceId, ex);

        // ⑥ 构建错误响应
        response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
        // ... 构建响应体 ...

        // ⑦ 响应写入完成后清理 MDC,无论成功/失败/取消都会执行
        return response.writeWith(Mono.just(buffer))
                .doFinally(s -> MDC.remove("traceId"));
    }
}

为什么从 Header 读取而不是从 MDC

来源 可靠性 原因
MDC ❌ 不可靠 当前线程可能不是请求处理线程,MDC 可能已被清理
HTTP Header ✅ 可靠 存储在 ServerHttpRequest 对象中,不受线程切换影响

方案三:线程池 MDC 传播

适用场景

下游微服务中使用 ThreadPoolTaskExecutor@Async 执行异步任务,任务中需要:

  • 日志携带 traceId
  • 通过 Feign / RestTemplate 等 HTTP 客户端调用下游时传递 traceId

问题演示

java 复制代码
// 请求线程提交异步任务
// MDC: {"traceId": "abc123"}

@Async
public void asyncProcess() {
    // 工作线程执行
    log.info("异步处理");     // ❌ traceId 丢失
    feignClient.callRemote();  // ❌ 请求头无 traceId
}

原因:提交任务时,MDC.put 在请求线程上;任务执行时,ThreadLocal 隔离,工作线程拿不到请求线程的上下文。

核心思路:TaskDecorator 快照-恢复

Spring 的 ThreadPoolTaskExecutor 提供 TaskDecorator 接口,允许在任务提交时和执行时插入自定义逻辑:

复制代码
提交时(请求线程):拍下 MDC 快照,存入装饰器
执行时(工作线程):恢复快照到 MDC → 执行任务 → 还原工作线程的 MDC

实现代码

java 复制代码
import org.slf4j.MDC;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.Map;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
public class ThreadPoolConfiguration {

    @Bean
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(Runtime.getRuntime().availableProcessors());
        executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 2);
        executor.setQueueCapacity(1000);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

        // MDC 上下文传播:异步任务执行时恢复提交线程的 MDC
        executor.setTaskDecorator(task -> {
            // ① 装饰阶段:在提交任务的线程中执行,捕获 MDC 快照
            Map<String, String> contextMap = MDC.getCopyOfContextMap();

            // ② 返回包装后的 Runnable,在工作线程中执行
            return () -> {
                // ③ 保存工作线程当前的 MDC(可能为空,或有残留)
                Map<String, String> previous = MDC.getCopyOfContextMap();

                // ④ 恢复提交线程的 MDC
                if (contextMap != null) {
                    MDC.setContextMap(contextMap);
                } else {
                    MDC.clear();
                }

                try {
                    task.run();  // ⑤ 执行实际任务,此时 MDC 有 traceId
                } finally {
                    // ⑥ 还原工作线程的原始 MDC(或清空),防止泄漏
                    if (previous != null) {
                        MDC.setContextMap(previous);
                    } else {
                        MDC.clear();
                    }
                }
            };
        });

        return executor;
    }
}

执行时序

css 复制代码
═══════════════════════════════════════════════════════════════════
[请求线程 http-nio-8080-1]
═══════════════════════════════════════════════════════════════════
  MDC: {"traceId": "abc123"}
  executor.submit(task)
    └─ TaskDecorator 被调用
       contextMap = {"traceId": "abc123"}  ← 快照
       return wrappedRunnable

═══════════════════════════════════════════════════════════════════
[线程池工作线程 pool-1-thread-3](稍后执行)
═══════════════════════════════════════════════════════════════════
  MDC: null
  wrappedRunnable.run()
    ├─ previous = null
    ├─ MDC.setContextMap({"traceId": "abc123"})  ← 恢复
    ├─ task.run()
    │     ├─ log.info("异步处理")  // ✅ [abc123]
    │     └─ feignClient.call()
    │           └─ FeignInterceptor: MDC.get("traceId") → "abc123" ✅
    └─ MDC.clear()  ← 清理

嵌套任务安全性

previous 的保存-还原机制确保了嵌套任务场景下的正确性:

css 复制代码
[线程A] MDC={"traceId": "parent-123"}
    └→ executor.submit(nestedTask)
         └→ [线程B] previous = {"traceId": "parent-123"}  ← 保存
              MDC.setContextMap({"traceId": "child-456"})  ← 覆盖
              nestedTask.run()
              MDC.setContextMap({"traceId": "parent-123"})  ← 还原

如果不还原 previous,线程 B 后续复用时可能残留 "child-456",导致日志混乱。


完整代码清单

网关模块

类名 作用 说明
TraceFilter 入口 traceId 生成 最高优先级 GlobalFilter,生成 traceId 写入 MDC + Header
ReactorMdcConfiguration 反应式 MDC 传播 Hooks.onEachOperator + Operators.lift 全局注册
GatewayExceptionHandler 异常处理 MDC 恢复 从 Header 读取 traceId,兜底生成

公共模块(下游服务共享)

类名 作用 说明
TraceIdInterceptor Servlet 拦截器 从 Header 读 traceId 写入 MDC,afterCompletion 清理
TraceIdFeignInterceptor Feign 拦截器 从 MDC 读 traceId 写入 Feign 请求头
ThreadPoolConfiguration 线程池配置 配置 TaskDecorator 传播 MDC 到工作线程

全链路数据流

less 复制代码
客户端请求
  │
  ▼
┌─────────────────────────────────────────────────────────────────┐
│ Gateway (WebFlux)                                               │
│                                                                 │
│  TraceFilter: MDC.put(traceId) + Header["X-Trace-Id"] = traceId│
│     │                                                           │
│     ├─ ReactorMdcConfiguration: 操作符链 MDC 传播              │
│     │     └─ flatMap / map / doOnNext 等回调中 MDC 有效        │
│     │                                                           │
│     ├─ 其他 Filter 正常执行                                    │
│     │                                                           │
│     └─ 异常 → GatewayExceptionHandler: 从 Header 读 traceId    │
│              └─ MDC.put(traceId) → 日志 → MDC.remove()         │
│                                                                 │
│  路由至下游服务,Header["X-Trace-Id"] 自动透传                  │
└─────────────────────────────────────────────────────────────────┘
  │
  ▼
┌─────────────────────────────────────────────────────────────────┐
│ 下游微服务 (Servlet)                                            │
│                                                                 │
│  TraceIdInterceptor: Header["X-Trace-Id"] → MDC.put(traceId)   │
│     │                                                           │
│     ├─ 同步逻辑: MDC 天然有效                                  │
│     │                                                           │
│     ├─ 异步任务: ThreadPoolConfiguration.TaskDecorator         │
│     │     └─ 提交时快照 → 执行时恢复 → 完成后还原              │
│     │                                                           │
│     └─ Feign 调用: TraceIdFeignInterceptor                     │
│           └─ MDC.get(traceId) → Header["X-Trace-Id"]           │
│                                                                 │
│  afterCompletion: MDC.remove(traceId)                           │
└─────────────────────────────────────────────────────────────────┘
  │
  ▼
下游服务同样通过 TraceIdInterceptor 接收,形成全链路追踪

兼容性说明

组件 最低版本 说明
Spring Boot 3.0.0+ jakarta.annotation.PostConstruct 迁移
Spring Cloud 2022.0.0+ 对应 Spring Boot 3.x
Reactor 3.4.0+ Operators.lift 在此版本引入
Java 17+ Spring Boot 3.x 最低要求

如果使用 Spring Boot 2.x,需将 jakarta.annotation.PostConstruct 替换为 javax.annotation.PostConstruct

替代方案

如果不希望使用 Operators.lift,也可通过 Hooks.onEachOperator 手动实现 Publisher 包装:

java 复制代码
Hooks.onEachOperator(publisher -> {
    Map<String, String> ctx = MDC.getCopyOfContextMap();
    if (ctx == null || ctx.isEmpty()) {
        return publisher;
    }
    return subscriber -> publisher.subscribe(new MdcLifter(subscriber, ctx));
});

注意事项

  1. Hooks.onEachOperator 是全局的:如果项目中存在多个调用,后注册的会覆盖先注册的。建议只在网关模块中注册一次。
  2. 性能开销 :每个信号传递时多一次 MDC.getCopyOfContextMap()MDC.setContextMap() 调用,属于轻量级 HashMap 操作,对性能影响可忽略。
  3. 与 Micrometer Tracing 的关系:如果项目使用了 Micrometer Tracing(Spring Cloud Sleuth 的继任者),它已内置 Reactor 上下文传播,不需要方案一。但方案二和方案三仍然适用。
  4. Operators.lift 的类型参数 :使用 CoreSubscriber<? super Object> 而非泛型 CoreSubscriber<T>,避免与 Operators.liftBiFunction 泛型签名产生类型推断冲突。
相关推荐
神奇小汤圆1 小时前
我们如何在 Elasticsearch 上构建一个持久 agent 记忆层,实现 0.89 召回率和零租户泄漏
后端
掘金一周1 小时前
裁员了,公司很爽快,赔偿一分没少 | 沸点周刊 7.8
前端·人工智能·后端
神奇小汤圆1 小时前
MySQL 5.7 频繁被 Systemd 杀死:一次惊心动魄的故障排查之旅
后端
行思理1 小时前
fastadmin 新手部分功能点
后端·php
Fanta丶2 小时前
5.Activiti7工作流启动和查询流程、流程任务处理
后端
꒰ঌ 安卓开发໒꒱2 小时前
.NET CAP入门到入土
后端·c#·.net
sukioe3 小时前
【无标题】
后端·docker·centos
geovindu3 小时前
java:Abstract Factory Pattern
java·开发语言·后端·设计模式·抽象工厂模式·创建型模式