分布式追踪与 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 机制,在使用 @Async 或 CompletableFuture 时可以通过 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 丢失怎么办?
三种解决方案(见第十节):
- 手动传递(简单但麻烦)
- TaskDecorator 装饰线程池(推荐)
- OpenTelemetry Context.wrap()(如果已接入 OTel)
Q4: 生产环境全量追踪会影响性能吗?
会。影响主要在两方面:
- 内存:每个 Span 占用内存
- 网络:追踪数据发送到 Collector
解决:使用采样策略,生产环境推荐 1%-10% 采样率。关键是确保错误和慢请求 100% 采集。
Q5: 没有 OpenTelemetry 也能做分布式追踪吗?
可以。只用 RequestId + MDC 就能实现基本的日志关联:
- 入口生成 UUID
- MDC 放入日志
- Feign 拦截器传递 Header
- 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 把散落各处的日志串成一条完整链路。