凌晨两点,手机弹出告警:订单服务的 P99 延迟从 120ms 飙到 2.4s,错误率从 0.1% 涨到 3.8%。值班的同事打开 Grafana 看指标,CPU、内存、QPS 都正常;翻日志看到一堆 5xx,但报错点分散在十几个下游调用里,分不清因果;拉了 Jaeger 链路,发现慢的 trace 都卡在某个下游缓存调用上,但样本只有寥寥几条,无法确认是普遍现象还是个例。三套系统来回切换,半小时才定位到根因。

这是我们两年前的一次真实排障。那次复盘让我们彻底想清楚一件事:Metrics、Logging、Tracing 不是三个孤立的工具,而是一个闭环的三条腿。这篇文章就以一次延迟告警为线索,讲清楚这三支柱各自能做什么、各自的天花板在哪、以及它们如何通过 trace_id 串成一条完整的排障链路。

一、可观测性三支柱:各自能回答什么问题

很多人把"监控"和"可观测性"混为一谈。两者的差别在于:监控只能回答你预先定义好的问题(你知道要问什么,提前埋了点);而可观测性允许你回答事先没想过的问题,靠的是系统自身暴露的丰富信号。可观测性建立在三个互补的支柱之上。

  • Metrics(指标):回答"发生了什么、规模多大、趋势如何"。它是聚合后的数值时序数据,成本低、查询快,适合告警和大盘。局限是只有数字没有细节——P99 涨了你不知道是哪批请求、哪个用户、哪个参数。
  • Logging(日志):回答"具体某次发生了什么"。它保留了每次调用的离散事件细节,是定位 bug 最直接的证据。局限是难以关联——一次跨 8 个服务的请求,日志散落在 8 个地方,没有统一的请求标识就拼不起来。
  • Tracing(链路):回答"这次请求经过了哪些环节、各自花了多少时间"。它用一棵 span 树还原调用关系,是定位慢调用和异常分支的最佳工具。局限是样本有限——出于成本考虑,全量采集的链路通常只覆盖万分之一到千分之一。

一句话概括:指标告诉你"病了",日志告诉你"哪里疼",链路告诉你"病灶在哪个器官"。三者缺一不可。

二、Metrics 层:用 RED 看服务,用 USE 看资源

指标层我们用 Prometheus。在动手埋点之前,先想清楚要看什么。有两套成熟的方法论:RED 方法面向服务,USE 方法面向资源。

RED 是 Tom Wilkie 提出的,针对每个微服务看三个核心指标:

  • Rate:请求速率(QPS/RPS)。
  • Errors:错误请求数/错误率。
  • Duration:请求耗时分布(通常看 P50/P95/P99)。

USE 是 Brendan Gregg 提出的,针对每个资源(CPU、磁盘、网络、连接池)看:

  • Utilization:使用率。
  • Saturation:饱和度(排队长度)。
  • Errors:错误(丢包、重传、OOM)。
维度RED 方法USE 方法
适用对象服务 / 接口 / 业务流资源(CPU/内存/磁盘/网络/连接池)
回答问题服务健不健康、用户体感如何资源够不够、有没有瓶颈
典型指标QPS、错误率、P99 延迟CPU 使用率、磁盘 IO 队列长度、连接池占用
排障定位用户视角的"慢"和"错"系统视角的"挤"和"满"
配合使用RED 发现服务异常 →→ USE 下钻到具体资源瓶颈

Prometheus 提供四种指标类型,理解它们的差异是正确埋点的前提:

  • Counter(计数器):只增不减,比如 http_requests_totalerrors_total。查速率时用 rate()increase()
  • Gauge(瞬时值):可增可减,表示当前状态,比如 active_connectionsqueue_depthmemory_usage_bytes
  • Histogram(直方图):把观测值分桶(bucket)累计,用于计算分位数(P95/P99)。比如 http_request_duration_seconds_bucket
  • Summary(摘要):客户端直接算好分位数上报。精度高但无法跨实例聚合,多副本场景下应该优先选 Histogram

下面这段是订单服务用 Prometheus 客户端暴露的 RED 指标。所有指标都带上 trace_id(通过 exemplar 关联)和业务维度,方便后续下钻:

from prometheus_client import Counter, Histogram

http_requests_total = Counter(
    "http_requests_total",
    "Total HTTP requests",
    ["method", "endpoint", "status"]
)

