分布式追踪与 RequestId 传播完全指南

分布式追踪与 RequestId 传播完全指南


一、什么是分布式追踪?

在微服务架构中,一个用户请求可能经过多个服务、多个中间件才能完成:

复制代码
用户请求
  → API 网关
    → 订单服务
      → 库存服务(Feign HTTP)
      → 支付服务(Feign HTTP)
    → 发送 Kafka 消息
      → 库存变更消费者
        → 数据库操作

当这个链路中某个环节报错或变慢时,如何快速定位是哪个服务、哪个环节出了问题?

分布式追踪的解决方案:给每个请求分配一个全局唯一 ID,让这个 ID 跟随请求在所有服务、线程、消息队列中传播,最终通过 ID 串联起完整的调用链路。

复制代码
[RequestId: abc-123]
  → 订单服务日志:[abc-123] 创建订单开始
  → 库存服务日志:[abc-123] 扣减库存
  → Kafka 消息头:[abc-123]
  → 消费者日志:[abc-123] 处理库存变更事件

只要搜索 abc-123,就能看到这个请求的全部日志。


二、核心概念

2.1 追踪术语

术语 含义 类比
Trace 一个完整请求的端到端追踪 一次旅行的完整行程
Span Trace 中的一个操作单元 行程中的一段路(北京→上海)
TraceId Trace 的全局唯一标识 旅行单号
SpanId Span 的唯一标识 每段路的编号
ParentSpanId 父 Span 的 ID 上一段路的编号
Context Propagation 上下文在服务间传递 行李跟着人一起走

2.2 Trace 和 Span 的关系

复制代码
Trace(TraceId = abc-123)
│
├── Span A: 订单服务 - 创建订单(SpanId=1, 耗时 500ms)
│   ├── Span B: 库存服务 - 扣减库存(SpanId=2, ParentSpanId=1, 耗时 120ms)
│   ├── Span C: 支付服务 - 发起支付(SpanId=3, ParentSpanId=1, 耗时 300ms)
│   └── Span D: Kafka - 发送消息(SpanId=4, ParentSpanId=1, 耗时 15ms)
│
└── Span E: 库存消费者 - 处理消息(SpanId=5, ParentSpanId=4, 耗时 80ms)

2.3 RequestId vs TraceId

RequestId TraceId
生成方 应用自身(如 UUID) 追踪框架自动生成
用途 日志关联,方便搜索 完整链路追踪、性能分析
可视化 只能搜索日志 可在追踪平台(Jaeger/Zipkin)可视化
传播范围 手动传播(Header/MDC) 框架自动传播
复杂度 简单 较复杂

实际项目中两者经常并存:RequestId 用于业务日志快速搜索,TraceId 用于性能分析和依赖关系可视化。


注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

三、OpenTelemetry 简介

3.1 它是什么?

OpenTelemetry(简称 OTel)是 CNCF(云原生计算基金会)的开源可观测性框架,统一了追踪(Traces)、指标(Metrics)、日志(Logs)三种信号的采集标准。

复制代码
┌─────────────────────────────────────────────────────┐
│                   OpenTelemetry                       │
│                                                     │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐          │
│  │  Traces  │  │ Metrics  │  │   Logs   │          │
│  │ 分布式追踪│  │  指标    │  │   日志   │          │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘          │
│       └──────────────┼──────────────┘               │
│                      ↓                               │
│            OTel Collector(收集器)                    │
│                      ↓                               │
│     ┌────────────────┼────────────────┐             │
│     ↓                ↓                ↓             │
│  Jaeger          Prometheus       Elasticsearch     │
│  (追踪可视化)    (指标监控)       (日志存储)         │
└─────────────────────────────────────────────────────┘

3.2 OpenTelemetry 的优势

优势 说明
厂商无关 不绑定特定后端(可切换 Jaeger/Zipkin/DataDog)
自动埋点 Java Agent 模式无需改代码即可追踪
标准统一 取代 OpenTracing + OpenCensus
生态丰富 支持 HTTP、gRPC、Kafka、数据库等几乎所有中间件
语言广泛 支持 Java、Go、Python、Node.js 等主流语言

3.3 两种接入方式

