文章目录
- [Spring Cloud 学习与实践(15):使用 TraceId 与 MDC 串联微服务日志](#Spring Cloud 学习与实践(15):使用 TraceId 与 MDC 串联微服务日志)
-
- 1、本章要解决什么问题
- [2、TraceId、MDC 与请求头的关系](#2、TraceId、MDC 与请求头的关系)
-
- [2.1 TraceId 是什么](#2.1 TraceId 是什么)
- [2.2 MDC 是什么](#2.2 MDC 是什么)
- [2.3 为什么还需要 X-Trace-Id 请求头](#2.3 为什么还需要 X-Trace-Id 请求头)
- [2.4 为什么 MDC 容易丢失](#2.4 为什么 MDC 容易丢失)
- 3、本章设计
-
- [3.1 统一名称](#3.1 统一名称)
- [3.2 TraceId 生成规则](#3.2 TraceId 生成规则)
- [3.3 传播路线](#3.3 传播路线)
- [3.4 为什么响应头也要返回 TraceId](#3.4 为什么响应头也要返回 TraceId)
- [4、阶段一:在订单服务建立 MDC 最小闭环](#4、阶段一:在订单服务建立 MDC 最小闭环)
-
- [4.1 本阶段目标](#4.1 本阶段目标)
- [4.2 修改订单服务日志格式](#4.2 修改订单服务日志格式)
- [4.3 新增 TraceIdFilter](#4.3 新增 TraceIdFilter)
- [4.4 为什么使用 OncePerRequestFilter](#4.4 为什么使用 OncePerRequestFilter)
- [4.5 为什么必须在 finally 中清理 MDC](#4.5 为什么必须在 finally 中清理 MDC)
- [4.6 重启范围](#4.6 重启范围)
- [4.7 验证自动生成 TraceId](#4.7 验证自动生成 TraceId)
- [4.8 验证复用上游 TraceId](#4.8 验证复用上游 TraceId)
- [4.9 验证不会串号](#4.9 验证不会串号)
- [4.10 阶段一结论](#4.10 阶段一结论)
- [5、阶段二:由 Gateway 统一生成并回传 TraceId](#5、阶段二:由 Gateway 统一生成并回传 TraceId)
-
- [5.1 本阶段目标](#5.1 本阶段目标)
- [5.2 为什么 Gateway 暂时不直接使用 MDC](#5.2 为什么 Gateway 暂时不直接使用 MDC)
- [5.3 新增 TraceIdGlobalFilter](#5.3 新增 TraceIdGlobalFilter)
- [5.4 为什么顺序设置为 -200](#5.4 为什么顺序设置为 -200)
- [5.5 为什么覆盖请求头](#5.5 为什么覆盖请求头)
- [5.6 订单服务为什么不需要修改](#5.6 订单服务为什么不需要修改)
- [5.7 可选:跨域前端读取响应头](#5.7 可选:跨域前端读取响应头)
- [5.8 重启范围](#5.8 重启范围)
- [5.9 验证正常链路](#5.9 验证正常链路)
- [5.10 验证固定 TraceId](#5.10 验证固定 TraceId)
- [5.11 验证 401 也有 TraceId](#5.11 验证 401 也有 TraceId)
- [5.12 阶段二结论](#5.12 阶段二结论)
- [6、阶段三:让所有业务服务统一接收 TraceId](#6、阶段三:让所有业务服务统一接收 TraceId)
-
- [6.1 本阶段目标](#6.1 本阶段目标)
- [6.2 为什么暂时不抽到 cloud-common](#6.2 为什么暂时不抽到 cloud-common)
- [6.3 增加日志格式](#6.3 增加日志格式)
- [6.4 给 cloud-user 增加 TraceIdFilter](#6.4 给 cloud-user 增加 TraceIdFilter)
- [6.5 给商品服务增加 TraceIdFilter](#6.5 给商品服务增加 TraceIdFilter)
- [6.6 给认证服务增加 TraceIdFilter](#6.6 给认证服务增加 TraceIdFilter)
- [6.7 为什么保留兜底生成](#6.7 为什么保留兜底生成)
- [6.8 重启范围](#6.8 重启范围)
- [6.9 验证范围](#6.9 验证范围)
- [6.10 阶段三结论](#6.10 阶段三结论)
- [7、阶段四:让 OpenFeign 继续透传 TraceId](#7、阶段四:让 OpenFeign 继续透传 TraceId)
-
- [7.1 本阶段问题](#7.1 本阶段问题)
- [7.2 扩展现有 Feign 拦截器](#7.2 扩展现有 Feign 拦截器)
- [7.3 为什么删除 userId 为空时的提前 return](#7.3 为什么删除 userId 为空时的提前 return)
- [7.4 让 CircuitBreaker 线程池传播 MDC](#7.4 让 CircuitBreaker 线程池传播 MDC)
- [7.5 为什么复制整个 MDC Map](#7.5 为什么复制整个 MDC Map)
- [7.6 为什么恢复工作线程原上下文](#7.6 为什么恢复工作线程原上下文)
- [7.7 Resilience4j 配置为什么不改](#7.7 Resilience4j 配置为什么不改)
- [7.8 重启范围](#7.8 重启范围)
- [7.9 唯一主线验证:创建订单](#7.9 唯一主线验证:创建订单)
- [7.10 阶段四结论](#7.10 阶段四结论)
- [8、阶段五:让 @Async 异步线程继承 MDC](#8、阶段五:让 @Async 异步线程继承 MDC)
-
- [8.1 本阶段问题](#8.1 本阶段问题)
- [8.2 修改 UserContextTaskDecorator](#8.2 修改 UserContextTaskDecorator)
- [8.3 OrderAsyncConfig 为什么不改](#8.3 OrderAsyncConfig 为什么不改)
- [8.4 重启范围](#8.4 重启范围)
- [8.5 验证异步传播](#8.5 验证异步传播)
- [8.6 两种线程池传播方案](#8.6 两种线程池传播方案)
- [8.7 阶段五结论](#8.7 阶段五结论)
- 9、阶段六:验证跨服务异常链路
-
- [9.1 本阶段目标](#9.1 本阶段目标)
- [9.2 选择安全失败场景](#9.2 选择安全失败场景)
- [9.3 发送失败请求](#9.3 发送失败请求)
- [9.4 检查日志](#9.4 检查日志)
- [9.5 检查流程是否及时停止](#9.5 检查流程是否及时停止)
- [9.6 阶段六结论](#9.6 阶段六结论)
- 10、最终调用链
- 11、几个容易混淆的职责
- 12、为什么当前方案还不是完整链路追踪
- 13、与相关方案对比
-
- [13.1 手工 TraceId](#13.1 手工 TraceId)
- [13.2 Spring Cloud Sleuth / Brave](#13.2 Spring Cloud Sleuth / Brave)
- [13.3 Micrometer Tracing](#13.3 Micrometer Tracing)
- [13.4 SkyWalking](#13.4 SkyWalking)
- 14、生产环境边界
-
- [14.1 外部 TraceId 是否可信](#14.1 外部 TraceId 是否可信)
- [14.2 TraceId 不能包含隐私](#14.2 TraceId 不能包含隐私)
- [14.3 MDC 必须清理](#14.3 MDC 必须清理)
- [14.4 RabbitMQ 是另一条传播边界](#14.4 RabbitMQ 是另一条传播边界)
- [14.5 集中日志检索](#14.5 集中日志检索)
- [14.6 日志采样与日志量](#14.6 日志采样与日志量)
- 15、本章总结
- 16、下一章预告
Spring Cloud 学习与实践(15):使用 TraceId 与 MDC 串联微服务日志
本章继续基于现有
cloud-demo项目演练,不额外创建脱离业务的示例工程。本章按照真实功能演进顺序推进:先在单个业务服务中建立 MDC 最小闭环,再由 Gateway 统一生成 TraceId,随后扩展到多个服务、OpenFeign 和异步线程,最后用一条真实失败链路完成验证。
1、本章要解决什么问题
在微服务项目中,一次请求往往会经过多个模块:
text
客户端
↓
Gateway
↓
订单服务
↓
用户服务
↓
商品服务
如果日志中只有时间、线程和消息:
text
查询用户成功
查询商品成功
创建订单失败
库存不足
很难判断这些日志是否属于同一次请求。
尤其在并发较高时,多个请求的日志会交叉出现:
text
请求 A:查询用户
请求 B:查询商品
请求 A:扣减库存
请求 B:创建订单
仅凭时间顺序无法稳定还原完整链路。
本章要建立:
text
Gateway 统一生成 TraceId
↓
通过 X-Trace-Id 请求头向下游传递
↓
业务服务把 TraceId 放入 MDC
↓
日志格式自动输出 TraceId
↓
OpenFeign 继续透传
↓
异步线程复制 MDC
↓
响应头回传 TraceId
最终希望看到:
text
[traceId=7f8a...] Gateway 接收请求
[traceId=7f8a...] 订单服务开始创建订单
[traceId=7f8a...] 用户服务查询用户
[traceId=7f8a...] 商品服务查询商品
[traceId=7f8a...] 商品不存在
只要按 TraceId 检索,就能串联同一次请求的日志。
2、TraceId、MDC 与请求头的关系
2.1 TraceId 是什么
TraceId 是一次调用链的唯一标识。
例如:
text
3c4ef362082f4da18612e091dd865c13
它和业务 ID 不是一回事。
| 标识 | 作用 |
|---|---|
traceId |
标识一次请求或一次调用链 |
userId |
标识当前用户 |
orderId |
标识一笔订单 |
messageId |
标识一条消息 |
requestId |
有时只标识单次 HTTP 请求 |
同一个用户连续创建两次订单,应产生两个不同的 TraceId。
2.2 MDC 是什么
MDC 可以理解为:
text
当前线程专属的日志变量容器
代码中放入:
java
MDC.put("traceId", traceId);
日志格式通过:
text
%X{traceId}
读取。
之后业务代码只需正常记录:
java
log.info("开始创建订单");
日志会自动显示:
text
[traceId=3c4ef362082f4da18612e091dd865c13] 开始创建订单
这样不需要在每条业务日志中手工拼接 TraceId。
2.3 为什么还需要 X-Trace-Id 请求头
MDC 只存在于当前进程和当前线程中,不能自动跨 HTTP 请求传播。
因此微服务之间要通过请求头传递:
http
X-Trace-Id: 3c4ef362082f4da18612e091dd865c13
完整关系:
text
Gateway 生成 TraceId
↓
写入 X-Trace-Id
↓
业务服务读取请求头
↓
MDC.put("traceId", traceId)
↓
日志 Pattern 通过 %X{traceId} 输出
2.4 为什么 MDC 容易丢失
MDC 通常建立在 ThreadLocal 之上。
线程切换后:
text
请求线程
↓
CircuitBreaker 线程池
↓
Feign 调用
或者:
text
请求线程
↓
@Async 线程池
新线程不会自动继承原线程中的 MDC。
因此本章后面还要分别处理:
text
Resilience4j CircuitBreaker 线程池
@Async 线程池
3、本章设计
3.1 统一名称
请求头统一使用:
text
X-Trace-Id
MDC Key 统一使用:
text
traceId
日志格式统一读取:
text
%X{traceId}
3.2 TraceId 生成规则
当前演练使用:
java
UUID.randomUUID()
.toString()
.replace("-", "");
生成 32 位字符串,例如:
text
3c4ef362082f4da18612e091dd865c13
3.3 传播路线
text
客户端
↓
Gateway 读取或生成 TraceId
↓
X-Trace-Id
↓
业务服务 TraceIdFilter
↓
MDC
↓
Feign RequestInterceptor
↓
下游服务
异步线程则通过:
text
TaskDecorator
复制当前 MDC。
3.4 为什么响应头也要返回 TraceId
Gateway 将 TraceId 同时写入响应头:
http
X-Trace-Id: 3c4ef362082f4da18612e091dd865c13
这样客户端遇到错误时,可以直接把这个值提供给开发人员。
开发人员不需要先知道订单号、用户 ID 或错误发生在哪个服务,只需按 TraceId 搜索日志。
4、阶段一:在订单服务建立 MDC 最小闭环
4.1 本阶段目标
本阶段先不修改 Gateway、Feign 和其他服务,只完成:
text
请求进入 cloud-order
↓
读取 X-Trace-Id
↓
没有时生成 TraceId
↓
放入 MDC
↓
日志自动打印
↓
请求结束后清理 MDC
4.2 修改订单服务日志格式
在 Nacos 的:
text
cloud-order-dev.yaml
中增加顶级配置:
yaml
logging:
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [traceId=%X{traceId:-}] %logger{36} - %msg%n"
其中关键部分:
text
[traceId=%X{traceId:-}]
含义:
text
MDC 中存在 traceId
↓
输出具体值
MDC 中不存在 traceId
↓
输出 -
启动日志显示:
text
[traceId=-]
是正常现象,因为启动阶段不属于某一次 HTTP 请求。
4.3 新增 TraceIdFilter
创建:
text
cloud-order
└── src/main/java
└── com.example.cloud.order.trace
└── TraceIdFilter.java
完整代码:
java
package com.example.cloud.order.trace;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
/**
* 订单服务 TraceId 过滤器。
*
* 当前阶段职责:
* 1. 读取上游传入的 X-Trace-Id
* 2. 没有 TraceId 时临时生成
* 3. 将 TraceId 放入 MDC
* 4. 请求结束后清理 MDC
*/
@Slf4j
@Component
public class TraceIdFilter extends OncePerRequestFilter {
/**
* 微服务之间传递 TraceId 使用的请求头。
*/
private static final String TRACE_ID_HEADER =
"X-Trace-Id";
/**
* 日志格式从 MDC 中读取的 Key。
*/
private static final String TRACE_ID_MDC_KEY =
"traceId";
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String traceId = request.getHeader(
TRACE_ID_HEADER
);
/*
* 当前请求没有携带 TraceId 时,
* 先由订单服务生成。
*
* 后续阶段会调整为由 Gateway 统一生成。
*/
if (!StringUtils.hasText(traceId)) {
traceId = generateTraceId();
}
MDC.put(TRACE_ID_MDC_KEY, traceId);
long startTime = System.currentTimeMillis();
try {
log.info(
"请求开始:method={},uri={}",
request.getMethod(),
request.getRequestURI()
);
filterChain.doFilter(request, response);
long cost = System.currentTimeMillis()
- startTime;
log.info(
"请求结束:status={},cost={}ms",
response.getStatus(),
cost
);
} catch (Exception e) {
long cost = System.currentTimeMillis()
- startTime;
log.error(
"请求异常:method={},uri={},cost={}ms",
request.getMethod(),
request.getRequestURI(),
cost,
e
);
throw e;
} finally {
/*
* Servlet 容器会复用工作线程。
*
* 如果不清理,当前请求的 TraceId
* 可能污染后续复用同一线程的请求。
*/
MDC.remove(TRACE_ID_MDC_KEY);
}
}
/**
* 生成 32 位 TraceId。
*/
private String generateTraceId() {
return UUID.randomUUID()
.toString()
.replace("-", "");
}
}

4.4 为什么使用 OncePerRequestFilter
普通 Servlet Filter 也可以实现,但业务服务采用 Spring MVC,OncePerRequestFilter 更适合作为请求过滤器基础类。
当前项目是:
text
Spring Boot 2.7.18
因此使用:
java
javax.servlet.*
不是:
java
jakarta.servlet.*
4.5 为什么必须在 finally 中清理 MDC
Tomcat 工作线程会反复使用。
如果不清理:
text
请求 A 在线程 exec-1 写入 traceId=A
↓
请求结束但未清理
↓
请求 B 复用 exec-1
↓
可能继续输出 traceId=A
因此必须:
java
finally {
MDC.remove("traceId");
}
这里使用 remove,而不是:
java
MDC.clear();
因为当前过滤器只负责自己写入的 traceId,不应顺便清除其他组件写入的 MDC 数据。
4.6 重启范围
本阶段修改:
text
cloud-order-dev.yaml
TraceIdFilter.java
只重启:
text
cloud-order
4.7 验证自动生成 TraceId
通过 Gateway 请求:
http
GET http://localhost:9000/api/order/context/user-id
Authorization: Bearer <JWT>
订单服务应出现如:
text
[traceId=3c4ef362082f4da18612e091dd865c13]
请求开始:method=GET,uri=/context/user-id
以及:
text
[traceId=3c4ef362082f4da18612e091dd865c13]
请求结束:status=200,cost=...ms

开始和结束日志中的 TraceId 必须相同。
4.8 验证复用上游 TraceId
PowerShell:
powershell
curl.exe -H "Authorization: Bearer <你的JWT>" -H "X-Trace-Id: manual-trace-001" "http://localhost:9000/api/order/context/user-id"
订单服务日志应显示:
text
[traceId=manual-trace-001]

4.9 验证不会串号
连续发送两次不带 X-Trace-Id 的请求:
text
请求 A → traceId=A
请求 B → traceId=B
应满足:
text
A ≠ B
每次请求内部的开始和结束日志必须保持一致。

4.10 阶段一结论
阶段一完成:
text
单个业务服务
↓
读取或生成 TraceId
↓
MDC 自动打印
↓
finally 清理
但 Gateway 尚未统一生成 TraceId,下一阶段把入口职责前移到 Gateway。
5、阶段二:由 Gateway 统一生成并回传 TraceId
5.1 本阶段目标
阶段一由订单服务兜底生成 TraceId,本阶段改为:
text
客户端
↓
Gateway 读取或生成 TraceId
↓
写入下游请求头
↓
写入响应头
↓
cloud-order 复用同一个 TraceId
本阶段不修改订单服务。
5.2 为什么 Gateway 暂时不直接使用 MDC
Gateway 基于 WebFlux 与 Reactor。
MDC 基于 ThreadLocal,一次响应式请求可能发生线程切换:
text
Reactor 链路
↓
线程切换
↓
ThreadLocal 中的 MDC 不一定稳定
因此本阶段采用:
java
log.info("请求进入 Gateway,traceId={}", traceId);
Gateway 显式输出 TraceId,业务服务再通过 MDC 自动输出。
不在 Gateway 中简单执行:
java
MDC.put("traceId", traceId);
然后假定整个 Reactor 链路都能自动继承。
5.3 新增 TraceIdGlobalFilter
创建:
text
cloud-gateway
└── src/main/java
└── com.example.cloud.gateway.trace.filter
└── TraceIdGlobalFilter.java
完整代码:
java
package com.example.cloud.gateway.trace.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.UUID;
/**
* Gateway TraceId 全局过滤器。
*
* 职责:
* 1. 读取客户端传入的 X-Trace-Id
* 2. 没有时生成新的 TraceId
* 3. 覆盖写入下游请求头
* 4. 将 TraceId 写入响应头
* 5. 记录 Gateway 入口和结束日志
*/
@Slf4j
@Component
public class TraceIdGlobalFilter
implements GlobalFilter, Ordered {
/**
* 微服务之间传递 TraceId 使用的请求头。
*/
private static final String TRACE_ID_HEADER =
"X-Trace-Id";
@Override
public Mono<Void> filter(
ServerWebExchange exchange,
GatewayFilterChain chain
) {
ServerHttpRequest originalRequest =
exchange.getRequest();
String incomingTraceId = originalRequest
.getHeaders()
.getFirst(TRACE_ID_HEADER);
/*
* 当前学习阶段允许复用客户端传入值,
* 便于使用固定 TraceId 验证传播链路。
*
* 没有传入时,由 Gateway 统一生成。
*/
String traceId = StringUtils.hasText(incomingTraceId)
? incomingTraceId.trim()
: generateTraceId();
/*
* 覆盖写入,而不是继续追加。
*
* 这样可以保证下游最终只收到一个
* X-Trace-Id 请求头。
*/
ServerHttpRequest tracedRequest = originalRequest
.mutate()
.headers(headers -> {
headers.remove(TRACE_ID_HEADER);
headers.set(TRACE_ID_HEADER, traceId);
})
.build();
ServerWebExchange tracedExchange = exchange
.mutate()
.request(tracedRequest)
.build();
/*
* 请求刚进入 Gateway 时就写入响应头。
*
* 后面的 JWT Filter 即使直接返回 401,
* 响应中也能保留当前 TraceId。
*/
tracedExchange.getResponse()
.getHeaders()
.set(TRACE_ID_HEADER, traceId);
long startTime = System.currentTimeMillis();
log.info(
"请求进入 Gateway:traceId={},method={},path={}",
traceId,
originalRequest.getMethod(),
originalRequest.getURI().getPath()
);
return chain.filter(tracedExchange)
.doOnSuccess(unused -> {
long cost = System.currentTimeMillis()
- startTime;
HttpStatus status = tracedExchange
.getResponse()
.getStatusCode();
log.info(
"请求离开 Gateway:traceId={},"
+ "status={},cost={}ms",
traceId,
status == null
? "unknown"
: status.value(),
cost
);
})
.doOnError(error -> {
long cost = System.currentTimeMillis()
- startTime;
log.error(
"Gateway 请求异常:traceId={},"
+ "method={},path={},cost={}ms",
traceId,
originalRequest.getMethod(),
originalRequest.getURI().getPath(),
cost,
error
);
});
}
/**
* 生成 32 位 TraceId。
*/
private String generateTraceId() {
return UUID.randomUUID()
.toString()
.replace("-", "");
}
/**
* 必须早于 JWT 鉴权 Filter 执行。
*
* JwtAuthGlobalFilter 当前顺序为 -100,
* TraceId Filter 使用 -200。
*/
@Override
public int getOrder() {
return -200;
}
}
5.4 为什么顺序设置为 -200
当前 JWT Filter 顺序是:
java
return -100;
数值越小,前置逻辑越早执行:
text
TraceIdGlobalFilter:-200
↓
JwtAuthGlobalFilter:-100
↓
路由转发
这样即使 JWT 鉴权失败:
text
TraceId Filter 先生成 TraceId
↓
写入响应头
↓
JWT Filter 返回 401
401 响应仍能携带 X-Trace-Id。
5.5 为什么覆盖请求头
代码使用:
java
headers.remove(TRACE_ID_HEADER);
headers.set(TRACE_ID_HEADER, traceId);
而不是追加。
这样可以避免客户端重复提交多个同名请求头,使下游最终只收到一个确定的 TraceId。
5.6 订单服务为什么不需要修改
订单服务阶段一已经实现:
java
String traceId = request.getHeader("X-Trace-Id");
if (!StringUtils.hasText(traceId)) {
traceId = generateTraceId();
}
Gateway 写入请求头后,订单服务自然复用,不会重新生成。
5.7 可选:跨域前端读取响应头
如果前端跨域访问 Gateway,并希望 JavaScript 读取 X-Trace-Id,可在现有 CORS 配置中追加:
yaml
exposedHeaders:
- X-Trace-Id
若已有 exposedHeaders,只追加,不要重复创建整套 CORS 配置。
5.8 重启范围
本阶段只新增 Gateway Filter,因此只重启:
text
cloud-gateway
5.9 验证正常链路
请求:
http
GET http://localhost:9000/api/order/context/user-id
Authorization: Bearer <JWT>
检查:
text
Gateway 入口 TraceId
=
Gateway 结束 TraceId
=
订单服务 TraceId
=
响应头 X-Trace-Id



5.10 验证固定 TraceId
powershell
curl.exe -i -H "Authorization: Bearer <你的JWT>" -H "X-Trace-Id: gateway-manual-001" "http://localhost:9000/api/order/context/user-id"
预期:
text
Gateway 日志:gateway-manual-001
订单服务日志:gateway-manual-001
响应头:gateway-manual-001


5.11 验证 401 也有 TraceId
Knife4j 聚合页面请求
powershell
"http://localhost:9000/api/order/context/user-id"
预期:
http
401
X-Trace-Id: 某个32位字符串
订单服务没有本次请求日志,因为请求在 Gateway 被拦截。

5.12 阶段二结论
现在已打通:
text
Gateway
↓
X-Trace-Id
↓
单个业务服务
↓
MDC
下一阶段扩展到认证、用户和商品服务。
6、阶段三:让所有业务服务统一接收 TraceId
6.1 本阶段目标
将订单服务已验证的 MDC Filter 扩展到:
text
cloud-auth
cloud-user
cloud-product
完成后,所有外部 HTTP 请求都具有统一日志格式。
6.2 为什么暂时不抽到 cloud-common
Gateway 使用 WebFlux,业务服务使用 Spring MVC。
| 模块 | Web 技术 | 是否适合 Servlet Filter |
|---|---|---|
cloud-gateway |
WebFlux | 否 |
cloud-auth |
Spring MVC | 是 |
cloud-user |
Spring MVC | 是 |
cloud-product |
Spring MVC | 是 |
cloud-order |
Spring MVC | 是 |
如果直接把 OncePerRequestFilter 放入 cloud-common,会把 Servlet 与 Spring Web 依赖带进公共模块。
当前演练采用:
text
Gateway → GlobalFilter
业务服务 → OncePerRequestFilter
更成熟的项目可以抽成:
text
cloud-common-web
或自定义 Starter。
6.3 增加日志格式
分别在:
text
cloud-auth-dev.yaml
cloud-user-dev.yaml
cloud-product-dev.yaml
中增加:
yaml
logging:
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [traceId=%X{traceId:-}] %logger{36} - %msg%n"
cloud-order-dev.yaml 已经存在,不重复添加。
6.4 给 cloud-user 增加 TraceIdFilter
创建:
text
cloud-user
└── src/main/java
└── com.example.cloud.user.trace
└── TraceIdFilter.java
完整代码:
java
package com.example.cloud.user.trace;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
/**
* 用户服务 TraceId 过滤器。
*/
@Slf4j
@Component
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID_HEADER =
"X-Trace-Id";
private static final String TRACE_ID_MDC_KEY =
"traceId";
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String traceId = request.getHeader(
TRACE_ID_HEADER
);
/*
* 正常情况下由 Gateway 生成。
*
* 保留兜底生成能力,是为了支持:
* 1. 开发时直接访问业务服务端口
* 2. 内部请求没有经过 Gateway
* 3. 单服务测试
*/
if (!StringUtils.hasText(traceId)) {
traceId = generateTraceId();
}
MDC.put(TRACE_ID_MDC_KEY, traceId);
long startTime = System.currentTimeMillis();
try {
log.info(
"请求开始:method={},uri={}",
request.getMethod(),
request.getRequestURI()
);
filterChain.doFilter(request, response);
long cost = System.currentTimeMillis()
- startTime;
log.info(
"请求结束:status={},cost={}ms",
response.getStatus(),
cost
);
} catch (Exception e) {
long cost = System.currentTimeMillis()
- startTime;
log.error(
"请求异常:method={},uri={},cost={}ms",
request.getMethod(),
request.getRequestURI(),
cost,
e
);
throw e;
} finally {
MDC.remove(TRACE_ID_MDC_KEY);
}
}
private String generateTraceId() {
return UUID.randomUUID()
.toString()
.replace("-", "");
}
}
6.5 给商品服务增加 TraceIdFilter
创建:
text
cloud-product
└── src/main/java
└── com.example.cloud.product.trace
└── TraceIdFilter.java
复制用户服务 Filter,只修改包名:
java
package com.example.cloud.product.trace;
类说明改为:
java
/**
* 商品服务 TraceId 过滤器。
*/
其他逻辑保持一致。
6.6 给认证服务增加 TraceIdFilter
创建:
text
cloud-auth
└── src/main/java
└── com.example.cloud.auth.trace
└── TraceIdFilter.java
修改包名:
java
package com.example.cloud.auth.trace;
类说明改为:
java
/**
* 认证服务 TraceId 过滤器。
*/
登录接口虽然不要求 JWT,但仍然需要 TraceId:
text
匿名接口
≠
不需要日志追踪

6.7 为什么保留兜底生成
即使 Gateway 已统一生成,业务服务仍保留:
java
if (!StringUtils.hasText(traceId)) {
traceId = generateTraceId();
}
用于:
- 直接访问业务服务端口;
- 单服务集成测试;
- 内部请求遗漏 TraceId;
- Gateway 未启动时的本地调试。
如果上游 TraceId 已经丢失,兜底生成只能从当前服务开始建立新链路,无法恢复之前的值。
6.8 重启范围
只重启:
text
cloud-auth
cloud-user
cloud-product
Gateway 和订单服务没有修改,不重启。
6.9 验证范围
只验证三个新增服务:
text
登录请求:
Gateway = cloud-auth = 响应头
用户请求:
Gateway = cloud-user = 响应头
商品请求:
Gateway = cloud-product = 响应头



订单服务只做一次最小回归,确认原链路未受影响。

固定 TraceId 只选择一个服务验证,例如:
powershell
curl.exe -i -H "Authorization: Bearer <你的JWT>" -H "X-Trace-Id: product-manual-001" "http://localhost:9000/api/product/products/1"

6.10 阶段三结论
现在所有外部 HTTP 请求均形成:
text
Gateway
↓
X-Trace-Id
↓
业务服务 TraceIdFilter
↓
MDC
↓
Controller / Service / Mapper 日志
但业务服务通过 OpenFeign 发起新请求时,请求头不会自动复制。下一阶段解决 Feign 传播。
7、阶段四:让 OpenFeign 继续透传 TraceId
7.1 本阶段问题
创建订单时:
text
Gateway
↓
cloud-order
↓
OpenFeign
↓
cloud-user / cloud-product
Feign 发起的是新的 HTTP 请求,自定义请求头不会自动复制。
当前项目还启用了 Resilience4j CircuitBreaker,Feign 调用可能在线程池中执行。
因此本阶段要同时处理:
text
请求线程 → CircuitBreaker 工作线程
CircuitBreaker 工作线程 → Feign 请求头
7.2 扩展现有 Feign 拦截器
修改:
text
cloud-order
└── client
└── interceptor
└── UserContextFeignInterceptor.java
完整代码:
java
package com.example.cloud.order.client.interceptor;
import com.example.cloud.common.context.UserContext;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* Feign 请求上下文透传。
*
* 当前负责:
* 1. 透传可信用户身份 X-User-Id
* 2. 透传日志链路标识 X-Trace-Id
*/
@Slf4j
@Component
public class UserContextFeignInterceptor
implements RequestInterceptor {
private static final String TRACE_ID_HEADER =
"X-Trace-Id";
private static final String TRACE_ID_MDC_KEY =
"traceId";
@Override
public void apply(RequestTemplate template) {
Long userId = UserContext.getUserId();
String traceId = MDC.get(
TRACE_ID_MDC_KEY
);
log.info(
"Feign 准备发送请求,"
+ "线程={},url={},"
+ "userId={},traceId={}",
Thread.currentThread().getName(),
template.url(),
userId,
traceId
);
/*
* 两种上下文分别判断。
*
* 不再因为 userId 为空就直接 return,
* 否则匿名内部调用也会跳过 TraceId 透传。
*/
if (userId != null) {
template.header(
UserContext.USER_ID_HEADER,
String.valueOf(userId)
);
}
if (StringUtils.hasText(traceId)) {
template.header(
TRACE_ID_HEADER,
traceId
);
}
}
}
7.3 为什么删除 userId 为空时的提前 return
原有逻辑如果写成:
java
if (userId == null) {
return;
}
会产生:
text
userId 为空
↓
整个拦截器提前结束
↓
TraceId 也无法透传
但:
text
没有用户身份
≠
没有日志链路
因此 X-User-Id 与 X-Trace-Id 分别判断。
7.4 让 CircuitBreaker 线程池传播 MDC
修改:
text
cloud-order
└── config
└── UserContextAwareExecutorService.java
完整代码:
java
package com.example.cloud.order.config;
import com.example.cloud.common.context.UserContext;
import org.slf4j.MDC;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
/**
* CircuitBreaker 上下文感知线程池。
*
* 在任务提交时捕获:
* 1. UserContext
* 2. MDC 日志上下文
*
* 在工作线程执行前恢复,
* 执行结束后恢复工作线程原有上下文。
*/
public class UserContextAwareExecutorService
extends AbstractExecutorService {
private final ExecutorService delegate;
public UserContextAwareExecutorService(
ExecutorService delegate
) {
this.delegate = delegate;
}
@Override
public void execute(Runnable command) {
Objects.requireNonNull(command, "command");
/*
* 任务提交线程中的上下文。
*/
Long capturedUserId =
UserContext.getUserId();
Map<String, String> capturedMdcContext =
MDC.getCopyOfContextMap();
delegate.execute(() -> {
/*
* 保存工作线程原有上下文。
*
* 线程池线程会被复用,执行完当前任务后
* 不能把当前请求的上下文留在线程中。
*/
Long previousUserId =
UserContext.getUserId();
Map<String, String> previousMdcContext =
MDC.getCopyOfContextMap();
try {
restoreUserContext(
capturedUserId
);
restoreMdcContext(
capturedMdcContext
);
command.run();
} finally {
restoreUserContext(
previousUserId
);
restoreMdcContext(
previousMdcContext
);
}
});
}
private void restoreUserContext(Long userId) {
if (userId == null) {
UserContext.clear();
} else {
UserContext.setUserId(userId);
}
}
/**
* 先清空工作线程旧 MDC,
* 再恢复指定上下文。
*/
private void restoreMdcContext(
Map<String, String> contextMap
) {
MDC.clear();
if (contextMap != null
&& !contextMap.isEmpty()) {
MDC.setContextMap(contextMap);
}
}
@Override
public void shutdown() {
delegate.shutdown();
}
@Override
public List<Runnable> shutdownNow() {
return delegate.shutdownNow();
}
@Override
public boolean isShutdown() {
return delegate.isShutdown();
}
@Override
public boolean isTerminated() {
return delegate.isTerminated();
}
@Override
public boolean awaitTermination(
long timeout,
TimeUnit unit
) throws InterruptedException {
return delegate.awaitTermination(
timeout,
unit
);
}
}

7.5 为什么复制整个 MDC Map
也可以只复制:
java
MDC.get("traceId");
当前复制:
java
MDC.getCopyOfContextMap();
这样以后新增:
text
spanId
tenantId
requestPath
不需要再次修改线程池包装器。
但复制整个 Map 后,更要在任务结束时恢复或清理,否则线程复用会产生串号。
7.6 为什么恢复工作线程原上下文
简单执行:
java
finally {
MDC.clear();
}
虽然能防止多数串号,但会删除工作线程中可能由其他框架设置的上下文。
当前实现采用:
text
保存 previousContext
↓
设置 capturedContext
↓
执行任务
↓
恢复 previousContext
职责更完整。
7.7 Resilience4j 配置为什么不改
现有 Resilience4j 配置已经使用:
text
UserContextAwareExecutorService
本阶段只增强该包装器内部能力,不需要重复调整 CircuitBreaker 配置。
7.8 重启范围
只修改订单模块,因此只重启:
text
cloud-order
7.9 唯一主线验证:创建订单
通过 Gateway 创建一次真实订单:
http
POST http://localhost:9000/api/order/orders
Authorization: Bearer <JWT>
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}
该请求会真实执行:
text
查询用户
查询商品
扣减库存
新增订单
本阶段只调用一次。
检查:
text
Gateway TraceId
=
cloud-order TraceId
=
Feign 拦截器 TraceId
=
cloud-user TraceId
=
cloud-product TraceId
Feign 拦截器中的 traceId 不能为 null。
订单创建成功也证明原有 UserContext 传播没有被破坏。




7.10 阶段四结论
现在已打通:
text
Gateway
↓
cloud-order
↓
CircuitBreaker 线程池
↓
Feign
↓
cloud-user / cloud-product
下一阶段解决 @Async 线程池中的 MDC 传播。
8、阶段五:让 @Async 异步线程继承 MDC
8.1 本阶段问题
当前请求线程中:
text
UserContext.userId = 1
MDC.traceId = A
进入 @Async 后,线程发生切换:
text
http-nio-9400-exec-1
↓
order-async-1
现有 TaskDecorator 已经传播 UserContext,本阶段让它同时传播 MDC。
8.2 修改 UserContextTaskDecorator
修改:
text
cloud-order
└── src/main/java
└── com.example.cloud.order.config
└── UserContextTaskDecorator.java
完整代码:
java
package com.example.cloud.order.config;
import com.example.cloud.common.context.UserContext;
import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
import java.util.Map;
/**
* @Async 任务上下文装饰器。
*
* 在任务提交时捕获:
* 1. 当前用户身份
* 2. MDC 日志上下文
*
* 在异步线程执行前恢复,
* 执行结束后恢复工作线程原有上下文。
*/
public class UserContextTaskDecorator
implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
/*
* decorate() 在提交任务的线程中执行。
*
* 此时仍处于当前 HTTP 请求线程,
* 因此可以获取 UserContext 和 MDC。
*/
Long capturedUserId =
UserContext.getUserId();
Map<String, String> capturedMdcContext =
MDC.getCopyOfContextMap();
return () -> {
/*
* 保存异步工作线程原有的上下文。
*
* 线程池中的线程会被重复使用,
* 当前任务结束后需要恢复原状态。
*/
Long previousUserId =
UserContext.getUserId();
Map<String, String> previousMdcContext =
MDC.getCopyOfContextMap();
try {
/*
* 将提交线程中的用户身份
* 恢复到异步工作线程。
*/
restoreUserContext(
capturedUserId
);
/*
* 将提交线程中的 MDC
* 恢复到异步工作线程。
*/
restoreMdcContext(
capturedMdcContext
);
/*
* 执行真正的异步任务。
*/
runnable.run();
} finally {
/*
* 无论异步任务正常结束还是发生异常,
* 都恢复工作线程原有上下文,
* 防止线程池复用造成数据串号。
*/
restoreUserContext(
previousUserId
);
restoreMdcContext(
previousMdcContext
);
}
};
}
/**
* 恢复指定的用户上下文。
*/
private void restoreUserContext(Long userId) {
if (userId == null) {
UserContext.clear();
} else {
UserContext.setUserId(userId);
}
}
/**
* 恢复指定的 MDC 上下文。
*
* 先清除当前线程已有 MDC,
* 再写入需要恢复的上下文。
*/
private void restoreMdcContext(
Map<String, String> contextMap
) {
MDC.clear();
if (contextMap != null
&& !contextMap.isEmpty()) {
MDC.setContextMap(contextMap);
}
}
}
8.3 OrderAsyncConfig 为什么不改
现有配置已经注册:
java
executor.setTaskDecorator(
new UserContextTaskDecorator()
);
本阶段只增强 TaskDecorator 内部逻辑。
异步业务日志不需要手工拼接 TraceId,因为日志 Pattern 会从 MDC 自动读取。
8.4 重启范围
只修改:
text
UserContextTaskDecorator.java
因此只重启:
text
cloud-order
8.5 验证异步传播
第一次请求:
powershell
curl.exe -i -H "Authorization: Bearer <你的JWT>" -H "X-Trace-Id: async-trace-001" "http://localhost:9000/api/order/context/async-user-id"
检查:
text
请求线程:traceId=async-trace-001
异步线程:traceId=async-trace-001
异步线程 userId=1

第二次请求:
powershell
curl.exe -i -H "Authorization: Bearer <你的JWT>" -H "X-Trace-Id: async-trace-002" "http://localhost:9000/api/order/context/async-user-id"
检查:
text
请求线程:traceId=async-trace-002
异步线程:traceId=async-trace-002
没有残留 async-trace-001

第二次请求不是重复测试普通功能,而是验证线程池复用后上下文没有串号。
8.6 两种线程池传播方案
| 场景 | 线程池 | 传播方式 |
|---|---|---|
| Feign + CircuitBreaker | Resilience4j 执行器 | 包装 ExecutorService |
@Async |
ThreadPoolTaskExecutor |
TaskDecorator |
两者都传播:
text
UserContext
MDC
但配置位置不同,处理了其中一个,不代表另一个自动生效。
8.7 阶段五结论
现在已打通:
text
HTTP 请求线程
↓
@Async 线程池
↓
UserContext 与 MDC 同时传播
↓
线程复用后没有串号
下一阶段用一条真实失败链路完成收口。
9、阶段六:验证跨服务异常链路
9.1 本阶段目标
正常链路已经通过。
本阶段不新增代码、不重启服务,只验证:
text
业务失败时
Gateway、订单、用户和商品日志
是否仍能通过同一个 TraceId 关联
9.2 选择安全失败场景
使用不存在的商品 ID:
text
999999
订单流程:
text
校验用户
↓
查询商品
↓
商品不存在
↓
停止后续流程
因此不会:
- 扣减真实库存;
- 创建订单;
- 发送消息。
9.3 发送失败请求
powershell
curl.exe -i -X POST "http://localhost:9000/api/order/orders" -H "Authorization: Bearer <你的JWT>" -H "X-Trace-Id: error-trace-001" -H "Content-Type: application/json" -d "{\"productId\":999999,\"quantity\":1}"
响应应明确表示:
text
商品不存在
具体错误码以当前项目统一响应为准。
9.4 检查日志
Gateway:
text
traceId=error-trace-001
path=/api/order/orders

订单服务:
text
[traceId=error-trace-001]
请求开始:uri=/orders
Feign 拦截器:
text
traceId=error-trace-001

用户服务:
text
[traceId=error-trace-001]
uri=/users/1

商品服务:
text
[traceId=error-trace-001]
uri=/products/999999
响应头:
http
X-Trace-Id: error-trace-001

9.5 检查流程是否及时停止
商品服务只应出现:
text
/products/999999
不应出现:
text
/products/999999/deduct-stock
因为查询商品失败后,订单流程不会进入扣库存步骤。
9.6 阶段六结论
最终满足:
text
响应头
=
Gateway
=
cloud-order
=
Feign 拦截器
=
cloud-user
=
cloud-product
=
error-trace-001
这证明 TraceId 不仅能串联成功请求,也能串联跨服务业务失败。
10、最终调用链
text
客户端
↓
TraceIdGlobalFilter
├─ 读取或生成 TraceId
├─ 写入 X-Trace-Id 请求头
└─ 写入 X-Trace-Id 响应头
↓
JwtAuthGlobalFilter
↓
业务服务 TraceIdFilter
├─ 读取 X-Trace-Id
├─ 写入 MDC
└─ finally 清理
↓
订单业务
↓
CircuitBreaker 上下文执行器
├─ 复制 UserContext
└─ 复制 MDC
↓
Feign RequestInterceptor
├─ 写入 X-User-Id
└─ 写入 X-Trace-Id
↓
用户服务 / 商品服务
↓
@Async TaskDecorator
├─ 复制 UserContext
└─ 复制 MDC
11、几个容易混淆的职责
| 组件 | 职责 |
|---|---|
| TraceId | 标识一次调用链 |
X-Trace-Id |
在 HTTP 请求间传递 TraceId |
| MDC | 让当前线程日志自动带上 TraceId |
| Gateway GlobalFilter | 生成并回传 TraceId |
| Servlet TraceIdFilter | 读取请求头并写入 MDC |
| Feign RequestInterceptor | 给新的 Feign 请求继续写入 TraceId |
| 上下文感知 ExecutorService | 跨 CircuitBreaker 线程池传播上下文 |
| TaskDecorator | 跨 @Async 线程池传播上下文 |
| 响应头 | 让调用者拿到 TraceId |
12、为什么当前方案还不是完整链路追踪
当前方案能够回答:
text
哪些日志属于同一次请求?
但不能自动回答:
text
每一个远程调用耗时多少?
哪个 Span 最慢?
调用拓扑是什么?
数据库执行耗时多少?
当前没有实现:
- SpanId;
- 调用树;
- 自动埋点;
- 服务拓扑;
- 数据库与 MQ 自动追踪;
- 可视化页面。
因此准确定位是:
text
手工 TraceId 日志关联
不是完整的 APM 链路追踪系统。
13、与相关方案对比
13.1 手工 TraceId
优点:
- 原理直观;
- 无需额外服务端;
- 代码可控;
- 适合学习和简单项目。
局限:
- 需要手工处理线程切换;
- Feign、MQ、Reactor 需分别适配;
- 没有 Span 和拓扑;
- 没有集中检索界面。
13.2 Spring Cloud Sleuth / Brave
Sleuth 在 Spring Boot 2.x 时代常用于自动生成 TraceId、SpanId,并集成日志与远程调用。
当前项目没有直接依赖它完成全部功能,原因是本章希望先理解:
text
请求头传播
MDC
ThreadLocal
Feign
线程池
如果直接引入自动追踪组件,这些底层边界容易被隐藏。
13.3 Micrometer Tracing
Micrometer Tracing 是较新的 Spring 生态追踪抽象,在更新的 Spring Boot 版本中更常见。
当前项目基于 Spring Boot 2.7.18,因此本章不强行切换整套技术栈。
13.4 SkyWalking
SkyWalking 可以通过 Agent 自动采集:
- 服务调用;
- Span;
- 耗时;
- 调用拓扑;
- 数据库访问;
- 部分中间件调用。
第 19 章再完整演练 SkyWalking。
到那时,可以把本章手工 TraceId 与自动链路追踪进行对照。
14、生产环境边界
14.1 外部 TraceId 是否可信
当前为了测试,Gateway 允许复用客户端传入的 X-Trace-Id。
生产环境至少应限制:
- 最大长度;
- 允许字符;
- 是否包含空白;
- 是否包含换行;
- 是否允许特殊字符。
更严格的系统可以始终由 Gateway 重建 TraceId,只允许受信任的上游继续传递。
14.2 TraceId 不能包含隐私
不要使用:
- 手机号;
- 身份证号;
- 用户名;
- JWT;
- 订单详情。
TraceId 只应是随机标识。
14.3 MDC 必须清理
本章有三处线程复用边界:
| 场景 | 清理位置 |
|---|---|
| Servlet 请求线程 | TraceIdFilter 的 finally |
| CircuitBreaker 线程池 | UserContextAwareExecutorService 的 finally |
@Async 线程池 |
UserContextTaskDecorator 的 finally |
任何一处遗漏,都可能导致日志串号。
14.4 RabbitMQ 是另一条传播边界
HTTP 和 Feign 已经打通,但 MQ 消息不会自动继承生产者线程的 MDC。
常见做法:
text
发送消息时把 TraceId 写入消息 Header
↓
消费者读取 Header
↓
MDC.put
↓
消费结束后 finally 清理
本章不改动第 12、13 章已经稳定的消息链路,只作为扩展边界说明。
14.5 集中日志检索
当前日志仍输出到各服务控制台。
生产项目通常会接入:
text
ELK
Loki + Grafana
云日志服务
通过:
text
traceId: 3c4ef...
检索多个服务的日志。
如果使用 JSON 结构化日志,应让 TraceId 成为独立字段,而不是只嵌在文本消息中。
14.6 日志采样与日志量
TraceId 本身开销不大,但高并发系统若给每个请求输出大量入口、出口和业务日志,会显著增加日志量。
生产环境需要控制:
- 日志级别;
- 是否采样;
- 是否记录完整请求体;
- 是否输出敏感字段;
- 日志保留周期。
15、本章总结
本章按照真实演进完成:
text
单服务 MDC
↓
Gateway 统一生成 TraceId
↓
所有业务服务接收
↓
OpenFeign 继续透传
↓
CircuitBreaker 线程池传播
↓
@Async 线程池传播
↓
失败链路验证
最重要的职责关系:
text
Gateway 负责确定入口 TraceId
请求头负责跨服务传递
MDC 负责进入日志
Feign 拦截器负责新的 HTTP 请求
ExecutorService 和 TaskDecorator 负责线程切换
finally 负责防止上下文串号
本章也证明:
text
接口请求成功
不代表日志链路一定完整
日志中出现 TraceId
也不代表跨线程和跨服务一定没有丢失
最终仍要通过真实链路逐段核对。

16、下一章预告
第 16 章将进入 Elasticsearch 商品搜索与索引同步。
计划在现有 cloud-product 中实现:
text
商品索引设计
↓
商品数据写入 Elasticsearch
↓
关键词搜索
↓
分页与条件查询
↓
数据库与索引同步
↓
失败补偿与生产边界
重点不是单独学习 Elasticsearch 命令,而是把搜索能力真正接入当前微服务项目。