--- tags: [microservice, distributed-tracing, opentelemetry, jaeger, skywalking] create time: 2026-05-05 14:30 --- # 分布式链路追踪 ## 概述 单个服务的日志只能告诉你局部信息。分布式链路追踪将一次请求跨越多个服务的完整调用链串起来,形成全局视图。 > [!question] 定位问题的困难 > 用户反映下单慢,你的系统由订单、支付、库存、会员 4 个服务串联而成。没有工具的情况下,你要怎么知道是哪个服务拖慢了整体响应时间? ## 核心概念 ```mermaid 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 数据结构 ```json { "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](https://www.w3.org/TR/trace-context/)),被 OpenTelemetry、Jaeger、SkyWalking 全部支持。 ```json // 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 场景:中间件自动注入与提取 ```go // 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) }) } ``` ```mermaid 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)。 ```go // === 调用方:自动从 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 命名约定。 ```go // === 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 引用**。 ```go 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") // ... // }() } ``` ```mermaid 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** 实现。 ```go // === 入口服务:设置 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: ```mermaid 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 | 验证格式合法性,非法则忽略并新建 | 记录告警,防止注入攻击 | **决策流程:** ```mermaid 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"] ``` ## 主流方案对比 ### 方案全景图 ```mermaid 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 ```mermaid 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 实战 ```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 提供了两种内置采样器: ```go // 静态采样:简单粗暴,适合开发环境 alwaysOn := sdktrace.AlwaysSample() // 100% 采集 alwaysOff := sdktrace.NeverSample() // 0% 采集(但仍在 SDK 内创建 span) // 动态采样:根据链路状态智能决策 ⭐ 推荐生产使用 dynamic := sampler.TraceIDRatioBased(0.1) // 10% 随机采样 ``` 实际项目中通常组合使用 **Parent-Based 分层采样**,确保父子链路一致性: ```go provider := sdktrace.NewTracerProvider( // Parent-based: 父级已采样则子级必采样,反之亦然 sdktrace.WithSampler( sdktrace.ParentBased( sdktrace.TraceIDRatioBased(0.1), // 无父 trace 时默认 10% ), ), sdktrace.WithBatcherExporter(exporter), ) ``` ### 错误优先采样 ```go 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} } ``` ```mermaid 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] 基于错误的智能采样 > > 高级做法:**正常路径低采样,一旦检测到错误立即提升当前请求的采样率**。这样既省了存储,又能在出问题时有足够的数据回溯。 ### 采样对应用的影响 ```mermaid 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,统一管理 ## 关联笔记 - [[02-服务治理/01-API网关]] — API Gateway 可以在入口处注入 trace_id - [[04-可观测性/03-链路追踪]] — 更详细的链路追踪设计方法论 - [[02-服务治理/07-配置管理]] — 配置中心的动态刷新可以联动调整采样率