http_request_duration_seconds = Histogram(
    "http_request_duration_seconds",
    "HTTP request duration",
    ["method", "endpoint"],
    buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10),
)

# 在中间件里埋点
@app.middleware("http")
async def metrics_middleware(request, call_next):
    start = time.time()
    try:
        response = await call_next(request)
        http_requests_total.labels(
            method=request.method,
            endpoint=request.url.path,
            status=response.status_code,
        ).inc()
        return response
    finally:
        http_request_duration_seconds.labels(
            method=request.method,
            endpoint=request.url.path,
        ).observe(time.time() - start)

对应的告警规则用 RED 三件套写,下面这条规则就是文章开头那起告警的源头:

# Prometheus alerting rules
groups:
  - name: order-service
    rules:
      - alert: HighLatencyP99
        expr: |
          histogram_quantile(0.99,
            sum(rate(http_request_duration_seconds_bucket[5m])) by (le, endpoint)
          ) > 1
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "P99 延迟超过 1s: {{ $labels.endpoint }}"
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m])) by (endpoint)
          /
          sum(rate(http_requests_total[5m])) by (endpoint)
          > 0.02
        for: 3m
        labels:
          severity: warning

三、Logging 层:结构化、可采样、带 trace_id

指标告诉我们"订单接口的 P99 在飙升",但具体是哪批请求、带了什么参数、调了哪个下游,指标答不上来——这就是日志的活。日志层要落地好,有三条原则:

第一,结构化日志。告别 printf("user %s failed", uid) 这种人类可读但机器难解析的写法,改用 JSON 字段。这样 ELK/Loki 能直接按字段过滤聚合:

# 推荐的结构化日志(JSON)
{
  "timestamp": "2026-06-10T02:14:33.512Z",
  "level": "ERROR",
  "service": "order-service",
  "trace_id": "a1b2c3d4e5f6789012345678",
  "span_id": "0abc1234def05678",
  "user_id": "U100234",
  "event": "payment_timeout",
  "downstream": "payment-service",
  "duration_ms": 30012,
  "error": "context deadline exceeded"
}

第二,统一注入 trace_id。这是日志能和链路串起来的关键。我们在网关入口生成 trace_id,通过 HTTP Header X-Trace-Id 向下游透传,每层服务都把它写进 MDC(Mapped Diagnostic Context)然后注入日志字段:

# 中间件:从 Header 提取 trace_id,没有就生成
import contextvars, uuid

trace_id_var = contextvars.ContextVar("trace_id", default="")

@app.middleware("http")
async def trace_middleware(request, call_next):
    trace_id = request.headers.get("X-Trace-Id") or uuid.uuid4().hex
    trace_id_var.set(trace_id)
    response = await call_next(request)
    response.headers["X-Trace-Id"] = trace_id  # 返回给客户端便于排查
    return response

# 日志格式器自动带 trace_id
class TraceFilter(logging.Filter):
    def filter(self, record):
        record.trace_id = trace_id_var.get()
        return True

第三,采样。INFO 级日志全量采集成本惊人——我们曾有一个服务一天产生 800GB 日志。策略是:DEBUG 直接不落盘;INFO 按 trace_id 末位做尾数采样(比如只采 1/16);ERROR/WARN 全量保留。这样既控制成本,又保证出问题时能拿到完整的错误链路。

日志系统选型上,体量不大可以用 ELK(Elasticsearch + Logstash + Kibana),体量大、成本敏感的推荐 Loki——它不索引日志内容只索引标签,存储成本只有 ELK 的十分之一左右,且和 Grafana 天然集成。

四、Tracing 层:用 OpenTelemetry 还原调用树

有了指标和日志,还差最后一环:一次请求到底经过了哪些服务、在哪个环节耗时最长。这就是 Tracing 的职责。我们用 OpenTelemetry(简称 OTel)作为统一标准,后端用 Jaeger 存储和查询。

先理清三个核心概念:

  • Trace(链路):一次完整请求的所有 span 组成的一棵树,用唯一的 trace_id 标识。
  • Span(跨度):链路中的一个工作单元,比如一次 HTTP 调用、一次数据库查询。每个 span 有自己的 span_id、父 span_id、起止时间、属性。
  • Context Propagation(上下文传播)trace_idspan_id 通过 HTTP Header(W3C Trace Context 规范,traceparent 字段)或 RPC 元数据在服务间透传,把分散的 span 拼成一棵完整的树。

