Files
cs-note/hhs/MS/02-服务治理/03-分布式追踪.md
T
2026-05-24 11:42:38 +08:00

20 KiB
Raw Blame History

tags, create time
tags create time
microservice
distributed-tracing
opentelemetry
jaeger
skywalking
2026-05-05 14:30

分布式链路追踪

概述

单个服务的日志只能告诉你局部信息。分布式链路追踪将一次请求跨越多个服务的完整调用链串起来,形成全局视图。

[!question] 定位问题的困难 用户反映下单慢,你的系统由订单、支付、库存、会员 4 个服务串联而成。没有工具的情况下,你要怎么知道是哪个服务拖慢了整体响应时间?

核心概念

flowchart TB
    Req["请求 trace_id=abc123"] --> Span1["Span #1 API Gateway 5ms"]
    Span1 --> Span2["Span #2 Order Service 70ms"]
    Span1 --> Span3["Span #3 User Service 15ms"]
    Span2 --> Span4["Span #4 Inventory DB Query 60ms"]

    style Span2 fill:#ff9999
    style Span4 fill:#ffcc99
概念 说明
Trace 一次完整请求的调用链,由一个唯一的 trace_id 标识
Span 链路中的一个执行片段(如一次 HTTP 调用、一次 SQL 查询),有独立的 span_id
Parent-Child Span 之间通过 parent_span_id 建立父子关系,形成树状结构
Context Propagation 通过 Header 传递 trace_id/span_id,贯穿整条链路
Sampling 不是每条请求都采样,按比例或策略选择,降低存储开销

Trace/Span 数据结构

{
  "traceId": "abc123def456...",
  "spans": [
    {
      "spanId": "span-001",
      "parentId": null,
      "operationName": "POST /orders",
      "startTime": "2026-05-05T10:00:00.000Z",
      "durationMs": 120,
      "tags": {
        "http.method": "POST",
        "http.url": "/api/orders",
        "http.status_code": 200
      },
      "logs": [
        {
          "timestamp": "2026-05-05T10:00:00.050Z",
          "fields": [{"key": "event", "value": "order.created"}]
        }
      ]
    },
    {
      "spanId": "span-002",
      "parentId": "span-001",
      "operationName": "GetUserById (gRPC)",
      "startTime": "2026-05-05T10:00:00.010Z",
      "durationMs": 15,
      "tags": {
        "rpc.system": "grpc",
        "rpc.service": "UserService",
        "rpc.method": "GetUser"
      }
    }
  ]
}

上下文传播 (Context Propagation)

trace_id 如何从上游服务传递到下游?核心原则:调用者创建上下文 → 被调者提取上下文 → 在自身链路中继续使用并透传到更下游。

W3C Trace Context 标准

这是业界事实标准(W3C Recommendation),被 OpenTelemetry、Jaeger、SkyWalking 全部支持。

// HTTP Header 中实际传递的字段
{
  "traceparent": "00-abc123def456...-789ghi012jkl-01",
  "tracestate":   "congo=t61rcWkgMzE,vendor=value"
}
字段 格式 说明
version 2 字符十六进制 版本号,目前固定 00
trace_id 32 字符十六进制 128-bit 全局唯一标识,建议用 ULID / Snowflake 生成
span_id 16 字符十六进制 64-bit 本段 span 的唯一标识
flags 2 字符十六进制 目前仅 01 表示已采样

tracestate:供厂商扩展使用,例如用于在多家 tracing 系统间同步采样决策或路由信息。多个厂商以逗号分隔。

HTTP 场景:中间件自动注入与提取

// OpenTelemetry SDK 自动处理 context 注入和提取
func Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // ① 从入站请求提取 trace context(如果请求中没有 traceparent,会生成新的 trace)
        ctx := propagator.Extract(r.Context(), headerReader{r.Header})

        // ② 在当前服务内创建新 span
        ctx, span := tracer.Start(ctx, "handleOrder")
        defer span.End()

        // ③ 将 trace context 注入到出站请求 —— 关键一步
        r = r.WithContext(ctx)
        propagator.Inject(ctx, headerWriter{r.Header})

        next.ServeHTTP(w, r)
    })
}
sequenceDiagram
    participant C as Client
    participant A as Service A
    participant B as Service B

    C->>A: GET /api/order\ntraceparent=00-abc...
    Note over A: Extract create Span A
    A->>B: POST /inventory\nInject traceparent
    Note over B: Extract create Span B
    B-->>A: Response
    A-->>C: Response

