一、Trace 与 Span 模型
1.1 Span 是什么
Span 是链路的基本单元,表示一段有起止时间的操作。一次 HTTP 请求的链路:
Go
Trace (trace_id: 0af765...)
└─ span: GET /checkout [SERVER] 1200ms (gateway)
├─ span: AuthService.verify [CLIENT] 50ms
│ └─ span: verify [SERVER] 45ms (auth 服务内部)
├─ span: OrderService.create [CLIENT] 900ms
│ ├─ span: redis.get [CLIENT] 5ms
│ └─ span: mysql INSERT [CLIENT] 800ms ← 慢在这里
└─ span: notify [PRODUCER] 10ms (发 Kafka 消息)
同一个 trace 内所有 Span 共享 trace_id ;父子关系靠 parent_span_id 串起来(存储上是时间线上的一组 Span,树形由 parent 关系推导)。
1.2 Span 的组成
| 部分 | 说明 | 示例 |
|---|---|---|
| trace_id | 16 字节,整条链路唯一 | 0af7651916cd43dd8448eb211c80319c |
| span_id | 8 字节,单个 Span 唯一 | b7ad6b7169203331 |
| parent_span_id | 父 Span;根 Span 此字段为空 | |
| name | 操作名,语义约定建议低基数(不要拼参数) | GET /users/{id} 而非 GET /users/42 |
| kind | SpanKind,见下表 | |
| start/end | 起止时间戳(纳秒) | |
| attributes | 键值属性,描述本次操作 | http.request.method=GET |
| events | 时间点事件(无时长) | exception 事件记录报错堆栈 |
| links | 指向其他 Span 的链接(批处理、异步扇出场景) | |
| status | Unset / Ok / Error |
1.3 SpanKind 五种取值
| Kind | 含义 | 典型场景 |
|---|---|---|
| INTERNAL | 进程内部操作,不跨边界 | 一个内部函数调用 |
| SERVER | 服务端处理入站请求 | HTTP handler、gRPC server |
| CLIENT | 客户端发起出站请求 | 调下游 HTTP/gRPC |
| PRODUCER | 异步生产消息 | 发 Kafka/AMQP 消息 |
| CONSUMER | 异步消费消息 | 消费者收到消息处理 |
CLIENT/SERVER 成对出现时,两个 Span 共享 trace_id------CLIENT 端在请求头里注入
traceparent,SERVER 端提取,链路就接上了(见下节)。
1.4 属性、事件、状态的使用姿势
Go
// 属性:描述"这次操作"的可检索维度(低基数!)
span.SetAttributes(attribute.String("http.route", "/users/{id}"))
// 事件:时间点记录,常用于异常
span.AddEvent("cache.miss", trace.WithAttributes(attribute.String("key", k))
// 记录 error 是标准做法(会生成 exception 事件)
recordError(span, err)
// 状态:标记整个 Span 失败
span.SetStatus(codes.Error, "db query failed")
基数(cardinality)警告 :属性的取值组合会成为后端的索引维度。user.id、完整 URL 这种高基数值放 attributes 会拖垮后端------放日志或 events,attributes 只放 http.route 这类有限集合。
二、Context 传播
2.1 进程内:Go 的 context.Context
OTel 没有发明新的上下文,直接复用 context.Context:
Go
ctx, span := tracer.Start(ctx, "child-op") // 从 ctx 取出当前 Span 作为 parent
defer span.End()
doWork(ctx) // 必须把 ctx 传下去,链路才不断
新手最常见的断链原因:函数里没接住 ctx 又起了新 ctx ,导致下游拿不到 parent。规则:埋点函数永远把 ctx 作为第一个参数传递。
2.2 跨服务:Propagator 与 W3C TraceContext
出站请求时把 trace 状态注入 HTTP 头,入站时提取:
Go
// 客户端:注入
propagator := propagation.TraceContext{} // otel.GetTextMapPropagator() 全局默认也是它
propagator.Inject(ctx, propagation.HeaderCarrier(req.Header))
// → 请求头里出现: traceparent: 00-0af765...-b7ad6b...-01
// 服务端:提取
ctx = propagator.Extract(ctx, propagation.HeaderCarrier(r.Header))
ctx, serverSpan := tracer.Start(ctx, "GET /api", trace.WithSpanKind(trace.SpanKindServer))
traceparent 头格式(W3C TraceContext 标准):
Go
00 - 0af7651916cd43dd8448eb211c80319c - b7ad6b7169203331 - 01
版本 trace_id (32位hex) parent_span_id (16位hex) 采样标志位
最后两位 01 表示 sampled=true。跨服务采样一致性就靠它 :下游看到 01 才记录,保证一条链路要么完整、要么整体不采(这是头采的天然特性)。
2.3 Baggage
Baggage 在 W3C 标准里与 traceparent 并行(baggage 头),用于跨服务传播业务键值 ,例如把 user.id=42 从网关带到所有下游,日志里统一打出来。注意:Baggage 会随每个请求到处飞,不要塞敏感/大字段。
三、Resource 与语义约定
Resource 描述"数据从哪个实体产生",同一条链路所有 Span 共享:
Go
res := resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("order-service"), // 最重要,必填级属性
semconv.ServiceVersion("v1.2.3"),
semconv.DeploymentEnvironment("prod"),
)
-
resource.Default()会自动带上 SDK 名/版本、进程 PID、host 名,并读取环境变量:-
OTEL_SERVICE_NAME=my-svc→ service.name -
OTEL_RESOURCE_ATTRIBUTES=k8s.cluster=prod,team=trade→ 任意自定义属性
-
-
生产环境通常再叠加
resourcedetection(Collector 端)自动补齐 K8s/云厂商元数据。
语义约定(Semantic Conventions) 是属性命名的"词典",例如 HTTP 服务端 Span 必有的:http.request.method、url.path、http.response.status_code、url.scheme、network.protocol.version、server.address/port。Go 里在 go.opentelemetry.io/otel/semconv/v1.x.x 包(版本随约定演进,用仓库当前最新)。遵守它的意义:换语言、换后端,属性名一致,面板和查询可以复用。
四、采样
全量上报成本高(内存、网络、存储),采样决定"哪些 trace 留下"。
4.1 头部采样(Head-Based)------ SDK 端,请求开始时决定
在链路第一个 Span 创建前就决策,整条链路一致采或不采:
| 采样器 | 行为 |
|---|---|
| AlwaysOn | 全采(开发环境) |
| AlwaysOff | 全不采 |
| TraceIDRatioBased(0.1) | 按 trace_id 哈希确定性采样 10%(同一 trace 结果稳定) |
| ParentBased(子采样器) | 默认值。父已采样→跟着采;父未采样→不采;根 trace→用子采样器 |
Go
// 生产常见配置:根请求采 10%,但跟随上游决策
sdktrace.WithSampler(
sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1)),
)
ParentBased 是保证"链路完整"的关键:只要入口采了,下游全采;入口没采,下游一个都不采,不会出现半截链路。
4.2 尾部采样(Tail-Based)------ Collector 端,请求结束时决定
链路走完再判断:错误链路 100% 保留、慢请求(>1s)保留、其余采 1%。这是 Collector tail_sampling processor 的能力(见 05 章)。代价是必须在 Collector 缓存完整链路,资源开销大,一般部署在 Gateway 层。
4.3 怎么选
-
开发/测试:AlwaysOn
-
生产常规:SDK 端
ParentBased(TraceIDRatioBased(p)),简单稳定 -
错误链路必须全留 + 想按响应时间采样:SDK 全采 + Collector 尾部采样(成本转嫁给 Collector,需评估容量)
五、Metrics 深入
5.1 指标工具(Instrument)类型
| 类型 | 语义 | Go API |
|---|---|---|
| Counter | 只增不减的计数 | meter.Int64Counter("http.requests") |
| Gauge | 瞬时值,可升可降 | meter.Float64Gauge / Int64Gauge |
| Histogram | 值分布(桶统计),算 P99 用它 | meter.Float64Histogram("http.server.duration") |
| Exponential Histogram | 指数桶直方图,桶数少精度高 | meter.Float64ExponentialHistogram |
| Summary(仅兼容) | 分位数,OTel 不推荐新增 | --- |
| Observable* | 回调式:注册函数定期返回当前值 | meter.Int64ObservableGauge |
Go
meter := otel.Meter("myapp")
duration, _ := meter.Float64Histogram(
"http.server.duration",
metric.WithUnit("s"),
metric.WithDescription("服务端处理耗时分布"),
)
duration.Record(ctx, 0.042, metric.WithAttributes(
attribute.String("http.route", "/checkout"),
attribute.Int("http.response.status_code", 200),
))
5.2 Temporality(时间性)
-
Cumulative(累积):从进程启动累计到现在的值;Prometheus 生态默认,重启即清零;
-
Delta(增量):每个时间区间的增量;OTLP 默认推荐,适合流式聚合。
用 Prometheus 抓取时 SDK 的 prometheus exporter 自动用 cumulative;用 OTLP 推送到支持 delta 的后端时可配置。初学阶段知道有这个概念即可。
5.3 指标 vs 手动打点
指标是预聚合 的:SDK 在进程内就把上万次请求聚合成几十个时间序列再发出去,成本远低于每请求一条 Span。所以:不是所有信号都要上 trace ------高频健康度用 metrics,排查问题用 trace,两者属性都用 http.route 等相同维度命名,才能在面板间互相下钻。
六、Logs 与 trace 关联
OTel 日志的核心价值:每条日志自动带上 trace_id / span_id,从链路一键跳到日志。
两种接入方式:
方式一:桥接现有日志库(推荐起步)
把 zap/logrus/slog 桥接到 OTel(自动注入 trace 信息、经 OTLP 发送):
Go
// contrib 仓库的 slog 桥
import "go.opentelemetry.io/contrib/bridges/otelslog"
logger := slog.New(otelslog.NewHandler("my-app"))
方式二:日志里手动打 trace_id(最小侵入)
Go
type traceHandler struct{ slog.Handler }
func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
r.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
return h.Handler.Handle(ctx, r)
}
关键前提 :打日志时必须传 ctx(logger.InfoContext(ctx, ...)),否则拿不到当前 Span。这也是生产上"日志和链路对不上"的头号原因。
七、Provider / Processor / Exporter 关系图
Go
TracerProvider(全局一个,持有 Resource、Sampler、SpanProcessor 列表)
└─ Tracer(按 instrumentation scope 名区分)
└─ Span
│ End() 时触发
▼
SpanProcessor 链(可多个)
├─ BatchSpanProcessor(生产标配:攒批、定时、限队列)
└─ SimpleSpanProcessor(同步立即导出,调试用)
│
▼
Exporter(OTLP gRPC/HTTP、stdout、控制台...)
│
▼
Collector 或后端
Metrics 同构:MeterProvider → Meter → Instrument → Reader(批量导出或 Prometheus 抓取)。
生命周期铁律 :进程退出前必须调用 tp.Shutdown(ctx),否则批量队列里没发完的 Span 会丢------这是新手丢数据的常见原因。