OTel 的好处是厂商中立:同一套 instrumentation,后端可以切到 Jaeger、Tempo、Datadog,不用改业务代码。下面是 Python 服务接入 OTel 并自动埋点的最小配置:

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

# 配置 Tracer,导出到 OTel Collector
provider = TracerProvider()
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="otel-collector:4317"))
)
trace.set_tracer_provider(provider)

app = FastAPI()
# 自动给所有路由创建 span,并注入 trace_id 到日志 MDC
FastAPIInstrumentor.instrument_app(app)

# 手动埋点关键业务逻辑
tracer = trace.get_tracer("order-service")
@app.get("/orders/{order_id}")
async def get_order(order_id: str):
    with tracer.start_as_current_span("query_order") as span:
        span.set_attribute("order.id", order_id)
        order = db.find(order_id)        # DB 调用也会被自动 instrumentation 成子 span
        return order

把链路查出来后,定位慢调用就一目了然。文章开头那起告警,我们最终在 Jaeger 里看到的 trace 长这样(伪结构,时间单位毫秒):

GET /orders/12345          [trace_id=a1b2c3...]  total=2412ms
├── gateway               span  1.2ms
├── order-service
│   ├── auth_check        span  8ms
│   ├── query_db          span  45ms
│   └── payment-service   span  2350ms        ← 慢点在这里!
│       ├── redis_get     span  2103ms        ← 根因:缓存击穿打到 DB
│       └── mysql_query   span  240ms
└── response_serialize    span  3ms

从这棵树一眼就能看到:总耗时 2412ms 里,payment-serviceredis_get 占了 2103ms。再结合日志(按同一个 trace_id=a1b2c3... 过滤),看到这个 key 在 Redis 里不存在,请求穿透到 MySQL,而 MySQL 那张表恰好没有热数据缓存——典型的缓存击穿。

五、三者协同:一次完整的排障闭环

把前面四节串起来,那起 P99 告警的完整排障路径是这样的:

  1. Metrics 触发告警:Prometheus 抓到 http_request_duration_seconds 的 P99 超过 1s,Alertmanager 推送。
  2. Grafana 大盘看趋势:确认不是单点抖动,而是从 02:10 开始持续走高,同时段 Redis 命令数没涨、但慢查询数翻倍——RED 发现问题,USE 下钻到资源层发现 Redis 趋于饱和。
  3. 链路定位慢 span:在 Grafana 里点开那条 P99 高的指标,通过 exemplar 跳到对应的 trace,看到耗时集中在 payment-serviceredis_get
  4. 日志拿根因细节:用 trace_id 在 Loki 里过滤出所有相关日志,发现报错都是同一个 order_id 段——一个热门商品秒杀活动,缓存 key 大面积失效。
  5. 修复并回归:临时给热点 key 加互斥锁 + 永不过期 + 异步刷新,Metrics 的 P99 在 4 分钟内回落到 130ms。

这条路径之所以能跑通,核心就是三个系统共享同一个 trace_id

  • Prometheus 的 Histogram 通过 exemplar 关联 trace_id,让指标点可以下钻到链路。
  • 日志每行都带 trace_id 字段,可以按 trace 聚合出一次请求的完整上下文。
  • Jaeger 的 trace 自带 trace_id,是串联这一切的主键。

这个闭环的关键不在工具本身,而在主键的统一。如果日志没带 trace_id,链路就找不到细节;如果指标没关联 trace,告警就只能告诉你"病了"而指不到具体哪条请求。所以接入可观测性时,第一步永远是打通 trace_id 的全链路透传,而不是急着上工具。

结语

回到开头的那个凌晨——如果当时三套系统已经通过 trace_id 打通,值班同事的排障路径会变成:告警 → 看 RED 大盘确认规模 → 点 exemplar 进 Jaeger 找慢 span → 用 trace_id 在 Loki 拉日志看根因,全程不超过 5 分钟,而不是在三个 tab 之间反复横跳半小时。可观测性不是堆砌工具,而是用一条 trace_id 把分散的信号织成一张网。Metrics 守住成本和告警的广度,Tracing 还原调用的深度,Logging 留下事件的细节——三者协同,系统才真正"可观测"。下一篇我们会聊聊怎么给这套可观测体系加上成本治理:在海量指标和链路面前,如何用采样和降采样把账单压在合理区间。