方式 实现 特点
Java Agent(自动埋点) JVM 启动参数加载 Agent JAR 零侵入,自动追踪常见框架
SDK(手动埋点) 代码中引入 SDK 依赖 精细控制,可自定义 Span

四、RequestId 传播机制

4.1 传播链路

复制代码
HTTP 请求进入
    ↓
拦截器/过滤器:生成或提取 RequestId → 放入 MDC
    ↓
业务代码:日志自动携带 RequestId
    ↓
调用其他服务(Feign):RequestId 放入 HTTP Header
    ↓
发送 Kafka 消息:RequestId 放入消息 Header
    ↓
消费 Kafka 消息:从消息 Header 提取 RequestId → 放入 MDC
    ↓
消费者日志:自动携带同一个 RequestId

4.2 MDC 是什么?

MDC(Mapped Diagnostic Context)是 SLF4J/Logback 提供的线程级日志上下文,允许在不修改日志语句的情况下给所有日志添加额外字段。

java 复制代码
// 放入 MDC
MDC.put("requestId", "abc-123");

// 之后该线程的所有日志都会自动携带 requestId
log.info("处理订单");  // 输出:[abc-123] 处理订单

// 清除
MDC.remove("requestId");

Logback 配置中通过 %mdc{requestId} 引用:

xml 复制代码
<pattern>[%date] [%thread] [%level] [%mdc{requestId:-no-request-id}] - %msg%n</pattern>

五、完整通用示例

5.1 项目结构

复制代码
my-service/
├── pom.xml
├── src/main/java/com/example/myservice/
│   ├── config/
│   │   ├── RequestIdFilter.java         # HTTP 请求 RequestId 过滤器
│   │   ├── FeignRequestInterceptor.java  # Feign 调用传递 RequestId
│   │   └── kafka/
│   │       ├── KafkaProducerInterceptor.java  # Kafka 生产者拦截器
│   │       └── KafkaConsumerInterceptor.java  # Kafka 消费者拦截器
│   ├── util/
│   │   └── RequestIdUtil.java            # RequestId 工具类
│   └── domain/
│       └── order/
│           ├── controller/
│           ├── service/
│           └── kafka/
└── src/main/resources/
    ├── application.yml
    └── logback-spring.xml

5.2 pom.xml 依赖

xml 复制代码
<properties>
  <opentelemetry.version>1.52.0</opentelemetry.version>
</properties>

<dependencies>
  <!-- Spring Boot Web -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <!-- Spring Cloud OpenFeign -->
  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
  </dependency>

  <!-- Kafka -->
  <dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
  </dependency>

  <!-- OpenTelemetry - Kafka 客户端追踪 -->
  <dependency>
    <groupId>io.opentelemetry.instrumentation</groupId>
    <artifactId>opentelemetry-kafka-clients-2.6</artifactId>
    <version>2.18.1-alpha</version>
  </dependency>

  <!-- OpenTelemetry SDK(手动埋点时需要) -->
  <dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-api</artifactId>
    <version>${opentelemetry.version}</version>
  </dependency>
  <dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-sdk</artifactId>
    <version>${opentelemetry.version}</version>
  </dependency>

  <!-- OpenTelemetry 导出到 OTLP(发送到 Collector) -->
  <dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-otlp</artifactId>
    <version>${opentelemetry.version}</version>
  </dependency>
</dependencies>

5.3 RequestId 工具类

java 复制代码
package com.example.myservice.util;

import java.util.UUID;
import org.slf4j.MDC;

/**
 * RequestId 工具类.
 * 管理请求级别的唯一标识,用于日志追踪和跨服务传播.
 */
public final class RequestIdUtil {

  public static final String REQUEST_ID_HEADER = "X-Request-Id";
  public static final String MDC_KEY = "requestId";

  private RequestIdUtil() {
  }

  /**
   * 生成新的 RequestId.
   */
  public static String generate() {
    return UUID.randomUUID().toString().replace("-", "");
  }

  /**
   * 获取当前线程的 RequestId.
   */
  public static String get() {
    return MDC.get(MDC_KEY);
  }

  /**
   * 设置当前线程的 RequestId.
   */
  public static void set(String requestId) {
    MDC.put(MDC_KEY, requestId);
  }

