This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/MS/02-服务治理/03-分布式追踪.md
T
2026-05-17 22:00:24 +08:00

553 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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-配置管理]] — 配置中心的动态刷新可以联动调整采样率