Spring Cloud Gateway 全链路 traceId 传递方案
概述
在 Spring Cloud Gateway (WebFlux) 微服务架构中,实现 traceId 从网关到下游服务的全链路传递,确保每一处日志都能关联到原始请求。核心挑战在于三个场景的 MDC 丢失问题:
| 场景 | 原因 | 影响 |
|---|---|---|
| 反应式操作符链 | flatMap 等操作符在线程间跳转,ThreadLocal 不跟随 |
网关日志 traceId 丢失 |
| 全局异常处理器 | 框架直接调用,不在反应式链中 | 异常日志 traceId 丢失 |
| 异步线程池 | 任务在工作线程执行,ThreadLocal 隔离 |
下游日志 traceId 丢失,Feign 传递中断 |
目录
- 前置知识
- [方案一: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")
- [方案二:全局异常处理器 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")
- [方案三:线程池 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")
- 完整代码清单
- 全链路数据流
- 兼容性说明
前置知识
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回调- 任何可能发生线程切换的
flatMap、concatMap、publishOn、subscribeOn
问题演示
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 上,线程B 的 ThreadLocal 中没有 traceId。
核心思路:快照-恢复
不在线程间传递 MDC,而是将 MDC 快照嵌入到反应式链路中。信号到达时恢复,信号处理完后还原:
装配时:拍下当前线程 MDC 的快照,嵌入到操作符链中
执行时:不管运行在哪个线程,先把快照恢复到当前线程,再执行逻辑,最后还原
关键 API
Hooks.onEachOperator:全局拦截 Reactor 链路中每个操作符的装配过程。
Operators.lift :在订阅发生时,用一个包装器替换原始订阅者(CoreSubscriber),拦截所有信号:onSubscribe、onNext、onError、onComplete。
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 框架直接调用,不属于反应式操作符链:
- 框架截获反应式链中的错误信号
- 框架调用
ErrorWebExceptionHandler.handle()方法 - 此时当前线程的 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));
});
注意事项
Hooks.onEachOperator是全局的:如果项目中存在多个调用,后注册的会覆盖先注册的。建议只在网关模块中注册一次。- 性能开销 :每个信号传递时多一次
MDC.getCopyOfContextMap()和MDC.setContextMap()调用,属于轻量级 HashMap 操作,对性能影响可忽略。 - 与 Micrometer Tracing 的关系:如果项目使用了 Micrometer Tracing(Spring Cloud Sleuth 的继任者),它已内置 Reactor 上下文传播,不需要方案一。但方案二和方案三仍然适用。
Operators.lift的类型参数 :使用CoreSubscriber<? super Object>而非泛型CoreSubscriber<T>,避免与Operators.lift的BiFunction泛型签名产生类型推断冲突。