  /**
   * 清除当前线程的 RequestId.
   */
  public static void clear() {
    MDC.remove(MDC_KEY);
  }
}

5.4 HTTP 请求过滤器 --- 入口生成/提取 RequestId

java 复制代码
package com.example.myservice.config;

import com.example.myservice.util.RequestIdUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

/**
 * RequestId 过滤器.
 * 每个 HTTP 请求进入时,提取或生成 RequestId 并放入 MDC.
 */
@Slf4j
@Component
@Order(1)
public class RequestIdFilter extends OncePerRequestFilter {

  @Override
  protected void doFilterInternal(HttpServletRequest request,
                                  HttpServletResponse response,
                                  FilterChain filterChain)
      throws ServletException, IOException {

    try {
      // 1. 尝试从请求头提取 RequestId(上游服务传过来的)
      String requestId = request.getHeader(RequestIdUtil.REQUEST_ID_HEADER);

      // 2. 如果没有则自动生成
      if (!StringUtils.hasText(requestId)) {
        requestId = RequestIdUtil.generate();
      }

      // 3. 放入 MDC(后续所有日志自动携带)
      RequestIdUtil.set(requestId);

      // 4. 放入响应头(方便前端/调用方看到)
      response.setHeader(RequestIdUtil.REQUEST_ID_HEADER, requestId);

      log.debug("请求开始, method={}, uri={}, requestId={}",
          request.getMethod(), request.getRequestURI(), requestId);

      // 5. 继续执行
      filterChain.doFilter(request, response);

    } finally {
      // 6. 请求结束,清除 MDC(防止线程复用导致污染)
      RequestIdUtil.clear();
    }
  }
}

5.5 Feign 拦截器 --- HTTP 跨服务传播

java 复制代码
package com.example.myservice.config;

import com.example.myservice.util.RequestIdUtil;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;

/**
 * Feign 请求拦截器.
 * 每次 Feign 调用其他服务时,自动将 RequestId 放入 HTTP Header.
 */
@Configuration
public class FeignRequestInterceptorConfig {

  @Bean
  public RequestInterceptor requestIdInterceptor() {
    return (RequestTemplate template) -> {
      String requestId = RequestIdUtil.get();
      if (StringUtils.hasText(requestId)) {
        template.header(RequestIdUtil.REQUEST_ID_HEADER, requestId);
      }
    };
  }
}

这样调用链就通了:

复制代码
服务A(生成 RequestId: abc-123)
  → Feign 调用,Header: X-Request-Id: abc-123
    → 服务B(Filter 提取 abc-123 → MDC)
      → 服务B 的日志都带 [abc-123]

5.6 Kafka 生产者拦截器 --- 消息传播

java 复制代码
package com.example.myservice.config.kafka;

import com.example.myservice.util.RequestIdUtil;
import io.opentelemetry.instrumentation.kafkaclients.v2_6.TracingProducerInterceptor;
import java.util.Map;
import org.apache.kafka.clients.producer.ProducerInterceptor;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.apache.kafka.common.header.Headers;
import org.springframework.util.StringUtils;

/**
 * Kafka 生产者拦截器.
 * 发送消息时:
 * 1. OpenTelemetry 自动注入 Trace 上下文(继承自 TracingProducerInterceptor)
 * 2. 手动注入 RequestId 到消息 Header
 */
public class KafkaProducerInterceptor extends TracingProducerInterceptor<String, String>
    implements ProducerInterceptor<String, String> {

  private static final String HEADER_REQUEST_ID = "X-Request-Id";

  @Override
  public ProducerRecord<String, String> onSend(ProducerRecord<String, String> record) {
    // 1. 先让 OpenTelemetry 注入追踪上下文(TraceId、SpanId)
    record = super.onSend(record);

    // 2. 手动注入 RequestId
    Headers headers = record.headers();
    if (headers.lastHeader(HEADER_REQUEST_ID) == null) {
      String requestId = RequestIdUtil.get();
      if (!StringUtils.hasText(requestId)) {
        requestId = RequestIdUtil.generate();
      }
      headers.add(HEADER_REQUEST_ID, requestId.getBytes());
    }

    return record;
  }

  @Override
  public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
    // 消息确认回调(可记录发送结果)
  }

  @Override
  public void close() {
  }

  @Override
  public void configure(Map<String, ?> configs) {
  }
}

