微服务链路追踪:OpenTelemetry + Jaeger/Zipkin,TraceId 透传与采样策略
提出问题
一个请求跨 5 个服务,用户反馈"页面加载慢",你打开日志发现 5 个服务各自打印了耗时,但根本连不起来——不知道是 A 服务慢了还是调用 C 服务时网络延迟了。这就是微服务架构下最常见的排障困境:服务间调用链是断裂的,每个服务只看到自己的局部视角。
链路追踪(Distributed Tracing)就是为了解决这个问题的。它通过一个全局唯一的 TraceId 把一次请求经过的所有服务、所有调用串成一条完整的调用链,告诉你:整个请求花了多少时间,每个环节各花了多少,哪个环节最慢,哪个环节出错了。2025 年 OpenTelemetry 已取代 Jaeger/Zipkin 等成为链路追踪的事实标准,"有没有接入链路追踪"已经从加分项变成了微服务基础设施的标配。
分析问题
核心概念:Trace、Span、SpanContext
链路追踪的三个核心抽象:
- Trace:一次完整请求的调用链,由全局唯一的 TraceId 标识。一个 Trace 由多个 Span 组成。
- Span:Trace 中某个服务节点的处理单元,记录开始时间、结束时间、状态、标签。每个 Span 有 SpanId 和 ParentSpanId,通过 ParentSpanId 构建父子关系形成调用树。
- SpanContext:TraceId + SpanId + Baggage 的载体,通过 HTTP Header(
traceparent/uber-trace-id)或 gRPC Metadata 在服务间传递。
举个例子,用户下单请求经过网关 → 订单服务 → 库存服务 → 支付服务,会生成 4 个 Span,共享同一个 TraceId,通过 ParentSpanId 串联为如下的调用树:
Trace: abc123
├── Span: 网关 (开始 → 结束, 耗时 350ms, parentId=null)
│ ├── Span: 订单服务 (耗时 300ms, parentId=网关.spanId)
│ │ ├── Span: 库存服务 (耗时 100ms, parentId=订单.spanId)
│ │ └── Span: 支付服务 (耗时 150ms, parentId=订单.spanId)OpenTelemetry 架构:API → SDK → Exporter → Collector
OpenTelemetry(简称 OTel)是 CNCF 的孵化项目,统一了链路追踪、指标和日志的采集规范。它的架构分四层:
- API:定义 Trace、Span、Metric、Log 的接口规范,不绑定具体实现。各语言实现同一套 API 接口。
- SDK:各语言的具体实现,负责 Span 创建、上下文传递、采样决策、数据导出。Java 端通过
opentelemetry-java包引入,自动织入 Spring Boot 的 RestTemplate、WebClient、gRPC 等常见框架。 - Exporter:将数据发送到后端,支持 Jaeger、Zipkin、Prometheus、Datadog、阿里云 ARMS 等。Exporter 是异步的,不会阻塞业务请求。
- Collector:可选的中转层,负责接收数据 → 处理(过滤、采样、属性增强)→ 转发到后端。强烈推荐部署 Collector,它让 SDK 变得轻量(只发数据不做处理),同时提供统一的处理管道。
// OpenTelemetry Java SDK 手动创建 Span 示例
Tracer tracer = OpenTelemetrySdk.builder()
.setTracerProvider(SdkTracerProvider.builder()
.addSpanProcessor(BatchSpanProcessor.builder(OtlpGrpcSpanExporter.builder()
.setEndpoint("http://otel-collector:4317")
.build()).build())
.build())
.build().getTracer("order-service");
Span span = tracer.spanBuilder("createOrder")
.setAttribute("orderId", "ORD-20260720-0001")
.setAttribute("userId", "user_12345")
.startSpan();
try (Scope scope = span.makeCurrent()) {
// 业务逻辑
Thread.sleep(50); // 模拟处理
span.setStatus(StatusCode.OK);
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR, e.getMessage());
throw e;
} finally {
span.end();
}TraceId 跨服务透传的三种场景
TraceId 的透传是链路追踪能否工作的核心。不同通信方式有不同的透传策略:
HTTP 场景(REST/gRPC):OpenTelemetry SDK 自动通过 traceparent Header(W3C TraceContext 标准格式:00-traceId-spanId-01)传递,Spring Boot 应用引入 opentelemetry-spring-boot-starter 后零配置。
消息队列场景(Kafka/RabbitMQ):TraceId 无法通过 MDC 自动传递,需要手动在消息体里携带 TraceId,消费者端从消息中提取并重新设置 MDC。
// 生产者:在消息头中携带 TraceId
Span span = tracer.spanBuilder("sendOrderMessage").startSpan();
try {
String traceId = span.getSpanContext().getTraceId();
ProducerRecord<String, String> record = new ProducerRecord<>("order-topic", messageBody);
record.headers().add("traceparent", ("00-" + traceId + "-" + span.getSpanContext().getSpanId() + "-01").getBytes());
kafkaTemplate.send(record);
} finally {
span.end();
}
// 消费者:从消息头恢复 TraceId
@KafkaListener(topics = "order-topic")
public void onMessage(ConsumerRecord<String, String> record) {
String traceparent = new String(record.headers().lastHeader("traceparent").value());
// 解析 traceparent 并设置到当前线程的 MDC 上下文
SpanContext extracted = W3CTraceContextPropagator.getInstance().extract(
Context.current(), record.headers(), (headers, key) -> {
Header h = headers.lastHeader(key);
return h != null ? new String(h.value()) : null;
});
try (Scope scope = Span.fromContext(extracted).makeCurrent()) {
// 业务处理
}
}线程池场景:ThreadPoolExecutor 的线程复用会导致 MDC 中的 TraceId 被污染——线程 A 处理完请求后 MDC 没清干净,线程 B 复用时拿到了线程 A 的 TraceId。解决方案是用装饰器模式为每个任务单独设置 MDC 上下文:
public class TraceAwareThreadPool extends ThreadPoolExecutor {
@Override
public void execute(Runnable command) {
// 提交任务时记录当前线程的 TraceContext
Context currentContext = Context.current();
super.execute(() -> {
// 在新的工作线程中恢复 TraceContext
try (Scope scope = currentContext.makeCurrent()) {
command.run();
}
});
}
}采样策略:全量 vs 概率 vs 自适应
一个日均 1 亿请求的微服务系统,如果全量采样,每天存储量可达 200GB+,远超多数团队预算。因此采样是链路追踪落地必须考虑的问题。
| 采样策略 | 原理 | 适用场景 | 优缺点 |
|---|---|---|---|
| 头部采样(Head-based) | 请求入口决定是否采样,后续所有 Span 跟随 | QPS 稳定、流量均匀的系统 | 简单高效,但可能错过慢请求 |
| 尾部采样(Tail-based) | 请求完成后根据延迟/错误率决定是否保留 | 需要精确抓取慢请求和异常 | 存储开销大,能抓到关键异常 |
| 概率采样 | 固定比例(如 1%、10%) | 流量大的稳定系统 | 最省资源,但采样比例固定不够灵活 |
| 自适应采样 | 根据错误率动态调整,错误率升高时自动提高采样比例 | 生产环境推荐 | 实现复杂,但效果最好 |
生产推荐方案:分层采样——QPS 低于 5000 的接口全量采样,QPS 超过 50000 的接口采样率降到 0.1%,同时配置错误请求自动提升采样率(错误采样率 100%)。OpenTelemetry Collector 的 Tail Sampling Processor 可以基于错误状态、延迟阈值等条件精确控制哪些 Span 保留。
实战:OpenTelemetry Collector 采样配置
# otel-collector-config.yaml — 尾部采样配置
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
tail_sampling:
decision_wait: 30s # 等待 30 秒后再做采样决策
num_traces: 100000 # 最多同时追踪 10 万条 Trace
expected_new_traces_per_sec: 1000
policies:
- name: error-policy # 错误请求 100% 保留
type: status_code
status_code:
status_codes:
- ERROR
- name: slow-policy # 延迟超过 500ms 的请求 100% 保留
type: latency
latency:
threshold_ms: 500
- name: prob-policy # 剩下的请求按 1% 概率采样
type: probabilistic
probabilistic:
sampling_percentage: 1
exporters:
jaeger:
endpoint: jaeger:14250
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling]
exporters: [jaeger]总结
关键点清单:
- TraceId 透传是链路追踪的基础——HTTP 用
traceparentHeader 自动传递,MQ 和线程池场景需要手动处理,踩过的坑最多。 - OpenTelemetry Collector 是必选项——不要在 SDK 中直接配 Jaeger/Zipkin 地址,统一走 Collector 做采样、过滤、缓冲,减少 SDK 耦合。
- 采样策略按接口分层——低 QPS 全量采样,高 QPS 概率采样,错误/慢请求提权采样,这是生产级链路追踪的标配实践。
- 链路追踪与日志打通——日志中输出 traceId,Kibana 搜
traceId:abc123就能看到整个请求的日志,这才是链路追踪的真正价值——不是"看调用链",而是"用调用链关联日志"。
参考:OpenTelemetry 官方文档 · Trace Context Propagation,Jaeger 官方 · Architecture & Sampling,OpenTelemetry Collector · Tail Sampling Processor