为什么需要分布式追踪

微服务架构下,一个用户请求往往要穿越多个服务。当某个接口耗时从 200ms 飙升到 2s 时,日志散落在 N 台机器上,你很难判断瓶颈在哪一层——是网关转发慢、下游 DB 查询慢、还是某个服务间调用排队?

分布式追踪(Distributed Tracing)解决的就是这个问题:它为每个请求生成一个全局唯一的 Trace ID,在服务间透传,最终在 UI 上画出一条完整的调用链路树,让每一段耗时一目了然。

Jaeger(发音类似"耶格")由 Uber 开源,现为 CNCF 毕业项目。本文基于 Jaeger v1.60+ 和 OpenTelemetry SDK 进行实践。官方文档

分布式追踪核心概念

Trace

一个 Trace 代表一次完整的分布式请求链路,由唯一的 128-bit Trace ID 标识。它是一棵由多个 Span 组成的树形结构:

Trace (TraceID: a1b2c3...)
├── Span: HTTP GET /api/orders         [gateway]
│   ├── Span: RPC GetUser             [user-service]
│   │   └── Span: SELECT * FROM users [mysql]
│   └── Span: RPC GetOrderList         [order-service]
│       └── Span: Redis GET           [redis]

Span

Span 是追踪的最小单元,记录一次操作的开始和结束。关键字段:

字段说明
TraceID所属 Trace 的全局唯一 ID
SpanID当前 Span 的唯一 ID
ParentSpanID父 Span ID,用于构建调用树
OperationName操作名称,如 GET /api/orders
StartTime / Duration开始时间与耗时
Tags结构化标签,如 http.status_code=200
Logs时间戳事件,如异常堆栈
SpanKindCLIENT / SERVER / PRODUCER / CONSUMER / INTERNAL

Context Propagation

上下文传播是分布式追踪的基石。当 Service A 调用 Service B 时,必须将 TraceID、SpanID、采样标志等信息通过请求头传递给 Service B,这样 Service B 创建的 Span 才能挂到同一棵 Trace 树上。

OpenTelemetry 定义了两种标准传播格式:

  • W3C TraceContext(推荐):通过 traceparenttracestate 请求头传递,格式为 traceparent: 00-{trace-id}-{span-id}-{flags}
  • Baggage:通过 baggage 请求头传递业务键值对(如 user.id=12345),跨服务共享业务上下文
# W3C traceparent 格式示例
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
              ↑  ↑______________________↑ ↑________________↑ ↑
            version      trace-id            parent-id       flags

W3C TraceContext 规范详见 W3C Recommendation

OpenTelemetry SDK 接入

OpenTelemetry(简称 OTel)是 CNCF 的可观测性标准,统一了 Trace、Metrics、Logs 三大信号的 API 和 SDK。Jaeger 原生支持 OTLP 协议接收数据。

Go 服务接入

安装依赖:

go get go.opentelemetry.io/otel \
       go.opentelemetry.io/otel/sdk \
       go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc \
       go.opentelemetry.io/otel/trace \
       go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp \
       go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc

初始化 Tracer Provider:

package tracing

import (
	"context"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

func InitTracer(ctx context.Context, serviceName, otelEndpoint string) (*sdktrace.TracerProvider, error) {
	// 创建 OTLP gRPC exporter,指向 OTel Collector 或 Jaeger
	exporter, err := otlptracegrpc.New(ctx,
		otlptracegrpc.WithEndpoint(otelEndpoint),
		otlptracegrpc.WithInsecure(),
	)
	if err != nil {
		return nil, err
	}

	// 定义服务级资源信息,在 Jaeger UI 中可作为筛选条件
	res, _ := resource.New(ctx,
		resource.WithAttributes(
			semconv.ServiceName(serviceName),
			semconv.ServiceVersion("1.0.0"),
			semconv.DeploymentEnvironment("production"),
		),
	)

	tp := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exporter,
			sdktrace.WithBatchTimeout(5*time.Second),
			sdktrace.WithMaxExportBatchSize(512),
		),
		sdktrace.WithResource(res),
		// 设置采样器(也可用 Remote/Adaptive Sampling 替代)
		sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1)),
	)

	otel.SetTracerProvider(tp)
	// 使用 W3C TraceContext 作为标准传播格式
	otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
		propagation.TraceContext{},
		propagation.Baggage{},
	))

	return tp, nil
}