gRPC 场景:Metadata 透传

gRPC 基于 HTTP/2,传播方式类似,但使用的是 gRPC Metadata 而非 HTTP Headers(gRPC 库内部会将 metadata 转为 HTTP Header)。

// === 调用方:自动从 context 提取 trace 信息写入 metadata ===
ctx, span := tracer.Start(parentCtx, "callUserService")
defer span.End()

// otelgrpc.Interceptor 会自动完成 Inject,无需手动处理
conn, err := grpc.DialContext(ctx, "user-service:9090",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithStatsHandler(otelgrpc.NewClientHandler()), // 自动注入
)
client := userpb.NewUserServiceClient(conn)
resp, err := client.GetUser(ctx, &userpb.GetRequest{Id: "123"})

// === 被调方:拦截器自动完成 Extract ===
server := pb.NewUserServiceServerImpl()
grpc.NewServer(
    grpc.StatsHandler(otelgrpc.NewServerHandler()), // 自动提取
).Serve(listener)

[!note] 为什么不直接用 HTTP Headers? gRPC 对 metadata key 有严格的大小写规范——全部小写且不支持连字符。因此 traceparent 这样的 Header 名无法直接映射到 gRPC Metadata,各 tracing SDK 内部做了适配层,开发者只需关注 context,不需要手动操作 metadata。

MQ 场景:消息属性透传

消息队列没有标准化的上下文传播协议,需要在消息属性中手动携带 trace 信息。OpenTelemetry 提供了标准的 attribute 命名约定。

// === RocketMQ 生产者:注入 trace context ===
msg := rocketmq.NewMessage("order-created", payload)

// 标准 OTel 属性名
msg.WithProperty(semconv.RPCSystemKey, "rocketmq")
msg.WithProperty(semconv.MessagingSystemKey, "rocketmq")
msg.WithProperty(semconv.HTTPFlavorKey, "1.1")

// 手动注入 trace_id 和 span_id
if sc, ok := trace.SpanFromContext(ctx).SpanContext(); ok && sc.IsValid() {
    msg.WithProperty("traceparent", fmt.Sprintf(
        "00-%s-%s-01", sc.TraceID().String(), sc.SpanID().String(),
    ))
}

producer.SendSync(msg)

// === RocketMQ 消费者:恢复 trace context ===
consumer.Receive(func(ctx context.Context, messages ...*rocketmq.Message) error {
    for _, msg := range messages {
        // 从消息属性中提取 traceparent
        tp := msg.GetProperty("traceparent")
        if tp != "" {
            ctx = propagator.Extract(ctx, textmap.Reader(func(k string) string {
                return tp // 模拟 header reader
            }))
        } else {
            // 补偿策略:如果上游没带 trace 信息,作为新 trace 起点
            ctx = trace.ContextWithRemoteSpanContext(ctx,
                trace.NewSpanContext(trace.SpanContextConfig{
                    TraceID: generateFallbackTraceID(),
                    Remote:  true,
                }),
            )
        }

        _, span := tracer.Start(ctx, "processOrderCreated",
            trace.WithSpanKind(trace.SpanKindConsumer))
        span.SetAttributes(
            semconv.MessagingSystemKey, "rocketmq",
            semconv.MessagingDestinationKey, msg.Topic,
            semconv.MessagingMessageIDKey, msg.MsgID,
        )

        doProcess(ctx, msg.Body)
        span.End()
    }
    return nil
}, rocketmq.ConsumeFunc())

[!tip] 补偿策略:异步解耦时可能出现"上游未传 trace"的情况

当 MQ 消息来自非追踪系统或上游漏传了 trace_id 时,消费者应作为 新 Trace 的根 Span 启动,而不是丢弃消息。同时通过属性标记 messaging.message.is_remote=true 标明这是一个远程关联点。