5.7 Kafka 消费者拦截器 --- 消息接收

java 复制代码
package com.example.myservice.config.kafka;

import com.example.myservice.util.RequestIdUtil;
import io.opentelemetry.instrumentation.kafkaclients.v2_6.TracingConsumerInterceptor;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.header.Header;

/**
 * Kafka 消费者拦截器.
 * 消费消息时:
 * 1. OpenTelemetry 自动提取 Trace 上下文(继承自 TracingConsumerInterceptor)
 * 2. 手动从消息 Header 提取 RequestId 放入 MDC
 */
@Slf4j
public class KafkaConsumerInterceptor extends TracingConsumerInterceptor<String, String> {

  private static final String HEADER_REQUEST_ID = "X-Request-Id";
  private final ThreadLocal<Long> startTime = new ThreadLocal<>();

  @Override
  public ConsumerRecords<String, String> onConsume(ConsumerRecords<String, String> records) {
    // 1. 先让 OpenTelemetry 提取追踪上下文
    records = super.onConsume(records);

    // 2. 从消息 Header 提取 RequestId,或生成新的
    String requestId = extractRequestId(records);
    if (requestId == null) {
      requestId = RequestIdUtil.generate();
    }
    RequestIdUtil.set(requestId);

    // 3. 记录开始时间
    startTime.set(System.currentTimeMillis());
    log.debug("开始消费, 消息数={}, requestId={}", records.count(), requestId);

    return records;
  }

  @Override
  public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
    // 消费完成,记录耗时并清理
    Long start = startTime.get();
    if (start != null) {
      log.debug("消费完成, 耗时={}ms", System.currentTimeMillis() - start);
    }
    startTime.remove();
    RequestIdUtil.clear();
  }

  @Override
  public void close() {
  }

  @Override
  public void configure(Map<String, ?> configs) {
  }

  private String extractRequestId(ConsumerRecords<String, String> records) {
    // 从第一条消息的 Header 中提取 RequestId
    var iterator = records.iterator();
    if (iterator.hasNext()) {
      Header header = iterator.next().headers().lastHeader(HEADER_REQUEST_ID);
      if (header != null) {
        return new String(header.value());
      }
    }
    return null;
  }
}

5.8 application.yml 配置

yaml 复制代码
spring:
  kafka:
    producer:
      properties:
        # 注册生产者拦截器
        interceptor.classes: com.example.myservice.config.kafka.KafkaProducerInterceptor
    consumer:
      properties:
        # 注册消费者拦截器
        interceptor.classes: com.example.myservice.config.kafka.KafkaConsumerInterceptor

# 日志格式(包含 requestId)
logging:
  level:
    root: info
  pattern:
    console: "[%date{HH:mm:ss.SSS}] [%thread] [%level] [%logger{0}] [%mdc{requestId:-no-id}] - %msg%n"

# OpenTelemetry 配置(使用 Java Agent 时通过环境变量配置)
# OTEL_SERVICE_NAME=my-service
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
# OTEL_TRACES_EXPORTER=otlp

