553 lines
20 KiB
Markdown
553 lines
20 KiB
Markdown
|
|
---
|
|||
|
|
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-配置管理]] — 配置中心的动态刷新可以联动调整采样率
|