同步并发:Goroutine 间的 Context 传播

Go 的 context.Context 是协程安全的,子 Goroutine 直接使用父级 context 即可自动继承 trace。关键在于不能丢失 context 引用。

func handleOrder(ctx context.Context, w http.ResponseWriter, r *http.Request) {
    _, span := tracer.Start(ctx, "handleOrder")
    defer span.End()

    // ✅ 正确:直接传入同一 context,子 goroutine 自动继承 trace
    var wg sync.WaitGroup
    for _, item := range order.Items {
        wg.Add(1)
        go func(item OrderItem) {
            defer wg.Done()
            processItem(ctx, item) // ctx 携带 trace context
        }(item)
    }
    wg.Wait()

    // ❌ 错误:创建了无父 context 的新 context,trace 断裂
    // go func() {
    //     _, childSpan := tracer.Start(context.Background(), "processItem")
    //     ...
    // }()
}
flowchart TB
    subgraph Main["主 Goroutine"]
        S0["Span #1 handleOrder"]
        S0 --> S1["Span #2 proc Item A"]
        S0 --> S2["Span #3 proc Item B"]
        S0 --> S3["Span #4 proc Item C"]
    end

    style S0 fill:#bbddff
    style S1 fill:#ccf2ff
    style S2 fill:#ccf2ff
    style S3 fill:#ccf2ff

[!warning] 常见陷阱

  1. 忘记传入 context:用 context.Background() 或 context.TODO() 启动了 Goroutine,导致 trace 断裂。
  2. 超时覆盖:子 Goroutine 中设置了独立的 context.WithTimeout 但没有保留父级的 deadline,导致整体超时无效。
  3. recover 后丢失 context:defer recover() 中如果有错误上报逻辑,仍需持有原始 context。

额外负载:Baggage 机制

除了 trace/span 信息,有时需要在链路间传递业务标签(如用户 ID、租户 ID、A/B 测试分组),这通过 Baggage 实现。

// === 入口服务:设置 baggage ===
baggage, _ := baggage.New(baggage.Pair("user.id", "u-12345"),
                           baggage.Pair("tenant", "acme-corp"))
ctx = propagator.Inject(ctx, baggageInjector{baggage})

// === 中间任意服务:读取 baggage ===
baggage = baggage.FromContext(ctx)
userID, _ := baggage.Value("user.id")
// userID == "u-12345"

// === 下游服务:也能读到同样的 baggage ===
特性 Baggage Span Attributes
传播范围 沿整条链路传递给所有下游 仅记录在本地 Span 中,不传播
大小限制 总计 512 字节(防止 Header 膨胀) 无限制
典型用途 用户 ID、租户、环境标记 状态码、耗时、SQL 语句等遥测数据
是否计入 Span Duration 否 否

[!caution] Baggage 安全须知

Baggage 会被编码为 HTTP Header 随每个请求传播,绝不能包含敏感信息(密码、Token、PII)。默认 512 字节上限也意味着不适合传递大量数据。如需传递大对象,请改为通过 span.RecordError() 或独立日志存储。

边界的判断:何时创建新 Trace

并非所有场景都需要延续上游 trace。以下情况应考虑开启新 trace:

quadrantChart
    title 跨系统调用:是否延续 Trace?
    x-axis Low Coupling --> High Coupling
    y-axis New Trace --> Continue Trace
    "健康检查 / Scrape": [0.2, 0.15]
    "定时任务 / Cron": [0.15, 0.1]
    "MQ 消息异步解耦": [0.4, 0.7]
    "正常 HTTP/gRPC 请求": [0.85, 0.9]
    "第三方回调": [0.5, 0.25]
    "消息积压重新消费": [0.3, 0.3]
