本节把 TaskHub 推进到「慢在哪一跳能看见」:日志有 ID 能串起来,但一次请求内部的调用顺序、每一段的耗时、哪一步报错,还得靠 span 组成的调用链。我们用 OpenTelemetry 给 TaskHub 的 handler 与数据库调用建父子 span,并在本地把整条链打出来。
适用版本:Go 1.27(实测go1.27.0)+go.opentelemetry.io/otel v1.47.0。
10.3 链路追踪(OTel)
日志和指标各有一块拼不上的空白:日志知道「发生了什么」,但不知道「这几件事之间的因果关系」;指标知道「整体慢」,但不知道「慢在哪个环节」。分布式追踪(distributed tracing)填的就是这块:它把一次请求拆成一棵 span 树,每个 span 记下开始、结束、属性和状态,父子关系还原出调用链。
10.3.1 五个核心概念
OpenTelemetry(OTel)的词汇表不多,但必须先分清:
| 概念 | 含义 | 类比 |
|---|---|---|
| TracerProvider | 追踪的总入口,持有采样与导出配置 | 全局单例 |
| Tracer | 从 Provider 拿到的、按组件命名的实例 | otel.Tracer("taskhub/api") |
| Span | 一次操作的记录,有起止时间 | 调用链上的一节 |
| SpanContext | trace_id + span_id,跨进程传播的载体 | 关联 ID 的升级版 |
| Exporter | 把 span 送出去的出口 | stdout / OTLP / Jaeger |
一次 trace 由 trace_id 标识,整条链上所有 span 共享同一个 trace_id,每个 span 有自己的 span_id 并指向父 span。这正是 10.1 关联 ID 的「豪华版」——trace_id 天生全局唯一且跨服务一致。
10.3.2 初始化:本地开发用 stdouttrace
生产环境用 OTLP 导出到 collector,但本地开发只要能看到 span 就够了。stdouttrace 把 span 以 JSON 打到标准输出,零依赖、零配置:
package main
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func initTracer() (*sdktrace.TracerProvider, error) {
exp, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(1.0))),
)
otel.SetTracerProvider(tp)
return tp, nil
}
三个点:
otel.SetTracerProvider:注册成全局,之后otel.Tracer(...)都能拿到它。WithBatcher:导出是批量、异步的,避免每个 span 同步写一次 IO。ParentBased+TraceIDRatioBased:采样策略。ParentBased保证「上游采样了我就采样」,TraceIDRatioBased(1.0)表示本进程 100% 采样(本地开发用;生产会调到 0.01 之类)。
10.3.3 建 span:三种 SpanKind
var tracer = otel.Tracer("taskhub/api")
func queryTasks(ctx context.Context, tenant string) (int, error) {
ctx, span := tracer.Start(ctx, "db.query tasks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
attribute.String("db.system.name", "postgresql"),
attribute.String("tenant.id", tenant),
))
defer span.End()
time.Sleep(3 * time.Millisecond)
if tenant == "boom" {
err := errors.New("connection reset by peer")
span.RecordError(err)
span.SetStatus(codes.Error, "db query failed")
return 0, err
}
span.SetAttributes(attribute.Int("db.rows", 3))
return 3, nil
}
SpanKind 不是装饰,它决定调用链怎么画:
| Kind | 用在 | 在链上的意义 |
|---|---|---|
Server | 收到请求的入口 | 一段链的起点 |
Client | 发出去的下游调用 | 与对面的 Server span 配成一对 |
Producer / Consumer | 消息收发 | 异步链路的断点 |
Internal | 纯本地计算 | 默认值 |
两个纪律:
defer span.End()必须紧跟Start,中间不能有提前 return 漏掉它。忘了 End 的 span 永远不会被导出,链路直接断。span.RecordError只记录错误事件,不改变 span 状态。要让链路显示为失败,必须额外调span.SetStatus(codes.Error, ...)。这是最常漏的一步——错误记了,但追踪界面里那条 span 还是绿的。
10.3.4 本机实测:一条完整链路
把 handler(Server span)套着数据库调用(Client span)跑一遍,stdouttrace 打出的真实输出(节选,略去 Resource 等固定字段):
$ go run ./cmd/tracedemo
trace_id=7b3f8ceb65ca9d65bcd264761bf19601
ok: tasks=3 err=<nil>
fail: err=connection reset by peer
{
"Name": "db.query tasks",
"SpanContext": {
"TraceID": "7b3f8ceb65ca9d65bcd264761bf19601",
"SpanID": "ed435d19c612e460",
"TraceFlags": "01"
},
"Parent": {
"TraceID": "7b3f8ceb65ca9d65bcd264761bf19601",
"SpanID": "45a42cbaae41f81a"
},
"SpanKind": 3,
"StartTime": "2026-10-10T10:23:59.484804+08:00",
"EndTime": "2026-10-10T10:23:59.488040667+08:00",
"Attributes": [
{"Key": "db.system.name", "Value": {"Type": "STRING", "Value": "postgresql"}},
{"Key": "tenant.id", "Value": {"Type": "STRING", "Value": "acme"}},
{"Key": "db.rows", "Value": {"Type": "INT64", "Value": 3}}
],
"Status": {"Code": "Unset", "Description": ""},
"InstrumentationScope": {"Name": "taskhub/api"}
}
对着这段读几件事:
- 子 span 的
TraceID与父 span 完全相同(7b3f8ceb...),Parent.SpanID指向父 span——树就是这么连起来的。 SpanKind: 3是Client的枚举值。StartTime到EndTime相差约 3.2ms,正好对应代码里的time.Sleep(3ms),说明 span 确实在计时。- 成功路径的
Status.Code是Unset(不是Ok),这是 OTel 的约定:只有出错才显式设Error。
失败路径那条 span 会带上 Status: {"Code": "Error", "Description": "db query failed"} 和一条 error event,fail: err=connection reset by peer 也印证了错误被正确抛出。
10.3.5 跨服务传播:W3C traceparent
单进程内的链路只是热身,追踪的价值在跨服务。OTel 用 W3C 的 traceparent header 传播上下文,格式是 版本-trace_id-span_id-标志:
import "go.opentelemetry.io/otel/propagation"
func main() {
otel.SetTextMapPropagator(propagation.TraceContext{})
ctx, span := tracer.Start(context.Background(), "upstream")
defer span.End()
// 出口:把当前上下文注入 header
carrier := propagation.MapCarrier{}
otel.GetTextMapPropagator().Inject(ctx, carrier)
// 入口:从 header 恢复上下文,子 span 自动挂到同一个 trace 上
req, _ := http.NewRequest("GET", "http://downstream/tasks", nil)
req.Header.Set("traceparent", carrier["traceparent"])
ctx2 := otel.GetTextMapPropagator().Extract(
context.Background(), propagation.HeaderCarrier(req.Header))
_, child := tracer.Start(ctx2, "downstream", trace.WithSpanKind(trace.SpanKindServer))
defer child.End()
}
本机实测(用 SDK provider,所以 ID 是真的):
上游 trace_id=aa3e6a2b7dfb0c25a226ac91475f1ab3 span_id=7e4f500f9498f103
注入 header: traceparent=00-aa3e6a2b7dfb0c25a226ac91475f1ab3-7e4f500f9498f103-01
下游 trace_id=aa3e6a2b7dfb0c25a226ac91475f1ab3(与上游相同: true)
traceparent 里的 trace_id 与上游 span 完全一致,下游 span 因此被挂进了同一条链。-01 是采样标志位。这套机制和 10.1 的 X-Correlation-ID 是同一思路,区别是 traceparent 有标准格式、能被所有 OTel SDK 自动识别,不用手写中间件。
在 HTTP 服务里,otelhttp 包能把这一切自动化:
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
handler := otelhttp.NewHandler(mux, "taskhub-api")
http.ListenAndServe(":8080", handler)
它自动为每个请求建 Server span、自动 Extract/Inject header、自动把状态码写进 span 属性。业务代码一行不用改。
10.3.6 把 trace_id 写进日志
追踪和日志必须能互相跳转,否则两套系统各自为政。做法是在日志里带上 trace_id 和 span_id——它们就在 context 里:
func traceAttrs(ctx context.Context) []slog.Attr {
sc := trace.SpanContextFromContext(ctx)
if !sc.IsValid() {
return nil
}
return []slog.Attr{
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
}
}
把这段接进 10.1 的 correlationHandler.Handle,每条日志就同时有了 correlation_id(业务维度)和 trace_id(链路维度)。运维在追踪界面看到一条慢 span,复制 trace_id 去日志系统一搜,所有相关日志一次到齐——指标报警 → 链路定位 → 日志归因,这才是可观测的闭环。
10.3.7 采样:全采会破产
生产环境每个请求都导出 span 是不可承受的。1000 QPS 的服务一天就是 8600 万个 span。采样策略:
| 策略 | 适用 | 代价 |
|---|---|---|
TraceIDRatioBased(0.01) | 高流量服务 | 只留 1%,小概率漏掉偶发问题 |
ParentBased(...) | 所有服务 | 保证同一 trace 全留或全不留 |
AlwaysSample | 本地开发、低流量 | 数据全但贵 |
| 尾部采样 | 需要「保留所有错误」 | 需要 collector 支持 |
ParentBased 几乎是必选:它保证采样决策由链路入口统一做,下游不会出现「父 span 被采了、子 span 没采」的断链。单用 TraceIDRatioBased 时,不同服务的采样阈值稍有差异就会把链路打碎。
10.3.8 导出与优雅关闭
生产用 OTLP 导出到 collector:
exp, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint("otel-collector:4318"),
otlptracehttp.WithInsecure())
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp))
Shutdown 是必须的。批处理导出器把 span 攒在内存里,进程直接退出会丢掉最后一批。服务停机流程里要显式调:
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = tp.Shutdown(ctx) // 冲刷缓冲,等导出完成
}()
这和第 15 章的优雅停机是同一个道理:任何带缓冲的组件,退出前都要 flush。
本机未实测:OTLP 导出到 collector 这一段未在本机跑通,原因是本机没有部署
otel-collector(也无外部观测后端可连),otlptracehttp的端到端导出路径未经实跑验证。本节实测的是stdouttrace导出与 W3C 传播,代码路径相同,差异只在 Exporter 实现。
10.3.9 属性设计:能用数字就别用字符串
span 属性(attribute)是查询与聚合的维度,设计原则和指标标签相通但更宽松——因为 span 是逐条的,不像指标那样要预聚合:
- 数字用
attribute.Int64/Float64:db.rows、http.status_code,这样才能在追踪界面里排序、聚合。 - 遵循语义约定:
db.system.name、http.request.method、http.response.status_code是 OTel 规定的标准键名(db.system是旧名,已在语义约定 1.30 起弃用),自造键名会让通用面板失效。 - 别塞大对象:属性会被完整序列化并上传,塞一个 JSON 响应体会让 span 膨胀上百倍。
小结
- OTel 五件套:Provider、Tracer、Span、SpanContext、Exporter。
- 本地开发用
stdouttrace,生产用 OTLP +WithBatcher,退出前必须Shutdown冲刷。 SpanKind决定链路画法;RecordError只记事件,还要SetStatus(codes.Error, ...)才显示失败。- 跨服务用 W3C
traceparent传播;HTTP 服务可直接用otelhttp.NewHandler自动化。 - 把
trace_id写进日志,打通「指标 → 链路 → 日志」的闭环。 - 采样用
ParentBased保证整条链一致;全采在生产会破产。
到这一章为止,TaskHub 的观测三件套——日志、指标、链路——已经齐了。下一章换一个话题:有了这些能力之后,怎么用测试把它们守住,让每次改动都敢发布。
阅读导航:上一节:10.2 指标与 SLO · 下一节:11.1 分层测试与 Testcontainers 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。