5.9 Logback 配置(logback-spring.xml)

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<configuration>

  <!-- 控制台输出 -->
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>
        [%date{HH:mm:ss.SSS}] [%thread] [%highlight(%level)] [%cyan(%logger{0})] [%red(%mdc{requestId:-no-id})] - %msg%n
      </pattern>
      <charset>UTF-8</charset>
    </encoder>
  </appender>

  <!-- JSON 格式输出(生产环境,方便 ELK 解析) -->
  <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <includeMdcKeyName>requestId</includeMdcKeyName>
      <includeMdcKeyName>traceId</includeMdcKeyName>
      <includeMdcKeyName>spanId</includeMdcKeyName>
    </encoder>
  </appender>

  <!-- 文件输出 -->
  <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>logs/app.log</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
      <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
      <maxHistory>30</maxHistory>
    </rollingPolicy>
    <encoder>
      <pattern>
        [%date{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%level] [%logger] [%mdc{requestId:-no-id}] - %msg%n
      </pattern>
    </encoder>
  </appender>

  <root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="FILE"/>
  </root>

</configuration>

5.10 实际日志输出效果

复制代码
# HTTP 请求进入订单服务
[14:30:01.123] [http-nio-8080-exec-1] [INFO] [OrderController] [a1b2c3d4e5f6] - 创建订单请求, userId=1001

# 订单服务调用库存服务(Feign),库存服务日志
[14:30:01.200] [http-nio-8081-exec-3] [INFO] [StockController] [a1b2c3d4e5f6] - 扣减库存, productId=2001

# 订单服务发送 Kafka 消息
[14:30:01.250] [http-nio-8080-exec-1] [INFO] [OrderService] [a1b2c3d4e5f6] - 发送订单创建事件

# Kafka 消费者接收消息
[14:30:01.400] [kafka-consumer-1] [INFO] [OrderEventConsumer] [a1b2c3d4e5f6] - 处理订单创建事件

# 全程同一个 RequestId: a1b2c3d4e5f6

六、OpenTelemetry Java Agent 接入方式

6.1 下载 Agent

bash 复制代码
# 下载最新版 Java Agent
wget https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar

6.2 启动参数

bash 复制代码
java -javaagent:opentelemetry-javaagent.jar \
  -Dotel.service.name=my-order-service \
  -Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \
  -Dotel.traces.exporter=otlp \
  -Dotel.metrics.exporter=none \
  -Dotel.logs.exporter=none \
  -jar my-order-service.jar

6.3 Agent 自动埋点范围

无需改代码,Agent 自动追踪:

框架/中间件 自动追踪内容
Spring MVC 每个 HTTP 请求自动创建 Span
Spring WebFlux 响应式请求追踪
OpenFeign Feign 调用自动传播上下文
Kafka 生产/消费自动传播 TraceId
MySQL (JDBC) SQL 语句和执行时间
Redis (Lettuce/Jedis) Redis 命令追踪
OkHttp HTTP 调用追踪
gRPC RPC 调用追踪

6.4 Docker / Kubernetes 部署

dockerfile 复制代码
FROM eclipse-temurin:17-jre

COPY target/my-service.jar /app/app.jar
COPY opentelemetry-javaagent.jar /app/otel-agent.jar

ENV OTEL_SERVICE_NAME=my-order-service
ENV OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
ENV OTEL_TRACES_EXPORTER=otlp
ENV JAVA_TOOL_OPTIONS="-javaagent:/app/otel-agent.jar"

ENTRYPOINT ["java", "-jar", "/app/app.jar"]

七、OpenTelemetry 手动埋点

当需要追踪自定义业务逻辑(Agent 无法自动覆盖的场景)时:

java 复制代码
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;

@Service
public class OrderServiceImpl implements OrderService {

  // 获取 Tracer
  private static final Tracer tracer = 
      GlobalOpenTelemetry.getTracer("order-service", "1.0.0");

  @Override
  public OrderResponse createOrder(CreateOrderRequest request) {
    // 创建自定义 Span
    Span span = tracer.spanBuilder("createOrder")
        .setAttribute("user.id", request.getUserId())
        .setAttribute("product.id", request.getProductId())
        .startSpan();

    try (Scope scope = span.makeCurrent()) {
      // 业务逻辑...
      OrderResponse response = doCreateOrder(request);
      
      span.setAttribute("order.id", response.getOrderId());
      return response;
      
    } catch (Exception e) {
      span.recordException(e);
      span.setStatus(io.opentelemetry.api.trace.StatusCode.ERROR, e.getMessage());
      throw e;
    } finally {
      span.end();
    }
  }
}

八、可视化追踪平台

8.1 常见后端

平台 特点 部署方式
Jaeger CNCF 毕业项目,功能全面,社区活跃 Docker / K8s
Zipkin Twitter 开源,轻量级,入门简单 Docker / JAR
SkyWalking Apache 项目,对 Java 生态友好,中文社区活跃 Docker / K8s
Grafana Tempo 与 Grafana 深度集成,存储成本低 Docker / K8s
AWS X-Ray AWS 原生,与 AWS 服务深度集成 托管服务
阿里云 SLS 阿里云日志服务,支持追踪 托管服务

8.2 Jaeger 快速启动(本地开发)

bash 复制代码
# 一键启动 Jaeger All-in-One(包含 UI + 收集器 + 存储)
docker run -d --name jaeger \
  -p 16686:16686 \        # Jaeger UI
  -p 4317:4317 \          # OTLP gRPC(OpenTelemetry 数据接收)
  -p 4318:4318 \          # OTLP HTTP
  jaegertracing/all-in-one:latest

# 访问 Jaeger UI:http://localhost:16686

配合应用启动参数:

bash 复制代码
java -javaagent:opentelemetry-javaagent.jar \
  -Dotel.service.name=order-service \
  -Dotel.exporter.otlp.endpoint=http://localhost:4317 \
  -jar my-service.jar

8.3 追踪 UI 中能看到什么

复制代码
┌─────────────────────────────────────────────────────────────┐
│ Jaeger UI - Trace Detail                                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│ TraceId: a1b2c3d4e5f67890                                   │
│ Duration: 523ms                                             │
│ Services: 3  |  Spans: 7                                    │
│                                                             │
│ ──┬─ order-service: POST /api/order/create [500ms] ───────  │
│   │                                                         │
│   ├── order-service: SELECT from t_order [12ms]             │
│   │                                                         │
│   ├── stock-service: POST /api/stock/deduct [120ms] ──────  │
│   │   └── stock-service: UPDATE stock SET qty... [8ms]      │
│   │                                                         │
│   ├── payment-service: POST /api/pay [280ms] ────────────   │
│   │   └── payment-service: 调用支付宝API [250ms]            │
│   │                                                         │
│   └── order-service: Kafka send [15ms]                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

可以一目了然看到:

  • 哪个服务最慢(支付服务 280ms)
  • 瓶颈在哪里(支付宝外部 API 250ms)
  • 调用层级关系
  • 每个操作的耗时

九、OpenTelemetry Collector 架构

生产环境通常不直接将数据发给 Jaeger,而是经过 Collector 中转:

复制代码
┌──────────┐   ┌──────────┐   ┌──────────┐
│ 订单服务  │   │ 库存服务  │   │ 支付服务  │
└────┬─────┘   └────┬─────┘   └────┬─────┘
     │              │              │
     └──────────────┼──────────────┘
                    ↓
         ┌─────────────────────┐
         │  OTel Collector      │  ← 统一接收、处理、转发
         │  ┌───────────────┐  │
         │  │  Receivers     │  │  接收数据(OTLP/Jaeger/Zipkin 格式)
         │  ├───────────────┤  │
         │  │  Processors    │  │  过滤、采样、批量化
         │  ├───────────────┤  │
         │  │  Exporters     │  │  发送到后端
         │  └───────────────┘  │
         └──────────┬──────────┘
                    │
        ┌───────────┼───────────┐
        ↓           ↓           ↓
    ┌────────┐  ┌────────┐  ┌────────────┐
    │ Jaeger │  │Promethe│  │ 阿里云 SLS  │
    │(追踪)  │  │us(指标)│  │  (日志)    │
    └────────┘  └────────┘  └────────────┘

Collector 配置文件(otel-collector-config.yaml)

yaml 复制代码
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 5s
    send_batch_size: 1024
  
  # 采样:只保留 10% 的正常请求追踪,100% 的错误请求
  probabilistic_sampler:
    sampling_percentage: 10

exporters:
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true
  
  logging:
    loglevel: debug

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger, logging]

