凌晨两点,手机弹出告警:订单服务的 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_total、errors_total。查速率时用rate()或increase()。 - Gauge(瞬时值):可增可减,表示当前状态,比如
active_connections、queue_depth、memory_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_id和span_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-service 的 redis_get 占了 2103ms。再结合日志(按同一个 trace_id=a1b2c3... 过滤),看到这个 key 在 Redis 里不存在,请求穿透到 MySQL,而 MySQL 那张表恰好没有热数据缓存——典型的缓存击穿。
五、三者协同:一次完整的排障闭环
把前面四节串起来,那起 P99 告警的完整排障路径是这样的:
- Metrics 触发告警:Prometheus 抓到
http_request_duration_seconds的 P99 超过 1s,Alertmanager 推送。 - Grafana 大盘看趋势:确认不是单点抖动,而是从 02:10 开始持续走高,同时段 Redis 命令数没涨、但慢查询数翻倍——RED 发现问题,USE 下钻到资源层发现 Redis 趋于饱和。
- 链路定位慢 span:在 Grafana 里点开那条 P99 高的指标,通过 exemplar 跳到对应的 trace,看到耗时集中在
payment-service的redis_get。 - 日志拿根因细节:用 trace_id 在 Loki 里过滤出所有相关日志,发现报错都是同一个
order_id段——一个热门商品秒杀活动,缓存 key 大面积失效。 - 修复并回归:临时给热点 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 留下事件的细节——三者协同,系统才真正"可观测"。下一篇我们会聊聊怎么给这套可观测体系加上成本治理:在海量指标和链路面前,如何用采样和降采样把账单压在合理区间。