HTTP 服务自动埋点——通过 otelhttp 中间件,无需修改业务代码:

package main

import (
	"context"
	"log"
	"net/http"

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func main() {
	ctx := context.Background()
	tp, err := tracing.InitTracer(ctx, "order-service", "jaeger:4317")
	if err != nil {
		log.Fatal(err)
	}
	defer tp.Shutdown(ctx)

	mux := http.NewServeMux()
	mux.HandleFunc("/api/orders", handleGetOrders)

	// 用 otelhttp.NewHandler 包装,自动为每个请求创建 Span
	// 并从入站请求头提取 trace context
	wrappedHandler := otelhttp.NewHandler(mux, "order-service")

	log.Println("listening on :8080")
	http.ListenAndServe(":8080", wrappedHandler)
}

func handleGetOrders(w http.ResponseWriter, r *http.Request) {
	// 从 context 中获取当前 span,添加业务标签
	span := otelhttp.SpanFromContext(r.Context())
	span.SetAttributes(attribute.String("user.id", r.URL.Query().Get("uid")))

	// 调用下游服务时,otelhttp 会自动注入 traceparent 头
	resp, err := otelhttp.Get(r.Context(), "http://user-service:8081/api/user")
	// ...
}

Python 服务接入

# pip install opentelemetry-distro opentelemetry-exporter-otlp
# opentelemetry-bootstrap -a install

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.flask import FlaskInstrumentor

# 初始化 Tracer
resource = Resource.create({
    "service.name": "user-service",
    "service.version": "1.0.0",
    "deployment.environment": "production",
})

provider = TracerProvider(resource=resource)
provider.add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(endpoint="jaeger:4317", insecure=True),
        max_export_batch_size=512,
    )
)
trace.set_tracer_provider(provider)

# Flask 自动埋点:每个 HTTP 请求自动生成 Server Span
app = Flask(__name__)
FlaskInstrumentor().instrument_app(app)

@app.route("/api/user")
def get_user():
    # 手动创建子 Span 记录 DB 查询
    tracer = trace.get_tracer(__name__)
    with tracer.start_as_current_span("mysql.query.user") as span:
        span.set_attribute("db.system", "mysql")
        span.set_attribute("db.statement", "SELECT * FROM users WHERE id=?")
        user = db.query("SELECT * FROM users WHERE id=?", uid)
        return jsonify(user)

gRPC 上下文传递

gRPC 调用的链路传递通过拦截器实现,确保 metadata 中自动注入和提取 traceparent

// gRPC Server 端:注册 otelgrpc 拦截器
import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"

server := grpc.NewServer(
    grpc.StatsHandler(otelgrpc.NewServerHandler()),
)

// gRPC Client 端:同样通过 StatsHandler 自动注入上下文
conn, _ := grpc.Dial("user-service:50051",
    grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)

这样 Service A 调用 Service B 的 gRPC 请求会自动携带 traceparent metadata,Service B 端的 Span 会自动挂在 Service A 的 Span 下。

Jaeger 部署

Docker Compose 快速部署

最简部署使用 All-in-One 镜像(适合开发测试),内置了 Collector + Query + 内存存储:

# docker-compose.yml
version: '3.8'

services:
  jaeger:
    image: jaegertracing/all-in-one:1.60
    ports:
      - "16686:16686"   # Jaeger UI
      - "4317:4317"     # OTLP gRPC
      - "4318:4318"     # OTLP HTTP
      - "5778:5778"     # 配置传播(sampling)
    environment:
      - COLLECTOR_OTLP_ENABLED=true
      - LOG_LEVEL=info
    restart: unless-stopped

