1. 从一次事故看链路断裂:现象、表象与真实原因
先说一个我没法忘记的线上问题。那是一个典型的微服务电商场景:前端下单调用订单服务,订单服务调用库存服务,库存服务扣减成功后,订单服务又要发一条消息到 Kafka,由消息消费者异步更新积分。整体链路不算长,但就在一次大促压测时,我打开 Jaeger 看订单服务的 trace,发现订单服务自身有完整的 span 树,请求从 /api/order/create 进入,调用库存服务时却只剩一个 HTTP client span,下游库存服务完全没有对应子 span;到了 Kafka 消费端,更是直接另起了一个 trace,TraceID 和订单侧完全对不上。
单纯看服务可用性,这段时间的接口成功率并没有明显下滑,因为库存服务的错误被订单服务捕获后做了兜底重试,最终接口还是返回了成功。但排查这次压测引发的库存超卖风险时,根本没有办法把"库存扣减耗时突然升高"这个指标和某笔具体订单关联起来。Trace 断成了三段,指标又是一个独立的孤岛——想定位"到底是哪条链路拖慢了什么服务",完全无从下手。这就是我说"跨服务链路断裂"不只是 trace 图不好看的根本原因:链路数据一旦断裂,指标数据就失去了解释上下文的能力,整个可观测性体系从"能定位"退化成了"只能报警"。
这类问题的表面原因各不相同,但根子几乎都是同一类:服务之间传递的上下文信息断了。在 OpenTelemetry 的视角下,TraceID、SpanID、Baggage 需要沿着每一次调用边界传递下去,无论是 HTTP Header、gRPC Metadata,还是 Kafka ProducerRecord Header,哪一个环节没有显式透传,trace 就在那里断成两段。很多团队追到这一步就停了,以为把 Header 补上就万事大吉,但实际上"链路断裂"只是表象,藏在后面的还有四个更隐蔽的层次:
- 第一个层次是 Context Propagation 丢失,也就是 TraceID 没有跨进程传下去。
- 第二个层次是 进程内 Context 中断,比如开启了 goroutine 异步处理,却没有把携带 span 的 context 一并传入,导致新 goroutine 里的 span 从根开始。
- 第三个层次是 采样决策不一致,父服务采样放行了,下游服务自行决策时丢弃了,表现出来也是"链路断裂"。
- 第四个层次最容易忽略,是 Trace 和 Metric 之间没有关联维度,即使链路完整,指标也仍然是孤立的,跨服务链路里"某个服务处理慢"和"某个 Service Level Objective 亮红灯"永远对不上号。
这四个层次不是并列关系,而是递进关系。解决第一层,trace 串起来了;解决第二层,进程内完整了;解决第三层,跨服务的完整 trace 能稳定进入存储系统;解决第四层,才算真正做到"Tracing 指标统一"。本文的实践路径也会按照这个顺序展开,先讲 ctx 传递,再讲 SDK 配置,然后是 Collector 汇聚,最后落到 exemplar 关联指标。整个思路围绕 Go 语言展开,但其中的原理对 Java、Python、Node.js 同样适用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 根因层面:Context Propagation 在 Go 里的丢失点
很多 Go 开发者在第一次接触 OpenTelemetry 时有个误区,以为只要在 main 函数里初始化了 TracerProvider,所有网络请求的 trace 就会自动串联。这个认知错得离谱。OpenTelemetry 的自动埋点能力取决于你有多少现成的 instrumentation library,而 Go 语言的标准库 net/http 并不会自动埋点,它需要你显式地引入 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp 包,并且修改请求的包装方式。
我见过大量 Go 项目的初始化代码长这样:
go复制func main() {
tp := sdktrace.NewTracerProvider()
otel.SetTracerProvider(tp)
// ... 启动 HTTP Server
}
然后写了个中间件函数,手动 span := tracer.Start(r.Context(), "handler"),到调用下游 HTTP 服务时,也手动创建一个 client,span := tracer.Start(ctx, "call_inventory"),然后直接 req, _ := http.NewRequestWithContext(ctx, ...),发出去了。表面上看每一步都用了 context,但真正的问题出在 http.NewRequestWithContext:这个函数确实会把 ctx 绑定到 request 上,但 它不会自动把 TraceID 写进 HTTP Header。跨进程要传递追踪上下文,依赖的是 propagator 将 ctx 中的 span 信息注入到 carrier(也就是 HTTP Header、gRPC Metadata、Kafka 消息 Header),如果你只把 ctx 传过去而没做注入,下游服务收到的 Header 里根本没有 traceparent 字段,它自然无法把新创建的 span 挂在同一个 TraceID 下。
正确做法是用 otelhttp 包装 Transport,或者自己注册 Propagator 之后手动注入。先看 Propagator 注册这一步,任何程序都要保证全局 Propagator 至少包含 TraceContext 和 Baggage 两种,否则默认的 no-op 会直接把 trace 信息丢掉:
go复制import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
)
func setupPropagators() {
otel.SetTextMapPropagator(
propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
),
)
}
这里有个顺序细节:TraceContext 放在前面,Baggage 放在后面。Composite 里前面的 propagator 先注入也先提取,如果将来你接入第三方追踪头(比如 B3 格式),也要把 TraceContext 排在最前,因为 W3C traceparent 才是 OpenTelemetry 的默认标准格式,第三方系统通过 B3 或 Jaeger Header 传过来的信息应该作为兼容层处理。
然后是用 otelhttp 包装 client。推荐的做法是给 http.Client 设置 Transport,而不是在每个调用点手工起 span:
go复制import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func NewInventoryClient() *http.Client {
return &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
}
这样每次请求发出时,otelhttp 会先检查 ctx 里有没有当前 span,如果有就自动生成一个 client span,并把 TraceID、SpanID 注入到 outbound 请求的 Header 里。你也可以用 otelhttp.WithSpanNameFormatter 调整 span 名称,用 otelhttp.WithTracerProvider 指定自定义的 TracerProvider,这些扩展点后面再说。
Server 侧也要对称地处理。在 HTTP 服务端接入 otelhttp 时,不能只包装 http.Handler,还要把入站请求 Header 里的 TraceID 提取到 ctx 中,这一步是自动做的,前提是你挂了 otelhttp.NewMiddleware:
go复制handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 此时 r.Context() 已经包含了从 Header 恢复出的 Span
// 后续所有使用该 ctx 创建的 span 都会带同一 TraceID
fmt.Fprint(w, "ok")
})
http.Handle("/api/order/create", otelhttp.NewHandler(handler, "order.create"))
如果服务端没有做提取,即使客户端正确注入了 traceparent,服务端也会无视它,重新开一个新的 trace。这属于最典型的"单侧接入,链路仍然断裂"的场景。排查时可以用 curl 直接打一个经过 OTel 中间件的服务,看响应头里是否回带 traceparent,就能快速判断是入站提取的问题还是出站注入的问题。
进程内 Context 中断是 Go 特有的高发坑。Go 社区习惯用 goroutine 处理并发,但很多人把 context.Background() 而不是父 context 传进 goroutine:
go复制func (s *OrderService) Create(ctx context.Context) error {
_, span := tracer.Start(ctx, "order.create")
defer span.End()
go s.updateScore(ctx) // 错误示范:ctx 能传但容易丢 span?
return nil
}
其实把 ctx 直接传进 goroutine 用反而是正确的;误区是很多人觉得 context 不能在 goroutine 间传递,于是用 context.Background() 替代,结果新 goroutine 里的 trace 全部是新的根。这里要澄清一下:context.Context 在 goroutine 中传递没有任何问题,你只要保证把父 ctx 原封不动传进去,OTel 的 span 就会跟随 ctx 一并继承。真正的隐性坑在于,如果你在一个已经结束的 span 里启动 goroutine,比如先 defer span.End() 再在 defer 里继续用父 ctx 创建子 span,父子关系会错乱,因为父 span 已经上报了。这类问题在输出 trace 时表现为:子 span 明明存在,但父 span 已经结束,Jaeger 里会看到时间错位甚至链路分支断裂。
说到 Kafka 的消费场景,问题会更隐蔽。生产者在发送消息时通过 otelhttp 类似的机制把 TraceID 注入到消息 Header 里,但 Kafka Producer 并不是 HTTP 客户端,它不会自动注入。你需要用手动注入的方式把 trace 信息放到 ProducerRecord 的 Headers 里:
go复制import (
"go.opentelemetry.io/otel/propagation"
)
headers := make(map[string]string)
otel.GetTextMapPropagator().Inject(ctx, propagation.MapCarrier(headers))
message := &kafka.Message{
Topic: "topic.order.created",
Headers: make([]kafka.Header, 0, len(headers)),
}
for key, value := range headers {
message.Headers = append(message.Headers, kafka.Header{
Key: key,
Value: []byte(value),
})
}
消费者侧在拉取消息后做 Extract,再把提取出的 context 和消费者的 consumer.NewContext 合并,作为后续所有 span 的父 context:
go复制carrier := propagation.MapCarrier{}
for _, header := range msg.Headers {
carrier[string(header.Key)] = string(header.Value)
}
ctx := otel.GetTextMapPropagator().Extract(context.Background(), carrier)
ctx = consumer.NewContext(ctx, &consumer.ConsumerInfo{...})
这三段代码覆盖了进程间传递的三大通道:HTTP、goroutine、消息队列。每一段都有对应的断点:HTTP 断点多半是少了注入与提取;goroutine 断点多半是误用了 background context 或者父子 span 生命周期颠倒;消息队列断点则是没做 Inject/Extract。排查跨服务链路断裂,先顺着这三个断点逐个检查,90% 的问题都能找到答案。
3. 统一 Tracing 的落地:SDK 初始化、采样器与导出器
把链路串起来之后,下一步是让所有服务用同一套标准把 span 生产出来、送到同一个地方。很多团队在这一点上各行其是:有的服务直连 Jaeger,有的服务写日志文件再到第三方日志平台捞 span,有的服务干脆自己拼一个 JSON 塞给 Kafka。结果就是:TraceID 能和请求对应起来,但根本无法在统一的可观测性平台上做跨服务关联。OpenTelemetry 的价值就是标准化的 Tracer、Span、Metric API 和 Export 协议,只要所有 Go 服务按照统一方式初始化 SDK,数据格式天然一致。
一个合理的 Go SDK 初始化函数大致长这样:
go复制func initTracerProvider(serviceName, otlpEndpoint string) (*sdktrace.TracerProvider, error) {
res, err := resource.New(
context.Background(),
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.DeploymentEnvironment("production"),
),
resource.WithFromEnv(),
)
if err != nil {
return nil, err
}
exporter, err := otlptracegrpc.New(
context.Background(),
otlptracegrpc.WithEndpoint(otlpEndpoint),
otlptracegrpc.WithInsecure(), // 测试环境;生产环境应使用 TLS
)
if err != nil {
return nil, err
}
sampler := sdktrace.ParentBased(
sdktrace.TraceIDRatioBased(0.1),
sdktrace.WithRemoteParentSampled(sdktrace.AlwaysSample()),
)
tp := sdktrace.NewTracerProvider(
sdktrace.WithSampler(sampler),
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
return tp, nil
}
这里有几个点必须强调。
资源属性里的 service.name 是跨服务关联的核心维度。 如果不同服务把 service.name 写成了同一个值,或者漏了 DeploymentEnvironment 这类区分环境的属性,Jaeger 和 Grafana 里会把两个服务当成同一个节点合并,部署环境也会混在一起。在做资源属性设计时,至少要有这三层:service.name 标识服务名,deployment.environment 标识环境(prod/staging/dev),service.version 标识发布版本。service.version 平时用得少,但它对"某次发布后指标异常是否由新代码引入导致"的判断极其关键。
采样策略要特意多讲两句。很多初学团队直接使用 AlwaysSample,把所有 span 全量上报。在小流量阶段没问题,流量一旦上来,链路存储压力直线上升,几个服务一天的 span 量就能破亿。OpenTelemetry 的推荐策略是父级采样,也就是 downstream services 继承 upstream services 的采样决策,避免上游报了一条 span、下游报了一条不相关的 span,导致关联视图错乱。
上面代码里我用的 ParentBased(TraceIDRatioBased(0.1), WithRemoteParentSampled(AlwaysSample())) 代表一个常见的生产策略:如果父 span 没有被采样,当前 span 也不采样;如果远端父 span 被采样了,当前 span 无条件采样,保证跨服务链路完整。这样"整个 trace"要么全部进存储,要么全部不进存储,不会出现半条链路的尴尬局面。不过也要注意:WithRemoteParentSampled(AlwaysSample()) 在高并发下,如果所有端到端链路都被上游采样,下游流量仍然会全部上报,存储压力依然大。所以在真正大流量场景下,我建议对链路做分层采样:核心链路(如支付、下单)用 AlwaysSample,非核心链路用 TraceIDRatioBased,通过 collector 的 group-by-trace 策略来合并统计,而不是简单地在 SDK 端一刀切。
导出器方面,生产环境我强烈建议用 gRPC OTLP 导出到 Collector,而不是直接导出到 Jaeger 或 Tempo。原因有三:一是 Jaeger 直接接入要使用 Jaeger 原生协议,无法同时把指标(OTLP Metric)发到同一套通道;二是 Collector 可以做聚合、过滤、重试、多后端 fanout,直连后端则不具备这些能力;三是后续扩展日志模块时,OTLP 的 Logs 能力可以和 Trace/Metric 统一走一套 exporter,便于实现 trace 驱动的日志关联。只要后端支持 OTLP,无论是 Jaeger、Grafana Tempo 还是云厂商托管的可观测平台,都能统一对接。
有一点需要特别提醒:Go SDK 的 OTLP exporter 默认是异步批量的,WithBatcher 会攒一批 span 再发出去。这带来两个问题:第一是服务进程退出前,如果未调用 TracerProvider.Shutdown(),这些 span 会直接丢在内存里;第二是高延迟场景下落库有秒级延迟,看 Jaeger 时总觉得"数据少了",其实只是还没 flush。我习惯在每个服务的启动流程里注册 shutdown hook,同时写一个 healthcheck 接口,用来手动触发 flush:
go复制func shutdownTracerProvider(tp *sdktrace.TracerProvider) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := tp.Shutdown(ctx); err != nil {
log.Printf("failed to shutdown tracer provider: %v", err)
}
}
很多服务在 Kubernetes 下被 SIGTERM 杀掉,shutdown 逻辑不完整,最后一批 span 集体丢失,排查时表现为"零星有几条链路不完整",其实不是链路断裂,而是进程没有优雅退出。这一条值得你在上线前重点检查。
4. 统一指标的关键一役:Collector 汇聚与 exemplar 关联
链路串通、数据统一之后,又一个核心问题冒出来:指标和链路数据仍然是两套体系。你在 Grafana 看到的 P99 延迟来自 Prometheus 指标,Jaeger 里看到的单条调用耗时来自 trace,两者在界面上毫无关系。做容量规划时你可能盯着 PromQL 想了半天,还是不知道"订单服务最近五分钟的 99 分位延迟为什么从 80ms 跳到 350ms"。Trace ID 就在那里,但指标图上没有任何一个入口能跳转过去。
OpenTelemetry 对这个问题的标准答案叫 Exemplar。Exemplar 是一种挂在 metric data point 上的附加信息,它记录了这条指标的样本是从哪一次具体测量来的,可以把 trace ID、span ID 和 attribute 一起附在指标数据点上。这样你在看 Prometheus histogram 的时候,可以点开特定 bucket 里的 exemplar,直接跳转查看对应的 trace。Prometheus 2.27 之后的版本和 Grafana 8.5 之后的版本都支持 Exemplar 可视化。Go 的 OpenTelemetry SDK 在观测到某些 span 被采样时,会把这些 span 的 traceID 自动挂到某些指标的 sample 上,不需要你写额外代码,但使用场景有限制:主要针对 histogram 和 gauge,且默认存储最近一个 exemplar。
我们的实践是让所有 Go 服务在埋点指标时,尽量使用 OTel Metric API 而不是 Prometheus client 直写,并且统一经由 OTLP 发送到 Collector:
go复制import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
func NewOrderMetrics(meterProvider metric.MeterProvider, serviceName string) (*OrderMetrics, error) {
meter := meterProvider.Meter("order-service-metrics", metric.WithInstrumentationVersion("1.0.0"))
orderDelayHistogram, err := meter.Float64Histogram(
"order.create.delay",
metric.WithUnit("ms"),
metric.WithDescription("Time from order request received to response sent"),
)
if err != nil {
return nil, err
}
return &OrderMetrics{
orderDelayHistogram: orderDelayHistogram,
serviceName: serviceName,
}, nil
}
func (m *OrderMetrics) RecordOrderCreation(ctx context.Context, delayMs float64, isSuccess bool) {
opts := metric.WithAttributes(
attribute.String("service.name", m.serviceName),
attribute.Bool("success", isSuccess),
)
m.orderDelayHistogram.Record(ctx, delayMs, opts)
}
关键点:Record 时传的 ctx 必须包含当前 span。OTel SDK 在收到带 span 的 ctx 时,会根据采样决策和 exemplar reservoir 策略自动附带 trace ID。如果你在 Record 时传的是 context.Background(),指标照样记录,但 Exemplar 就没了,trace 和 metric 又断了。这是最常见的"指标和 trace 没有关联"败因。
Collector 承担统一汇聚的角色,配置值得仔细推敲。下面是生产环境可用的一个例子,把 trace 和 metric 合并接收、聚合后转发到 Prometheus 和 Tempo:
yaml复制receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
memory_limiter:
check_interval: 1s
limit_mib: 512
resourcedetection:
detectors: [env, system]
timeout: 2s
tail_sampling:
decision_wait: 10s
policies:
- name: errors-and-slow
type: and
and_sub_policy:
- name: error-policy
type: status_code
status_code:
status_codes: [ERROR]
- name: slow-policy
type: latency
latency:
threshold_ms: 300
- name: high-throughput-root
type: trace_state
trace_state:
key: sth
values: [critical]
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
namespace: otel
add_metric_suffixes: false
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, resourcedetection, tail_sampling, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [memory_limiter, resourcedetection, batch]
exporters: [prometheus]
这里要特别说明 tail_sampling 策略。前面 SDK 端用的是 ParentBased 采样,为了保证跨服务链路完整,它要求所有服务对同一 TraceID 做一致的采样决策。但如果你发现存储压力仍然很大,可以尝试在 Collector 端统一做尾部采样:所有服务的 span 都先进 Collector,Collector 以 10 秒为窗口预测,看整条 trace 里有没有错误、有没有慢调用,来决定是否保留整条 trace 的所有 span。这样下钻排障时你永远能拿到有问题的完整链路,而健康链路的存储开销可以压到很低。tail_sampling 和 SDK 端 ParentBased 采样是两条独立的控制维度:前者的 decision_wait 决定了要缓存多少秒的 span 才能做决策;后者的采样率决定了进入 Collector 的原始流量规模,两者需要配合调参,不能只靠一边。
Metrics Pipeline 里我没有加 tail_sampling,因为指标是不能随便丢的。指标需要在 Collector 端完成定时的聚合、降精度、压缩上报量,但不应该做"按 trace 保留/丢弃"这样的决策。
Collector 端还有一个经常被忽略但很重要的功能:把历史 AggregationTemporality 统一成 Cumulative 或 Delta。如果你的某些 Go 服务上报 Delta 指标,另一些上报 Cumulative 指标,Prometheus 再拉取时会显示成乱七八糟的时间序列。我建议在 Collector 端做一次标准化转换,Prometheus receiver 默认用 Cumulative,OTLP 的 metric 可以用 summarize 处理器转换,或者在导出器配置里指定 temporality。这个细节在数据量大的时候才看得出来,一旦发现 Grafana 图上频繁出现趋势突变和锯齿,先检查 temporality 是否一致。
5. 完整排查实录:从 TraceID 不一致到指标分桶偏差
看理论和配置可能不过瘾,我直接给一段真实排查过程,你跟着走一遍,就能把 OpenTelemetry 的常见坑一次见完。
第一类问题:TraceID 完全对不上。 某个服务的调用方和被调用方已经都接入了 otelhttp,但 Jaeger 里两个服务的 trace 仍然是两条独立的根。当时检查步骤是这样的:
- 用 curl 带
traceparentHeader 打被调用方接口,看响应是否正常提取。结果响应正常。 - 查看调用方日志里的 outbound 请求,发现根本没有
traceparentHeader 的打印。 - 再到代码里看调用逻辑,发现调用方用的是标准库
http.Client,而不是otelhttp.NewTransport包装过的 client。 - 还发现调用方服务虽然接入了 otelhttp Server 中间件,但自己的调用代码里又手工创建了
http.NewRequestWithContext(ctx, ...),那个 ctx 确实带着 span,但由于 Transport 没有做注入,Header 里自然什么都没有。
这个案例给我们的教训是:接了 Server 侧中间件不等于出站调用也接上了。在一个 Go 代码库里,只要有一个 HTTP client 没走统一 Transport,所有从它发出的请求都会断链。为了避免这种情况,我后来在代码规范里要求所有 Go 服务禁止直接使用 http.Client{} 零值,统一走 client := otelhttp.NewClient(),并且用静态检查工具扫出两处违规点。
第二类问题:TraceID 变了但父 span 没变。 这通常发生在消息队列消费场景。消费者从 Kafka 拉取消息,做了 Extract 后,在同一个 context 上创建了新的 span,但 Jaeger 里看到的 trace 结构是:旧的 root span 下面挂了一个新的根 span,但两者之间没有 parent-child 关系。排查时发现,消费者 Kafka 客户端从 Header 里取 TraceID,然后 Extract 到新的 ctx,逻辑是对的。问题出在 OpenTelemetry 的 Span 也包含"当前有效 parent"概念,如果消费者在 Extract 之前已经通过别的方式创建了一个 span,后续创建的 span 会把之前的 span 当 parent,而不是把 Kafka Header 里的 TraceID 当 parent。
修复方式是严格遵循"先 Extract,再 Start"的顺序,任何中间过程不得先启动 span。还要检查是否有库在某一步默认创建了 no-op span,导致 context 已携带一个"假 parent"。
第三类问题:链路完整但指标分桶偏差。 这是最让人困惑的一类。Trace 已经正常串联了,但看 Grafana 上某个指标的 P99 时,发现某些时间点数据突跳,和 Trace 里看到的单次调用耗时完全对不上。顺着 exemplar 点进去看了 select 行,发现 exemplar 里挂的 traceID 对应的耗时和指标显示值差了百倍。原因在于我们在某个服务里用同一把 Histogram 记录了多个场景的数据,但没有拆分按场景加 attribute,导致所有场景都塞进同一个 bucket map,进而 exemplar 关联到的是一个极端值样本。修复方法是拆成两把直方图,或者每个 scenario 用独立的 attribute set,生成独立的 time series。
这三类问题的共性是:排查链路断裂不能只看 trace 图,而是要看消息头、看采样决策、看指标分桶、看 exemplar 关联状态。OpenTelemetry 的可观测性不仅仅在于"收集数据",更在于"各类型数据之间能否彼此索引"。TraceID 是串起 trace 和 metric 的线索,Exemplar 是串起 metric 和 trace 的入口,只要你在这两条线上任何一处断了,统一的可观测性就无从谈起。
6. 我踩过的坑与团队落地建议
最后这一部分不写教程了,纯粹讲团队实践里踩过的几个坑,每一个都付出过真金白银的线上教训。
版本匹配问题是 Go 项目的头号隐形坑。OpenTelemetry Go SDK 的版本迭代比较频繁,go.opentelemetry.io/otel、go.opentelemetry.io/otel/sdk、go.opentelemetry.io/otel/exporters/otlp/otlptrace 这几个模块必须保持在同一个大版本甚至相近的小版本,否则会出现 proto not compatible 或者运行时 panic。每次升级依赖时,建议先跑一遍测试,再灰度一个服务验证数据正常。我们曾经把 otel sdk 升到 1.30 但没同步升级 otelhttp,启动时 builder 报了一堆类型不匹配,最后全网服务一起挂了。从那之后,我把所有 otel 相关依赖固定在同一个 go.mod 里,统一升级。
Exporter 的超时和重试机制也值得注意。OTLP exporter 默认有连接重试,但如果后端 Collector 长时间不可用,内存里的 span 会积压,最终导致内存溢出。你可以在 exporter 构造时设置 WithRetry 和 WithTimeout,配合 WithMaximumSize 控制内存占用。更稳妥的做法是给 collector 配一个本地磁盘队列当 buffer,当 remote 后端宕机时至少不丢太多数据。
资源清理顺序有讲究。在 shutdown hook 中,要先 Shutdown(tp) 再关闭 metric exporter,否则可能出现 trace 已经上报但指标还没 flush 的情况,导致 exemplar 里挂了一个已经失效的 TraceID。
在团队落地时,我一直推崇"逐步推进,不要一上来就全量接入"的策略。 第一阶段:只接入 trace 到 Jaeger,不动指标;第二阶段:接入 OTLP metric,让所有服务发出指标,并确保在 Grafana 上看得到 exemplar;第三阶段:再引入业务自定义指标,逐步淘汰手工 Prometheus client 埋点。每阶段都要有一个验证用例,比如"在 jaeger 里根据 traceID 可以找到对应指标样本",确认可用再扩大范围。先让链路通,再让数据统一,最后才谈业务定制。 如果一上来就既接 trace 又接 metric 又加自定义 attribute,出问题时根本分不清是哪一层断了。
个人实操中还有一个非常推荐的小技巧:在每个服务里暴露一个 /debug/otel 接口,返回当前的 TracerProvider 配置、采样器类型、资源属性、export 目标地址和最近一次 export 是否成功。这在接入初期和被历史遗留问题折磨时非常救命——你不需要猜"这个服务的采样率到底是多少",看接口就能确认。很多团队接入 OTel 失败,不是因为不熟练,而是因为配置和实际运行状态出现了脱节,而这个接口能帮你立刻看出当前服务的真实 OTel 状态。
关于指标和 trace 的统一,我最后的经验是:永远不要停留在一张漂亮的 trace 图。Trace 的价值在于解释"一次请求到底经历了什么",指标的价值在于回答"系统整体表现如何",只有当指标上任何一点异常都能通过体验跳转到具体 trace 时,跨服务链路的可观测体系才算真正闭环。做到这一点不需要多高深的技术,核心就是把 context 传播、采样策略、Collector 汇聚和 exemplar 关联这四件事想透,然后在一个生产场景里反复验证。希望上面的实践能帮你少走一些弯路,至少不用像我一样,在凌晨三点被拉起来处理"链路通但指标对不上"的问题。
