Spring Cloud 学习与实践(15):使用 TraceId 与 MDC 串联微服务日志

文章目录

  • [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-IdX-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 请求线程 TraceIdFilterfinally
CircuitBreaker 线程池 UserContextAwareExecutorServicefinally
@Async 线程池 UserContextTaskDecoratorfinally

任何一处遗漏,都可能导致日志串号。


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 命令,而是把搜索能力真正接入当前微服务项目。

相关推荐
剑藏锋1 小时前
嵌入式学习(46)-基于STM32F407Hal库的DMA乒乓缓存机制
stm32·单片机·学习
笨鸟先飞的橘猫1 小时前
skynet——网络模块学习
网络·学习·skynet
xd1855785551 小时前
鸿蒙PC平台Flutter魔术教学应用开发实践:打造跨端沉浸式魔术学习体验
人工智能·学习·flutter·华为·harmonyos·鸿蒙
2601_959982212 小时前
从单体架构到微服务,再到云原生:现代软件架构的发展趋势
微服务·云原生·架构
Linging_242 小时前
基于Langchain得AI Agent相关知识学习
人工智能·学习·langchain
Wang's Blog2 小时前
Go-Zero框架上手前奏2: 微服务核心要素 —— 拆分、通信与无状态设计
开发语言·微服务·golang
minglie111 小时前
高等代数试题
学习
Xiaofeng369312 小时前
GPT-5.6 发布后,我用了一个周末重新规划学习路线图
gpt·学习
其实防守也摸鱼13 小时前
运维--怎么看接口的请求和返回
运维·学习·网络安全·网络攻击模型·burpsuite·攻防对抗·蓝队