场景 行为 说明
正常 HTTP / gRPC / MQ 请求 延续上游 trace_id 标准流程,Extract → Continue
健康检查 / Prometheus scrape 新建 trace 可标记 tracestate 为 healthcheck
定时任务 / Cron job 新建 trace 无上游 context,天然无父 span
消息积压重新消费(跨越数天) 新 trace + baggage 引用原 trace_id 保留追溯关联
第三方回调(外部系统主动推送) 新建 trace,通过 callback_id 间接关联 无法 Extract,只能新建
伪造 traceparent 验证格式合法性,非法则忽略并新建 记录告警,防止注入攻击

决策流程:

flowchart LR
    Incoming["Incoming Request"] --> HasTrace{"是否有有效 traceparent"}
    HasTrace -->|"是"| Extract["Extract 并继续"]
    HasTrace -->|"否"| NewTrace["创建新 trace"]

    Extract --> Valid{"格式合法"}
    Valid -->|"是"| Continue["延续 trace"]
    Valid -->|"否"| LogWarn["记录告警丢弃伪造 context"]
    LogWarn --> NewTrace

    NewTrace --> MarkBaggage["可选 baggage 携带原 trace_id 作引用"]
    MarkBaggage --> StartRoot["启动根 Span"]

主流方案对比

方案全景图

quadrantChart
    title 三大追踪方案特性对比
    x-axis Low Invasiveness --> High Flexibility
    y-axis All-in-one APM --> Modular Components
    "SkyWalking": [0.25, 0.75]
    "OpenTelemetry": [0.75, 0.85]
    "Jaeger": [0.6, 0.35]
方案 协议 存储后端 侵入程度 特色能力
OpenTelemetry OTLP Prometheus/Jaeger/Zipkin SDK + Auto-Instrumentation CNCF 标准,厂商中立,未来趋势
Jaeger Jaeger native Cassandra/Elasticsearch Agent / SDK Uber 开源,UI 友好,支持业务 Tags
SkyWalking SkyWalking MySQL/Elasticsearch/ES 零侵入 Java Agent 国产,中文文档完善,APM 一体

演进路线:从单体到 OTel

flowchart LR
    subgraph Phase1["Phase 1 无 Tracing"]
        P1["各服务独立日志\n排查靠翻日志和SSH"]
    end

    subgraph Phase2["Phase 2 Jaeger/SkyWalking 自研"]
        P2["特定语言适配\n链路覆盖不全"]
    end

    subgraph Phase3["Phase 3 OpenTelemetry 统一"]
        P3["SDK 统一\nAuto-Instrumentation\nCollector 收集"]
    end

    P1 ==> P2 ==> P3

    note["推荐目标 OTel Collector 灵活后端\n不锁死任何组件按需替换"]
    note -.-> P3

[!tip] 选型建议 新项目优先选 OpenTelemetry——它是行业标准,未来会被所有工具支持。如果团队需要开箱即用的 APM,SkyWalking 的 Java Agent 零侵入方案是快速上手的最佳选择。

OpenTelemetry Go 实战

// 初始化 TracerProvider
provider := sdktrace.NewTracerProvider(
    sdktrace.WithBatcherExporter(exporter), // 异步批量上报,不阻塞
)
defer provider.Shutdown(context.Background())

tracer := provider.Tracer("order-service")

func handleOrder(w http.ResponseWriter, r *http.Request) {
    ctx, span := tracer.Start(r.Context(), "handleOrder")
    defer span.End()

    // 设置丰富的属性,方便查询和过滤
    span.SetAttributes(
        attribute.String("http.method", r.Method),
        attribute.Int("http.status_code", http.StatusOK),
        attribute.String("user.id", getUserID(r)),
    )

    // 调用下游服务(trace context 自动传播)
    resp, err := callInventoryService(ctx)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        w.WriteHeader(http.StatusBadGateway)
        return
    }
    w.Write(resp.Body)
}

采样策略

[!question] 为什么需要采样? 假设日活 100 万用户,每个用户产生 10 次请求 = 每天 1 千万条 Trace。如果全部存储,按每条 Span 平均 500 bytes 算,单张表一天就是 数 GB。采样不是偷懒,是用可控的成本换取可观测性。

采样算法

OpenTelemetry 提供了两种内置采样器:

