《Go 语言编程实战》10.3 链路追踪(OTel)

给 TaskHub 装上 OpenTelemetry 链路追踪:用 Tracer 建父子 span、标注属性与错误状态,用 stdouttrace 在本地把 span 打出来,用 W3C traceparent 把 trace 跨服务串起来,再把 trace_id 写进 slog,最后讲清采样策略与优雅关闭,并说明本机没有 collector 故 OTLP 导出未实测。

本节把 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一次操作的记录,有起止时间调用链上的一节
SpanContexttrace_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纯本地计算默认值

两个纪律:

  1. defer span.End() 必须紧跟 Start,中间不能有提前 return 漏掉它。忘了 End 的 span 永远不会被导出,链路直接断。
  2. 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练