可观测性体系:从Metrics到Trace的落地
一个典型的故障排查场景:订单服务 P99 延迟从 200ms 涨到 2s,告警群炸了。值班同学打开 Grafana 看板,发现订单服务 CPU 正常、内存正常、QPS 正常——所有 Metrics 都健康,但用户就是在骂。接下来要花两个小时翻日志、猜是哪个下游拖慢的。问题不在于监控没做,而在于 Metrics 只能告诉你“出了事”,说不出“事出在哪个调用链的哪一跳”。这篇文章讲的是怎么把 Trace 补上,让 Metrics 和 Trace 打通,把两小时的排查压缩到十分钟。
先看清楚 Metrics 的天花板在哪
Metrics 是聚合数据。订单服务的 P99 延迟是一个数值,背后可能是 10 万次请求的统计结果。这个数值涨了,但涨的原因被聚合抹掉了。
三个具体盲区:
- 跨服务定位不到。订单服务调了库存、支付、风控三个下游,Metrics 只能看到订单服务整体变慢,看不出是哪一个下游。
- 单请求上下文丢失。一次慢请求里,到底是 DB 查询慢、还是某个 HTTP 调用慢、还是序列化慢,Metrics 粒度到不了这一层。
- 聚合掩盖长尾。P99 从 200ms 涨到 2s,可能是 1% 的请求卡了 10s,也可能是 30% 的请求慢了 500ms,处理方式完全不同,但看板上都是“P99 涨了”。
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,存储和网络开销都不小。几个实操策略:
- 分层采样。入口服务用低采样率(1%~5%),内部服务用
ParentBased继承上游决策,保证链路不断。 - 尾部采样。在 OTel Collector 里配 tail_sampling processor,只保留错误 span、慢 span 和特定用户的 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% 做基线。这样故障排查时数据是全的,平时成本可控。
- 设置 span 属性上限。别把整个 request body 塞进 span attribute,单条 span 超过 1KB 就要警惕。
下一步做什么
从 Metrics 到 Trace 的落地路径是:先给核心服务加 OTel 自动埋点,让链路先跑起来;再配 Exemplar 把 Grafana 和 Jaeger 打通;最后上尾部采样把成本压住。三步不用一次做完,第一步做完就能覆盖大部分跨服务排查场景。
如果现在只有一个 Metrics 看板,建议先挑一个最常出故障的服务做试点,埋点、采样、打通告警链路跑通一遍,再复制到其他服务。可观测性不是一次建成的系统,是随着故障复盘一轮轮补出来的。
本文关键词:OpenTelemetry、Trace、Metrics、Prometheus、Grafana