十、异步场景下的上下文传播

10.1 问题

MDC 基于 ThreadLocal,线程切换时 RequestId 会丢失:

java 复制代码
// ❌ 异步执行时 RequestId 丢失
CompletableFuture.runAsync(() -> {
    log.info("异步任务");  // MDC 为空,没有 RequestId
});

10.2 解决方案

方案一:手动传递
java 复制代码
String requestId = RequestIdUtil.get();  // 主线程中获取

CompletableFuture.runAsync(() -> {
    RequestIdUtil.set(requestId);  // 子线程中设置
    try {
        log.info("异步任务");  // 现在有 RequestId 了
    } finally {
        RequestIdUtil.clear();
    }
});
方案二:装饰线程池
java 复制代码
/**
 * 支持 MDC 传播的线程池装饰器.
 */
public class MdcTaskDecorator implements TaskDecorator {

  @Override
  public Runnable decorate(Runnable runnable) {
    // 在主线程中捕获 MDC 上下文
    Map<String, String> contextMap = MDC.getCopyOfContextMap();
    
    return () -> {
      try {
        // 在子线程中恢复 MDC 上下文
        if (contextMap != null) {
          MDC.setContextMap(contextMap);
        }
        runnable.run();
      } finally {
        MDC.clear();
      }
    };
  }
}

