问题场景 + 需求约束
设计一个支撑 1000+ 微服务、每秒百万 span 的可观测性平台。某天上午 p99 延迟从 80ms 飙到 2s,用户开始投诉——你要在 5 分钟内定位到是哪个服务、哪个依赖、哪类请求出了问题。这不是『加几个监控图』,而是一个数据规模堪比业务本身的系统。
监控(monitoring)回答『系统是否健康』(已知问题),可观测性(observability)回答『为什么不健康』(未知问题)。前者是预设好的 dashboard 和告警,后者是事故现场任意切片、下钻的能力。
- 数据规模:百万 span/s,单 trace 几十到几百 span;metrics 时间序列千万级 active series;日志 TB/天。
- 延迟 SLO:告警端到端 < 1 分钟;query p95 < 2s(事故中工程师等不起)。
- 成本约束:可观测性账单常占基础设施 10-30%,全量存 trace 不可行 → 采样是核心设计。
- 留存:metrics 高分辨率存几天、降采样存一年;trace 存几天;日志按合规要求。
- 基数(cardinality):metric label 的笛卡尔积,决定存储成本上不上天——这是最容易爆炸的维度。
高层架构
graph LR
APP["服务 + OTel SDK
埋点 / context 传播"]
COL["OTel Collector
批处理 · 采样 · 路由"]
M["Metrics TSDB
Prometheus / Mimir"]
T["Trace 存储
Tempo / Jaeger"]
L["日志存储
Loki / ES"]
Q["查询 + 关联层
Grafana"]
A["告警引擎
Alertmanager"]
OC["On-call
PagerDuty"]
APP -->|OTLP| COL
COL --> M
COL --> T
COL --> L
M --> Q
T --> Q
L --> Q
M --> A -->|burn rate 超限| OC
classDef svc fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef pipe fill:#0e2030,stroke:#5eead4,color:#e8eef5
classDef store fill:#1a1a30,stroke:#ffb450,color:#e8eef5
classDef sink fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
class APP svc
class COL,Q pipe
class M,T,L store
class A,OC sink
SDK 统一埋点 → Collector 做采样/路由解耦应用与后端 → 三种信号靠 trace_id / exemplar 互相跳转
关键设计:应用只依赖 OTel SDK + Collector,不绑定任何后端厂商(避免 vendor lock-in)。Collector 是控制点——采样、限流、PII 脱敏、降采样都在这里做,改采样策略不用重新发布几百个服务。
关键技术点
1. 三支柱 Metrics / Logs / Traces — 不是三个工具,而是一份数据的三种切片
原理:Metrics 是预聚合的数值时间序列(counter/gauge/histogram),便宜、适合 dashboard 与告警,但只有你预先定义的维度。Logs 是离散事件,信息最全但检索贵、无结构难关联。Traces 把一个请求跨服务的因果链串成一棵 span 树,回答『慢在哪一跳』。三者的本质区别是基数承受力:metric 加一个高基数 label(如 user_id)会让时间序列数爆炸成百万级;trace/structured-log 天生适合高基数。
Trade-off:
- Metrics:✅ 存储 O(序列数) 与请求量无关、查询毫秒级;❌ 维度固定、无法事后下钻到单请求、高基数即破产。
- Logs:✅ 信息最全、灵活;❌ 成本随流量线性涨、跨服务难关联(除非带 trace_id)。
- Traces:✅ 因果链 + 跨服务延迟归因;❌ 必须采样(全量太贵)、埋点侵入、采样后看不到稀有请求。
正确姿势:
用 metric 发现异常 → 用 exemplar 跳到一条代表性 trace → 用 trace_id 拉出该请求全部日志。三支柱靠
trace_id 缝合,而不是三个割裂的孤岛。
现实案例:
- Honeycomb / Charity Majors:公开批判『three pillars』——把数据存三遍、人工跨工具关联是反模式,主张用任意宽的结构化事件(arbitrarily-wide events)做单一真相源,高基数才是可观测性的灵魂(Observability 2.0)。
- Prometheus + Grafana:metrics 用 exemplar 直接挂 trace_id,dashboard 一键跳 Tempo trace,是开源界的事实标准缝合方案。
- Google:内部 Monarch(metrics)+ Dapper(trace)分工,Borgmon 是 Prometheus 的思想源头。
2. 分布式追踪与 OpenTelemetry — context 传播 + 采样的两难
原理:一个请求经过 A→B→C,要把它们的 span 归到同一棵树,靠 context propagation:入口生成 trace_id,每跳生成 span_id 并记录 parent_id,通过 HTTP header(W3C traceparent)往下游传。OpenTelemetry(OTel)统一了这套 API/SDK/协议(OTLP),让埋点与后端解耦。核心难题是采样:百万 span/s 全存会破产,但采样又可能漏掉出错的那条。
Head sampling vs Tail sampling:
- Head(头部采样):入口处随机决定『这条 trace 采不采』(如 1%)。✅ 简单、无状态、开销低(Dapper 高流量服务甚至采到 0.01%);❌ 决策时还不知道这条会不会出错/变慢,错误请求大概率被丢。
- Tail(尾部采样):先 buffer 整条 trace 的所有 span,等结束后看『有没有 error / 是否 > 1s』再决定留不留。✅ 100% 留住慢/错请求;❌ Collector 要按 trace_id 聚齐所有 span(有状态、需一致性哈希路由)、内存压力大、延迟高。
- 折中:低固定 head sample 保底 + tail 规则『错误和慢请求 100% 留』。
# OTel Collector tail_sampling 策略 (pseudo-config)
processors:
tail_sampling:
decision_wait: 10s # 等一条 trace 的 span 聚齐
policies:
- type: status_code # 出错的全留
status_code: {status_codes: [ERROR]}
- type: latency # 慢的全留
latency: {threshold_ms: 1000}
- type: probabilistic # 其余按 1% 采
probabilistic: {sampling_percentage: 1}
# ⚠️ 多个 Collector 实例时, 同一 trace_id 的 span 必须路由到同一实例
# (load-balancing exporter 按 trace_id 哈希), 否则聚不齐
现实案例:
- Google Dapper(2010 论文,Sigelman 等):分布式追踪的开山之作,高流量服务采样率低到 0.01% 仍不影响分析;后来催生了 OpenTracing/OpenTelemetry。论文
- OpenTelemetry:CNCF 项目,由 OpenTracing + OpenCensus 合并,已成跨语言埋点事实标准,被 AWS / Azure / Datadog 等全面支持。
- Uber Jaeger:大规模生产追踪系统,开源后进 CNCF,自适应采样按服务流量动态调整每秒采样配额。
3. SLI / SLO / SLA 与 Error Budget — 把『可靠性』变成可决策的预算
原理:SLI(指标)= 好请求 / 总请求,如『延迟 < 200ms 的请求占比』。SLO(目标)= 对 SLI 设的内部门槛,如『99.9% 月度』。SLA(协议)= 对外承诺 + 违约赔偿,通常比 SLO 松(留缓冲)。关键发明是 error budget(错误预算)= 1 − SLO:99.9% 意味着一个月可以『坏』43 分钟。这把可靠性从『越高越好』的口水仗,变成一个可以花的预算——预算没花完就允许大胆发版(创新),花超了就冻结发布专心修稳定性。它对齐了产品团队和 SRE 的激励。
告警怎么用 error budget:传统阈值 vs burn rate
- 静态阈值告警(如 error rate > 1%):要么太敏感半夜狂响(alert fatigue),要么太迟钝。
- Burn rate 告警(Google SRE 推荐):监控『当前消耗错误预算的速度是预算允许速度的几倍』。多窗口多燃烧率:快烧(1 小时烧掉月预算 2%,14.4x)= 立即 page;慢烧(6 小时持续渗漏)= 开 ticket。既抓住灾难、又不被毛刺骚扰。
# 多窗口 burn rate 告警 (PromQL 思路)
# 快窗 1h 与慢窗 5m 同时超 14.4x 才 page (防抖)
(
error_budget_burn_rate{window="1h"} > 14.4
and
error_budget_burn_rate{window="5m"} > 14.4
)
# burn_rate = (1 - SLI) / (1 - SLO)
# = 实际错误率 / 预算允许错误率; >1 表示在透支预算
现实案例:
- Google SRE:error budget 是《SRE Book》核心思想——『100% 不是正确的可靠性目标』,SRE Workbook 的 Alerting on SLOs 详述多窗口多燃烧率告警公式。
- 各大厂 status page:对外 SLA(如『99.95% 或退款』)普遍比内部 SLO 松一档,给运维留 buffer。
4. Metrics 聚合陷阱 — 为什么 p99 不能再求平均
原理:百分位数(percentile)不可二次聚合。十台机器各自的 p99 求平均,得不到全局 p99——这是数学上错的。正确做法:客户端用 histogram(按 bucket 累计计数),把各机器的 bucket 计数相加后再用 histogram_quantile() 在查询时算分位数。summary 类型在客户端就把分位数算死了,跨实例无法合并。这也是 Prometheus 官方推荐 histogram > summary 的核心原因。
Histogram vs Summary:
- Histogram:✅ bucket 可跨实例相加、可任意时间窗算任意分位;❌ 精度取决于 bucket 边界、bucket 多则序列数涨。
- Summary:✅ 客户端算精确分位、查询便宜;❌ 不可聚合、分位数和窗口写死在埋点。
- 关键 metric 设计:盯 RED(Rate / Errors / Duration,面向请求)或 USE(Utilization / Saturation / Errors,面向资源)。永远看分位数不看均值——均值会被掩盖,p50 正常但 p99 爆炸是常态。
现实案例:
- Prometheus 官方文档:明确警告『aggregating percentiles is meaningless』,给出 histogram +
histogram_quantile() 才能跨实例算全局分位(Histograms and summaries)。
- Prometheus Native Histogram:新一代稀疏直方图,bucket 自动按指数分布,解决了传统固定 bucket 的精度/成本两难。
扩展与优化
- 基数治理:metric label 上线前评审;用 Collector 在写入前 drop/relabel 高基数 label,把 user_id 这类放进 trace/log 而非 metric。
- 降采样(downsampling):原始 15s 分辨率存几天,5m/1h 聚合存一年(Thanos / Mimir / VictoriaMetrics)。冷数据下沉对象存储省钱。
- 从 metric 跳 trace:exemplar 把代表性 trace_id 嵌进 histogram bucket,dashboard 看到 p99 尖刺一键下钻到那条慢 trace。
- 多区/多租户:每区独立采集、全局聚合视图;多租户要做查询隔离与配额,防一个团队的烂查询拖垮共享 TSDB。
- eBPF 自动埋点:无侵入采集系统调用/网络延迟(Pixie / Cilium),补足代码埋点盲区。
常见陷阱 + 面试追问
1. 看均值不看分位:平均延迟 80ms 很美好,但 p99 是 2s——1% 的用户在受苦,而恰恰是高价值重度用户。永远监控分位数。
2. 高基数 label 炸 TSDB:给 metric 加 user_id / request_id / 完整 URL,时间序列从几千暴涨到几百万,Prometheus OOM。高基数维度属于 trace/log。
3. head sampling 丢了出错请求:1% 头部采样下,偶发的 0.1% 错误请求几乎采不到,事故时无 trace 可看。错误/慢请求要 tail sampling 兜底。
4. 告警疲劳:几百条静态阈值告警,80% 是噪音 → on-call 麻木 → 真事故被淹没。改 SLO burn-rate 告警 + 严格区分 page(要人立刻起床)与 ticket。
5. 可观测性系统自己挂了:监控和被监控系统共用基础设施,故障时监控也瞎。可观测性栈要独立部署、独立故障域,并有外部黑盒探测兜底。
高频面试题:① metrics / logs / traces 各解决什么、为什么不能只用一个?② head vs tail sampling 的取舍,tail 怎么保证同 trace 的 span 聚齐?③ SLO / error budget 怎么定,burn-rate 多窗口告警为什么比静态阈值好?④ 为什么 p99 不能跨机器求平均?histogram 和 summary 怎么选?⑤ 怎么控制可观测性成本(采样 + 基数 + 降采样三板斧)?
深入资源
- 《Site Reliability Engineering》& 《SRE Workbook》(Google,免费在线 sre.google):SLI/SLO/error budget 与 burn-rate 告警的权威来源。
- 《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》(Google,2010):分布式追踪与采样的奠基论文。
- Prometheus 官方文档 — Histograms and summaries:分位数聚合陷阱与 histogram 选型。
- Honeycomb 博客 / Charity Majors(charity.wtf):observability 2.0、高基数、对 three pillars 的批判。
- 《Designing Data-Intensive Applications》(Kleppmann)第 1 章:可靠性/可维护性中关于监控可观测性的讨论。
深入思考(点击展开答案)
1. 一个 metric 加 user_id label 看似方便,为什么是灾难?算一笔账:1000 万用户 × 10 个其他 label 维度会怎样?
TSDB 的存储成本正比于活跃时间序列数(active series),而非请求量。每个唯一的 label 组合 = 一条独立序列。
- 不加 user_id:假设 5 个 endpoint × 4 个 status × 3 个 region = 60 条序列,毫无压力。
- 加 user_id:60 × 1000 万 = 6 亿条序列。每条序列内存几 KB(标签 + 最近样本 + 索引),直接几 TB 内存 → Prometheus OOM 崩溃。
本质:metric 适合『有界、低基数』维度(枚举型)。user_id、request_id、完整 URL、错误堆栈是『无界、高基数』,属于 trace 和结构化日志——它们的成本随事件而非维度笛卡尔积增长。这正是 Honeycomb 主张『宽事件』的理由:高基数查询是调试的核心能力,但要放对存储。治理手段:上线前评审 label、Collector 端 relabel drop、对疑似高基数维度做 cardinality 监控告警。
2. Tail sampling 要等一条 trace 的所有 span 聚齐再决策。在多个 Collector 实例的集群里,这引入了什么棘手的分布式问题?
核心矛盾:同一条 trace 的 span 来自不同服务、不同时间到达,可能落到不同的 Collector 实例。要做 tail 决策必须把它们聚到同一处。
- 路由一致性:需要一层 load-balancing exporter 按
trace_id 哈希,保证同 trace 永远进同一个后端 Collector。实例扩缩容时哈希环变化会导致迁移期 span 错配(类似 Day 4 的 resharding 问题)。
- 状态与内存:Collector 要 buffer 住未决策的 span(decision_wait 窗口,如 10s)。流量大时这是巨大内存压力,且 span 迟到/丢失会让 trace 残缺。
- 决策延迟:必须等够窗口才知道留不留,trace 落库有固有延迟,实时性差于 head。
- 窗口外的尾巴:超长 trace(> decision_wait)的尾部 span 会被当成孤儿,要么误丢要么单独处理。
这就是为什么很多团队用『head 低采样保底 + tail 只对 error/slow 兜底』的混合:把 tail 的状态压力限制在少量值得保留的 trace 上。
3. SLO 定 99.9% 还是 99.99%?多一个 9 的真实代价是什么?为什么『越高越好』是错的?
每多一个 9,允许的月度停机从 43 分钟(99.9%)压到 4.3 分钟(99.99%)再到 26 秒(99.999%)。代价不是线性而是指数级:
- 架构成本:4 个 9 要多区冗余、自动 failover;5 个 9 要无单点、跨区强一致——成本可能翻几倍。
- 速度代价:error budget 越小,能用来发版/实验的空间越小,创新被锁死。99.999% 意味着几乎不能停机部署。
- 边际收益递减:用户和下游依赖往往感知不到比『他们自己的可靠性 + 网络抖动』更高的 9。如果用户的网络本身只有 99.9%,你做到 99.999% 是白烧钱。
所以 SLO 应该从业务/用户体验反推『多可靠才够』,而不是工程师追求完美。100% 是错误目标:它消灭了 error budget,等于禁止一切变更,反而因无法迭代而更脆弱。正确的 SLO 是『刚好让用户不抱怨』的那条线,把剩下的预算花在发布速度上。
4. 串联 Day 6(一致性):metrics 本身是『最终一致 + 有损』的。这对告警意味着什么风险?
监控数据本身就不是强一致、不是精确的,这条经常被忽略却很致命:
- 采集延迟:Prometheus 按 scrape interval(如 15s)拉取,告警评估又有自己的周期,端到端可能滞后 30s-1min。说『1 分钟内告警』时这部分要算进去。
- 采样有损:trace 采样后,稀有事件统计天然失真;基于采样数据估算错误率要乘回采样率,且小样本方差大。
- 聚合丢信息:metric 是预聚合的,p99 histogram 的精度受 bucket 边界限制,边界设错会系统性高估/低估。
- 抓取缺口:target 短暂不可达 = 数据空洞,告警规则要区分『指标是 0』和『指标缺失』(用
absent()),否则服务全挂时反而因为没数据而不告警。
设计含义:告警要容忍数据的最终一致性——用多窗口(短窗抓急性、长窗去抖)、设置 for 持续时间避免单点毛刺、对『监控数据本身缺失』单独告警。永远别假设 dashboard 上的数字是当下精确真相。
5. 事故中,从『p99 报警』到『定位根因服务』,理想的下钻路径是什么?三支柱在这条链上各自扮演什么角色?
这是可观测性价值的终极检验——能不能把 MTTR(平均修复时间)压到几分钟。理想链路:
- Metric 发现(What):burn-rate 告警触发,RED dashboard 显示某 endpoint p99 从 80ms→2s,error rate 上升。确认『有问题、影响多大、何时开始』。
- Metric 缩小范围(Where):按 region/version/dependency 维度切分 metric,发现只有 v2.3 + us-east 的请求受影响 → 怀疑某次发布或某区依赖。
- Exemplar 跳 Trace(Why-1):从 p99 那个 bucket 的 exemplar 一键打开一条代表性慢 trace,span 树立刻显示『时间花在 service-C 调 DB 的那一跳,占了 1.8s』——根因服务锁定。
- Trace 拉 Log(Why-2):用该 trace 的 trace_id 过滤日志,看到 service-C 的 DB 连接池耗尽报错堆栈,确认机理。
角色分工:metric=雷达(便宜、全覆盖、发现异常),trace=GPS(因果定位到哪一跳),log=显微镜(单点细节)。三者靠 trace_id/exemplar 缝合;缺任何一环,下钻就断链,只能靠人肉猜——这正是『三个割裂工具』vs『真正可观测性』的差别。