启动后访问 http://localhost:16686 即可看到 Jaeger UI。

Kubernetes 生产部署

生产环境推荐使用 Jaeger Operator,支持自动注入 Sidecar 和灵活的存储后端配置:

# jaeger-operator.yaml
apiVersion: jaegertracing.io/v1
kind: Jaeger
metadata:
  name: jaeger-prod
  namespace: observability
spec:
  strategy: production
  storage:
    type: elasticsearch
    options:
      es:
        server-urls: https://elasticsearch:9200
        index-prefix: jaeger
    secretName: jaeger-es-secret
  # 启用 OTLP 接收端口
  collector:
    options:
      collector.otlp.enabled: true
  query:
    options:
      query.base-path: /jaeger
  ingress:
    enabled: true
    hosts:
      - jaeger.sre.wang
  # 采样配置
  sampling:
    options:
      sampling.type: remote
      sampling.strategies-file: /etc/jaeger/sampling.json

部署 Operator 和实例:

# 安装 Jaeger Operator
kubectl create namespace observability
kubectl apply -f https://github.com/jaegertracing/jaeger-operator/releases/download/v1.60.0/operator.yaml -n observability

# 部署 Jaeger 实例
kubectl apply -f jaeger-operator.yaml

采样策略

在高流量系统中,追踪所有请求会产生巨大的数据量和性能开销。采样策略决定了哪些 Trace 会被记录和上报。

常量采样(Const)

100% 采样或完全不采样,仅适用于开发调试:

sdktrace.WithSampler(sdktrace.AlwaysSample())   // 全量
sdktrace.WithSampler(sdktrace.NeverSample())     // 关闭

概率采样(Probabilistic)

按比例采样,例如 10% 的请求被追踪:

sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1))

远程采样(Remote Sampling)

Remote Sampling 允许 Collector 动态下发采样策略,无需重启服务即可调整采样率。这是生产环境的推荐方案。

首先在 Jaeger Collector 侧配置策略文件:

// /etc/jaeger/sampling.json
{
  "service_strategies": [
    {
      "service": "order-service",
      "operation_strategies": [
        {
          "operation": "GET /api/orders",
          "probabilistic": { "samplingRate": 0.5 }
        },
        {
          "operation": "POST /api/orders",
          "probabilistic": { "samplingRate": 1.0 }
        }
      ],
      "default_strategy": {
        "probabilistic": { "samplingRate": 0.1 }
      }
    },
    {
      "service": "user-service",
      "default_strategy": {
        "probabilistic": { "samplingRate": 0.05 }
      }
    }
  ],
  "default_strategy": {
    "probabilistic": { "samplingRate": 0.01 }
  }
}

Go 侧使用 Remote Sampling 需要额外引入 jaegerclient 或通过 OTel Collector 的采样扩展实现。OTel SDK 层面,推荐通过 OTLP 上报后由 Collector 做尾部采样。

Adaptive Sampling(自适应采样)

Jaeger 独有的 Adaptive Sampling 会根据各操作的 QPS 自动调整采样率:QPS 高的操作降低采样率,QPS 低的操作提高采样率,确保每种操作都有足够样本用于分析。

# Jaeger Collector 启用 Adaptive Sampling
SAMPLING_TYPE=adaptive
SAMPLING_TARGET_SAMPLES_PER_SECOND=1.0

Adaptive Sampling 的原理和使用可参考 Jaeger Sampling 文档

实战:微服务链路分析与瓶颈定位

场景描述

一个电商下单请求涉及 4 个服务:

Gateway → OrderService → UserService (gRPC)
                    → InventoryService (HTTP)
                    → PaymentService (gRPC)

用户反馈下单接口偶发超时(P99 达到 5s),但各服务自身日志无报错。

