← 返回博客
2026-09-23 08:00:02

可观测性体系:从Metrics到Trace的落地

可观测性体系:从Metrics到Trace的落地

一个典型的故障排查场景:订单服务 P99 延迟从 200ms 涨到 2s,告警群炸了。值班同学打开 Grafana 看板,发现订单服务 CPU 正常、内存正常、QPS 正常——所有 Metrics 都健康,但用户就是在骂。接下来要花两个小时翻日志、猜是哪个下游拖慢的。问题不在于监控没做,而在于 Metrics 只能告诉你“出了事”,说不出“事出在哪个调用链的哪一跳”。这篇文章讲的是怎么把 Trace 补上,让 Metrics 和 Trace 打通,把两小时的排查压缩到十分钟。

先看清楚 Metrics 的天花板在哪

Metrics 是聚合数据。订单服务的 P99 延迟是一个数值,背后可能是 10 万次请求的统计结果。这个数值涨了,但涨的原因被聚合抹掉了。

三个具体盲区:

Trace 解决的正是这三件事:把一次请求经过的每个服务、每次调用、每段耗时都记下来,形成一条完整链路。Metrics 回答“有没有问题”,Trace 回答“问题在哪”。

埋点:OpenTelemetry 落地的最小可用配置

补齐 Trace 的第一步是埋点。早期方案要在每个服务里手动传递 trace_id、手动记录 span,改造成本极高。现在用 OpenTelemetry(OTel)的自动埋点,Go/Java/Python 服务基本零代码改动。

以 Go 服务为例,注入 OTLP exporter 到启动流程:

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/sdk/trace"
)

func initTracer() (*trace.TracerProvider, error) {
    exporter, err := otlptracegrpc.New(context.Background(),
        otlptracegrpc.WithEndpoint("otel-collector:4317"),
        otlptracegrpc.WithInsecure(),
    )
    if err != nil {
        return nil, err
    }
    tp := trace.NewTracerProvider(
        trace.WithBatcher(exporter),
        trace.WithSampler(trace.ParentBased(trace.TraceIDRatioBased(0.1))),
    )
    otel.SetTracerProvider(tp)
    return tp, nil
}

几个关键点。WithSampler 决定采样率,全量采样在高 QPS 下会把 collector 打爆,TraceIDRatioBased(0.1) 表示采 10%。但 10% 采样会漏掉低频故障,推荐用 ParentBased 保证同一条链路的采样决策一致,再配合尾部采样(tail sampling)在 collector 侧按“错误请求 100% 保留、慢请求 100% 保留”的规则过滤。WithInsecure() 只在集群内网用,跨网络必须配 TLS。

HTTP 服务的自动埋点更省事,用 otelhttp 包一层 handler 就行:

handler := otelhttp.NewHandler(mux, "order-service")
http.ListenAndServe(":8080", handler)

自动埋点会为每个入站请求创建 span,并把 trace context 通过 traceparent header 传给下游。这里最容易踩的坑是异步任务丢 context:消息队列消费、goroutine 里发起的调用,如果不显式传递 context,链路就断了,Trace 里会出现一条孤立的 span。发消息时把 trace context 塞进 message header,消费端再提取出来,链路才完整。

打通 Metrics 和 Trace:从告警直跳链路

埋点做完,Trace 数据进了 Jaeger 或 Tempo,但值班同学还是习惯先看 Grafana。如果 Metrics 和 Trace 是两套割裂的系统,排查还是要手动切换、手动找时间点。

打通的核心是 Exemplar。Prometheus 2.26 之后支持在 Metrics 样本上挂一个 trace_id。配置方式:

# prometheus.yml
scrape_configs:
  - job_name: 'order-service'
    static_configs:
      - targets: ['order-service:8080']

应用侧在记录 Histogram 时附带 exemplar:

timer := prometheus.NewHistogramVec(prometheus.HistogramOpts{
    Name:    "http_request_duration_seconds",
    Buckets: prometheus.DefBuckets,
}, []string{"handler"})

timer.WithLabelValues("/order/create").
    ObserveWithExemplars(duration.Seconds(),
        prometheus.Labels{"trace_id": span.SpanContext().TraceID().String()})

Grafana 里打开 “Exemplars” 开关,延迟曲线上会出现小圆点,点一下直接跳到 Jaeger 对应的 Trace。这一步把“看板发现异常 → 手动找 Trace”的切换从几分钟压缩到一次点击。

再往前一步,把 Trace ID 写进日志。结构化日志里加一个 trace_id 字段,Loki 或 ELK 就能按 trace_id 聚合出这次请求的所有日志。这样排查路径变成:Grafana 告警 → 点 Exemplar 看 Trace → 从 Trace 里的 span 跳到对应服务的日志。三跳定位到根因。

采样策略和成本:别让可观测性拖垮预算

Trace 落地最大的现实阻力是成本。全量采样下,一个 QPS 1000 的服务每天产生近亿条 span,存储和网络开销都不小。几个实操策略:

processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: slow
        type: latency
        latency: {threshold_ms: 1000}
      - name: baseline
        type: probabilistic
        probabilistic: {sampling_percentage: 1}

decision_wait 是等待链路完整的时间,设太长会占内存,10s 是个常用起点。三条策略是“或”的关系,错误和慢请求全留,正常请求只留 1% 做基线。这样故障排查时数据是全的,平时成本可控。

下一步做什么

从 Metrics 到 Trace 的落地路径是:先给核心服务加 OTel 自动埋点,让链路先跑起来;再配 Exemplar 把 Grafana 和 Jaeger 打通;最后上尾部采样把成本压住。三步不用一次做完,第一步做完就能覆盖大部分跨服务排查场景。

如果现在只有一个 Metrics 看板,建议先挑一个最常出故障的服务做试点,埋点、采样、打通告警链路跑通一遍,再复制到其他服务。可观测性不是一次建成的系统,是随着故障复盘一轮轮补出来的。

本文关键词:OpenTelemetry、Trace、Metrics、Prometheus、Grafana