线上出问题,最难的往往不是"没数据",而是数据都在但连不起来:日志一堆异常、指标显示 CPU 正常、追踪一堆慢 span,却没人答得出"这次请求在哪一步失败了"。
设计目标不是"多采集",而是让一个问题三步内可定位:整体异常 → 具体请求 → 具体代码段。三者各管一段,缺一段就断链。
1. 三者的分工与只用其一的盲区
| 类型 | 回答的问题 | 数据结构 | 成本特征 |
|---|---|---|---|
| 日志 | 这一刻发生了什么 | 带时间戳的事件流 | 量与流量成正比,最贵 |
| 指标 | 整体是否正常、趋势如何 | 数值时间序列(聚合后) | 与标签基数成正比 |
| 追踪 | 一次请求慢在哪一段 | 有父子关系的 span 树 | 与采样率成正比 |
只用其中一种,盲区是固定的:
| 只有 | 盲区 | 后果 |
|---|---|---|
| 日志 | 整体水位与趋势 | 只能靠人翻,无法提前发现 |
| 指标 | 单次请求的因果链 | 知道"错误率 5%",不知哪类请求错 |
| 追踪 | 未被采样的请求 | 慢请求恰好没采到,问题隐身 |
三者是索引关系:指标发现异常 → 缩小范围(接口/版本/地域)→ 追踪定位具体请求 → 日志看这次请求的细节。
2. 日志的四个设计要求
| 要求 | 具体做法 | 不做的后果 |
|---|---|---|
| 结构化 | 输出 JSON,字段固定(ts/level/msg/traceId/业务键) |
只能靠正则捞,字段一改全断 |
| 级别语义 | ERROR 只给"需要人处理"的事件;可预期的业务失败用 WARN/INFO |
告警被噪声淹没 |
| 请求标识贯穿 | 入口生成 traceId,全链路透传,每条日志都带 |
无法把一次请求的日志拼起来 |
| 敏感信息不落盘 | 手机号、证件、token 在序列化层脱敏 | 合规风险 + 日志滥用 |
{"ts":"2026-11-09T07:12:03.441Z","level":"ERROR","svc":"order-api",
"traceId":"4f2a9c1e8b7d3a55","spanId":"9c31aa02","userId":"u_88213",
"event":"payment_callback_failed","orderId":"O20261109001",
"errCode":"PG_TIMEOUT","elapsedMs":3210,"msg":"gateway timeout after retry"}
必带字段:traceId(把一次请求串起来)、event(稳定枚举名,写自然语言就无法聚合)、errCode(分组用)、elapsedMs(让日志本身能算耗时分布)。
采样要分类型:
错误 / 慢请求日志:全量保留
正常 INFO 日志:按请求采样,或按 traceId 哈希采样(同一请求要么全留要么全不留)
调试日志:默认关闭,按 traceId 临时开启
3. 指标的四类与"为什么不能用平均值"
| 类型 | 语义 | 典型用途 | 陷阱 |
|---|---|---|---|
| Counter(计数器) | 只增不减的累计值 | 请求总数、错误数 | 查询时必须用 rate(),直接看累计值无意义 |
| Gauge(计量器) | 可增可减的瞬时值 | 队列长度、内存占用、连接数 | 采样点间隔决定精度,尖峰可能被跳过 |
| Histogram(直方图) | 分桶统计的分布 | 延迟、响应大小 | 桶边界决定精度,+Inf 桶必须存在 |
| Summary(摘要型) | 客户端算好的分位数 | 单实例延迟分位 | 不可跨实例聚合,这是硬伤 |
延迟是长尾分布:平均值会被少数慢请求拉高,却不告诉你拉高了多少、影响了多少人。
# 错误:平均值,掩盖长尾
rate(http_request_duration_sum[5m]) / rate(http_request_duration_count[5m])
# 正确:分位数(基于 histogram 桶)
histogram_quantile(0.99,
sum by (le, route) (rate(http_request_duration_bucket[5m])))
两个必须知道的限制:
- 分位数不能做算术:
p99(A+B) ≠ p99(A) + p99(B),也不能对分位数求平均。 - 桶边界决定 p99 可信度:目标区间的桶太稀,插值出的 p99 只是估计值——按实际分布核对桶边界。
4. RED 与 USE:两套视角,别互相替代
| 视角 | 指标 | 适用对象 | 回答 |
|---|---|---|---|
| RED | Rate、Errors、Duration | 面向请求的服务(HTTP/gRPC 接口) | 用户侧体验是否正常 |
| USE | Utilization、Saturation、Errors | 面向资源的组件(CPU、磁盘、连接池、队列) | 哪一环被压满了 |
RED 站调用方,USE 站被调用方。路径:RED 发现某接口错误率上升 → USE 看它依赖的资源是否饱和 → 定位到具体组件。只用 RED 时,错误率上升而资源全绿说明瓶颈在下游;只用 USE 会漏掉"资源充足但逻辑出错"。
5. 追踪的核心概念
| 概念 | 含义 | 设计要点 |
|---|---|---|
| trace | 一次请求的完整调用树 | 由入口生成 id,全链路唯一 |
| span | 树上的一个节点(一次操作) | 必须有开始/结束、操作名、状态、属性 |
| 父子关系 | 谁调用了谁 | 跨进程时靠上下文传播建立 |
| 上下文传播 | 把 traceId/spanId 塞进请求头 | 用标准头(traceparent)而非自定义 |
| 采样 | 只保留部分 trace | 头部采样决定"采不采",尾部采样决定"留不留" |
# W3C Trace Context:跨进程传播的标准头
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
# 版本-traceId(16字节)-parentSpanId(8字节)-flags(01=采样)
采样策略是设计决策,不是配置项:
| 策略 | 机制 | 优点 | 缺点 |
|---|---|---|---|
| 固定比例头部采样 | 入口按概率决定 | 简单、开销低 | 慢请求/错误请求可能恰好没采到 |
| 尾部采样 | 先全收,结束后按规则决定 | 能保证"错误与慢请求必留" | 需要缓冲,实现复杂 |
| 按属性采样 | 按路由/租户/版本分流 | 关键路径全量、其余低采样 | 规则要随业务演进维护 |
可用组合:头部采样保基础覆盖率 + 尾部采样保错误与慢请求。纯固定比例的典型故障是 0.1% 采样下,只在万分之一请求出现的超时恰好没被采到。
6. 三者如何串起来
请求进入 → 生成 traceId → 注入日志上下文(MDC) + 响应头 + 下游请求头
→ 指标按 route/status 打标签(不含 userId)→ span 记录每段耗时
异常:指标告警 → 看 route/version 分布
→ 用 traceId/时间窗过滤 → 打开 trace 看最慢 span
→ 用 traceId 查日志 → 看到 errCode 与参数
串联的三个技术前提:
- 日志里必须有
traceId,否则跳不过去; - 指标标签里要有能过滤到 trace 的维度(
route、version、status),但不能是userId; - trace 里要记业务属性(
orderId、tenantId),否则只看到"慢"看不到"为什么慢"。
7. SLO:把"正常"定义成可计算的
| 要素 | 选择方式 | 反例 |
|---|---|---|
| SLI | 用户可感知的比率,如成功率、p99 达标率 | 用 CPU 使用率当 SLI |
| 目标值 | 按历史水位与容忍度定,不是拍 99.99% | 全线统一 99.99%,成本失控 |
| 统计窗口 | 滚动窗口(28 天)比自然月稳 | 月末归零,行为被扭曲 |
| 错误预算 | 1 - 目标值 允许的失败额度 |
只算不控,烧完没人停发布 |
slo:
name: order_api_availability
sli: | # 成功率 = 非 5xx / 总请求
sum(rate(http_requests_total{route="/api/orders",status!~"5.."}[5m]))
/ sum(rate(http_requests_total{route="/api/orders"}[5m]))
target: 0.999 # 按业务容忍度核对,不要照抄
window: 28d
burn_rate_alerts: # 多窗口燃烧率:短窗口灵敏,长窗口稳
- {window: 1h, factor: 14.4}
- {window: 6h, factor: 6}
错误预算的用途是决策依据:预算充足可提高发布频率,烧得快就冻结变更——把"要不要发"从争论变成算术。
8. 告警设计
| 原则 | 做法 | 反例 |
|---|---|---|
| 按症状告警 | 告警"下单成功率下降",不告警"CPU 高" | CPU 高但服务正常 → 无效告警 |
| 分级 | P1 用户可感知中断 / P2 部分降级 / P3 趋势预警 | 全部同一个通道,全部 @所有人 |
| 可执行 | 每条告警附"下一步查什么"的链接或命令 | 告警只说"异常",值班人从头查 |
| 抑制与聚合 | 同一根因的多条告警合并 | 一次 DB 抖动触发 200 条告警 |
疲劳的机制很直接:误报率高 → 值班人开始忽略 → 真告警一起被忽略。宁可少几条,也要让每条都指向真实症状。
9. 成本控制
| 成本项 | 膨胀机制 | 控制手段 |
|---|---|---|
| 日志量 | 每个请求多条 + 无采样 + 长字段 | 结构化 + 分级采样 + 字段长度上限 |
| 指标基数 | 标签用了高基数字段(userId、orderId、URL 全路径) | 只打低基数维度;路径做模板化(/orders/:id) |
| 追踪存储 | 采样率过高 + span 属性过多 | 尾部采样 + 限制属性数量 |
基数爆炸是最常见的自伤:给指标加 userId 标签,一百万用户就多一百万条时间序列,写入与查询同时崩。判定规则只有一条:这个标签的取值数量是否可控(几百以内)。
10. 排错:五种"有数据但查不出"
| 现象 | 原因 | 处理 |
|---|---|---|
| 日志搜不到某次请求 | 没有 traceId 或没透传到下游 |
入口生成 + 拦截器统一注入 |
| 指标面板上延迟"看起来正常" | 用的是平均值 | 换成 histogram 分位 |
| 两个服务的时间对不上 | 时钟未同步、时区混用 | 统一 UTC + NTP,日志存 ISO8601 带时区 |
| trace 断在某个服务 | 该服务没接上下文传播 | 检查 HTTP 客户端是否注入 traceparent |
| 告警一直响但看不出问题 | 告警按原因而非症状 | 改告警对象为错误率/成功率 |
11. 边界:可观测性不替代什么
| 问题 | 可观测性能给答案吗 | 该用什么 |
|---|---|---|
| 某段代码为什么这么慢(算法级) | 只能定位到函数 | profiler |
| 数据不一致的根因 | 部分(靠日志比对) | 数据校验任务、审计表 |
| 用户主观体验 | 间接(延迟/成功率) | 前端 RUM + 真实用户反馈 |
一句话:可观测性回答"发生了什么、在哪一段",不回答"这段代码为什么这样写"。
12. 自测
- 只有指标没有追踪,排查一次具体请求的失败会卡在哪一步?
- 为什么延迟必须用分位数而不是平均值?分位数为什么又不能互相做算术?
Summary类型为什么不能跨实例聚合?- 固定比例采样在什么情况下会漏掉最该看的请求?尾部采样怎么补?
- 一条告警应该按"症状"还是"原因"设计?举一个反例说明后果。
- 给指标加
userId标签会发生什么?判定一个标签是否安全的规则是什么?