// 静态采样:简单粗暴,适合开发环境
alwaysOn := sdktrace.AlwaysSample()       // 100% 采集
alwaysOff := sdktrace.NeverSample()       // 0% 采集(但仍在 SDK 内创建 span)

// 动态采样:根据链路状态智能决策 ⭐ 推荐生产使用
dynamic := sampler.TraceIDRatioBased(0.1) // 10% 随机采样

实际项目中通常组合使用 Parent-Based 分层采样,确保父子链路一致性:

provider := sdktrace.NewTracerProvider(
    // Parent-based: 父级已采样则子级必采样,反之亦然
    sdktrace.WithSampler(
        sdktrace.ParentBased(
            sdktrace.TraceIDRatioBased(0.1), // 无父 trace 时默认 10%
        ),
    ),
    sdktrace.WithBatcherExporter(exporter),
)

错误优先采样

type errorPrioritySampler struct {
    baseRate float64 // 基础采样率
}

func (s *errorPrioritySampler) ShouldSample(params sampling.Parameters) sampling.Result {
    ctx := params.SpanContext.Context
    // 检查当前 span 是否已有 error status
    if sc, ok := trace.SpanFromContext(ctx).SpanContext(); ok {
        attrs := sc.Attributes()
        for _, attr := range attrs {
            if attr.Key == "error" && attr.Value.AsBool() {
                return sampling.Result{
                    Decision:  sampling.RecordAndSample, // 100% 采样错误请求
                    Attributes: []attribute.KeyValue{},
                }
            }
        }
    }
    // 非错误请求走基础采样率
    rate := rand.Float64()
    decision := sampling.Drop
    if rate < s.baseRate {
        decision = sampling.RecordAndSample
    }
    return sampling.Result{Decision: decision}
}
flowchart LR
    AllReq["全部请求"] --> Sampler{"采样决策"}
    Sampler -->|"有error"| CheckError{"错误标记"}
    CheckError -->|"是"| Core["核心链路全量采集"]
    CheckError -->|"否"| Prob["概率采样"]
    Sampler -->|"无parent"| Prob
    Prob -->|"命中"| Core
    Prob -->|"未命中"| Drop["丢弃"]

    Core --> Storage["Trace 存储"]
    Drop --> Log["非采样请求仅记录计数"]
环境 采样率 理由
开发 / 测试 100% 方便调试,无存储压力
生产 - 核心链路 100% 下单、支付等关键路径必须全量
生产 - 普通路径 5~10% 平衡成本和覆盖率
生产 - 错误链路 100% 出错时的请求优先保留

[!note] 基于错误的智能采样

高级做法:正常路径低采样,一旦检测到错误立即提升当前请求的采样率。这样既省了存储,又能在出问题时有足够的数据回溯。

采样对应用的影响

flowchart LR
    subgraph Sampled["被采样的请求比例十百分比"]
        S1["完整 Span 数据"]
        S2["完整日志关联"]
        S3["存储到后端"]
    end

    subgraph Dropped["未被采样的请求比例九十百分比"]
        D1["SDK 内仍创建 Span"]
        D2["不影响业务逻辑"]
        D3["上报时直接跳过 Export"]
    end

    Note["采样不等于不执行\n采样只影响上报不影响业务"]
    Note --> Sampled
    Note --> Dropped

[!warning] 常见误区

  1. "采样后 span 就不创建了" → 错!SDK 内部仍然创建和记录 span,只是在 Exporter 阶段跳过网络上报。
  2. "采样会导致链路断裂" → 配合 ParentBased 可以保证:只要链路上有一条被采样,整条链路的 span 都会被保留。
  3. "统计 P99 会有偏差" → 正确。低采样率下 P99 估计值会偏低,需用统计学方法做补偿估计。

渐进式落地路线

[!tip] 不要试图一开始就采集全部 Span

  1. 第一步:先接 Tracing,覆盖核心链路(下单、支付),rate=100%
  2. 第二步:加入 Metrics 监控(Prometheus + Grafana)
  3. 第三步:集中 Logging(Loki / ELK),与 trace_id 关联
  4. 第四步:全量上 OpenTelemetry Collector,统一管理

关联笔记