// 配置线程池
@Bean
public Executor asyncExecutor() {
  ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
  executor.setCorePoolSize(10);
  executor.setMaxPoolSize(50);
  executor.setTaskDecorator(new MdcTaskDecorator());  // 关键
  executor.initialize();
  return executor;
}
方案三:OpenTelemetry 的 Context 自动传播

OpenTelemetry SDK 有自己的 Context 机制,在使用 @AsyncCompletableFuture 时可以通过 Context.current().wrap() 自动传播:

java 复制代码
import io.opentelemetry.context.Context;

// OpenTelemetry 的 Context 包装
Runnable wrapped = Context.current().wrap(() -> {
    // 这里可以获取到父 Span 的上下文
    log.info("异步任务,Trace 自动传播");
});

CompletableFuture.runAsync(wrapped);

十一、采样策略

生产环境全量采集追踪数据会产生巨大存储开销,需要采样:

策略 说明 适用场景
全量采样 100% 采集 开发/测试环境
概率采样 按比例采集(如 10%) 生产环境,流量大
限速采样 每秒最多 N 条 控制存储成本
尾部采样 只保留异常/慢请求 关注问题链路
父级采样 跟随父 Span 的采样决策 保持链路完整性

配置示例(Java Agent 环境变量):

bash 复制代码
# 10% 概率采样
-Dotel.traces.sampler=parentbased_traceidratio
-Dotel.traces.sampler.arg=0.1

# 全量采样(开发环境)
-Dotel.traces.sampler=always_on

# 全部不采样(关闭追踪)
-Dotel.traces.sampler=always_off

十二、完整传播链路图

复制代码
┌─────────────────────────────────────────────────────────────────────────┐
│                          完整的追踪传播链路                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  [用户请求]                                                              │
│       │                                                                 │
│       ↓ HTTP Header: X-Request-Id (无则生成)                             │
│                                                                         │
│  ┌─── 服务A ───────────────────────────────────────────┐                │
│  │ Filter: 提取/生成 RequestId → MDC.put("requestId")  │                │
│  │ OTel Agent: 自动创建 Span, 生成 TraceId             │                │
│  │                                                     │                │
│  │ log.info("处理请求")                                 │                │
│  │ → [14:30:01] [INFO] [abc-123] 处理请求              │                │
│  │                                                     │                │
│  │ ┌──── Feign 调用 ────────────────────┐              │                │
│  │ │ Header: X-Request-Id: abc-123      │              │                │
│  │ │ Header: traceparent: 00-trace...   │ (OTel 自动)  │                │
│  │ └────────────────┬───────────────────┘              │                │
│  └──────────────────│──────────────────────────────────┘                │
│                     ↓                                                   │
│  ┌─── 服务B ───────────────────────────────────────────┐                │
│  │ Filter: 提取 RequestId: abc-123 → MDC               │                │
│  │ OTel Agent: 从 traceparent 恢复 Trace 上下文         │                │
│  │                                                     │                │
│  │ log.info("处理库存") → [abc-123] 处理库存            │                │
│  └─────────────────────────────────────────────────────┘                │
│                                                                         │
│  ┌──── Kafka 传播 ─────────────────────────────────────┐                │
│  │ Producer Interceptor:                               │                │
│  │   → Header: X-Request-Id: abc-123                   │                │
│  │   → Header: traceparent: 00-trace...  (OTel 自动)   │                │
│  │                                                     │                │
│  │ Consumer Interceptor:                               │                │
│  │   → 提取 X-Request-Id → MDC                         │                │
│  │   → OTel 恢复 Trace 上下文                           │                │
│  │                                                     │                │
│  │ log.info("消费消息") → [abc-123] 消费消息            │                │
│  └─────────────────────────────────────────────────────┘                │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