分析过程

  1. 在 Jaeger UI 中筛选 Trace:按 Service=gateway、Operation=POST /api/ordersmin duration=3s 筛选出慢请求 Trace

  2. 查看 Trace 树形图:展开 Trace 详情,找到耗时最长的 Span

  3. 定位瓶颈

Gateway: POST /api/orders           5230ms
├── OrderService: CreateOrder       5200ms ← 总耗时主要在这里
│   ├── UserService: GetUser          15ms  ✓ 正常
│   ├── InventoryService: Lock        20ms  ✓ 正常
│   └── PaymentService: Charge      5100ms ← 瓶颈!
│       └── DB: UPDATE transactions 5080ms ← 根因:DB 行锁等待
  1. 确认根因:PaymentService 的 Span Tag 中 db.statement=UPDATE transactions WHERE...db.duration=5080ms。进一步查 PaymentService 的 DB 监控发现 pg_locks 在该时间段大量 locktype=transactionid,确认是并发下单导致同一行记录锁等待。

Span Tags 好的实践

为便于排查,建议在关键路径上添加语义化标签:

// HTTP 调用
span.SetAttributes(
    semconv.HTTPMethod("POST"),
    semconv.HTTPRoute("/api/orders"),
    semconv.HTTPStatusCode(200),
    attribute.Int("order.amount", 299),
)

// DB 查询
span.SetAttributes(
    semconv.DBSystem("postgresql"),
    semconv.DBStatement("UPDATE transactions SET status=? WHERE id=?"),
    attribute.Int("db.rows_affected", 1),
)

// 错误记录
import "go.opentelemetry.io/otel/codes"

span.SetStatus(codes.Error, "db connection timeout")
span.RecordError(err)
span.SetAttributes(attribute.String("error.type", "ConnectionTimeout"))

基于 Trace 的告警

结合 Prometheus + Tempo/Loki,可以将 Trace 指标化为告警。OTel Collector 提供 spanmetrics connector,自动将 Span 转为 Prometheus 指标:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

connectors:
  spanmetrics:
    histogram:
      explicit:
        buckets: [10ms, 50ms, 100ms, 500ms, 1s, 5s, 10s]

exporters:
  prometheus:
    endpoint: 0.0.0.0:8889
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [spanmetrics, otlp/jaeger]
    metrics/spanmetrics:
      receivers: [spanmetrics]
      exporters: [prometheus]

这样就能在 Prometheus 中查询 traces_span_metrics_duration_seconds_bucket 并配置告警:

# P99 延迟告警
histogram_quantile(0.99, sum(rate(
  traces_span_metrics_duration_seconds_bucket{
    service_name="order-service",
    span_name="POST /api/orders"
  }[5m]
)) by (le)) > 3

总结

分布式追踪是可观测性三大支柱(Metrics、Logs、Traces)中串联前两者的关键。实践建议:

  • 统一传播格式:全链路使用 W3C TraceContext,避免异构系统间上下文断裂
  • 标准化语义:遵循 OpenTelemetry Semantic Conventions 命名 Span Tag,保证可查询性
  • 分级采样:核心链路高采样(甚至全量),非核心低采样;用 Remote/Adaptive Sampling 动态调整
  • 关联 Metrics 和 Logs:将 TraceID 注入日志(如 log.WithField("trace_id", span.SpanContext().TraceID())),实现日志-链路双向跳转
  • 安全意识:Span Tag 和 Baggage 中不要放入敏感信息(密码、Token、个人隐私数据),这些数据会持久化到存储后端

OpenTelemetry 官方文档:https://opentelemetry.io/docs/

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. 官方文档 — Jaeger 项目,参考了docs相关内容
  2. W3C Recommendation — W3,参考了W3C Recommendation相关内容
  3. Jaeger Sampling 文档 — Jaeger 项目,参考了Jaeger Sampling 文档相关内容
  4. opentelemetry.io — OpenTelemetry 社区,参考了docs相关内容