十三、常见问题 FAQ

Q1: RequestId 和 OpenTelemetry 的 TraceId 用哪个?

两者并存互补:

  • RequestId:轻量级,用于日志搜索,不依赖追踪基础设施
  • TraceId:重量级,用于链路可视化和性能分析

小团队起步可以先用 RequestId(零基础设施依赖),后续需要性能分析时再引入 OpenTelemetry。

Q2: 为什么 Kafka 拦截器要继承 TracingProducerInterceptor?

TracingProducerInterceptor 是 OpenTelemetry 提供的拦截器,它会自动在 Kafka 消息 Header 中注入 traceparent 字段(W3C Trace Context 标准),实现追踪上下文跨 Kafka 传播。继承它后,一行代码就能让 Kafka 消息自动参与到分布式追踪中。

Q3: 线程池切换后 RequestId 丢失怎么办?

三种解决方案(见第十节):

  1. 手动传递(简单但麻烦)
  2. TaskDecorator 装饰线程池(推荐)
  3. OpenTelemetry Context.wrap()(如果已接入 OTel)

Q4: 生产环境全量追踪会影响性能吗?

会。影响主要在两方面:

  • 内存:每个 Span 占用内存
  • 网络:追踪数据发送到 Collector

解决:使用采样策略,生产环境推荐 1%-10% 采样率。关键是确保错误和慢请求 100% 采集

Q5: 没有 OpenTelemetry 也能做分布式追踪吗?

可以。只用 RequestId + MDC 就能实现基本的日志关联:

  1. 入口生成 UUID
  2. MDC 放入日志
  3. Feign 拦截器传递 Header
  4. Kafka Header 传递

只是无法像 Jaeger 那样可视化调用链和统计耗时,但搜索日志已经够用了。

Q6: %mdc{requestId:-no-id} 是什么意思?

Logback 的 MDC 占位符语法:

  • %mdc{requestId} --- 输出 MDC 中 key 为 requestId 的值
  • :-no-id --- 如果值为空,输出默认值 no-id

十四、总结

组件 职责 实现方式
RequestId 全链路日志关联 UUID + MDC + Header 传递
OpenTelemetry Agent 自动埋点,零代码侵入 JVM Agent 启动参数
OTel SDK 手动埋点,自定义 Span 代码中创建 Span
Kafka 拦截器 消息队列追踪传播 继承 TracingXxxInterceptor
Feign 拦截器 HTTP 跨服务传播 RequestInterceptor + Header
MDC 线程级日志上下文 ThreadLocal 存储 RequestId
Collector 统一收集、处理、转发 独立部署的中间件
Jaeger/Zipkin 追踪数据可视化 Web Dashboard

一句话总结:给每个请求一个 ID,让这个 ID 跟着请求走遍所有服务和中间件,最终通过 ID 把散落各处的日志串成一条完整链路。

相关推荐
cheems95271 小时前
[RabbitMQ高级特性] 消息确认机制:从 Ready / Unacked 到 basicAck、basicReject、basicNack 的底层拆解
分布式·rabbitmq·ruby
枫华落尽2 小时前
【Hadoop01-完全分布式运行模式】
分布式
隔壁阿布都2 小时前
ShedLock 分布式定时任务锁框架介绍
spring boot·分布式
文艺倾年2 小时前
【强化学习】数学推导专题,20W字总结(十五)
人工智能·分布式·大模型·强化学习·vibecoding
guslegend2 小时前
第1章:初始Kafka
分布式·kafka
ACP广源盛1392462567320 小时前
GSV5600@ACP#多接口协议转换芯片,物理 AI 便携终端的互联核心
大数据·人工智能·分布式·嵌入式硬件·spark
极客先躯1 天前
高级java每日一道面试题-2026年02月12日-实战篇[Docker]-什么是容器的 Seccomp 配置?如何自定义?
java·运维·分布式·docker·容器·自动化·文件
Francek Chen1 天前
【大数据处理与分析】MapReduce:06 MapReduce编程实践
大数据·hadoop·分布式·mapreduce
小马爱打代码1 天前
Kafka消息队列监控:Topic积压、吞吐量、Broker负载及消费者组全观测
分布式·kafka