跳转到内容
← 返回系统剖析
系统剖析当代33 分钟阅读

Prometheus:拉模型与时序数据的代价

Prometheus: The Pull Model and the Price of Time Series

2012 年 11 月 24 日,Prometheus 仓库收到第一个公开提交。写下它的 Matt T. Proud 和 Julius Volz 刚刚离开 Google 加入 SoundCloud,手里带着一套用惯了的东西的记忆——Borgmon,Google 内部那个基于时间序列做告警的监控系统。SoundCloud…

Prometheus拉模型时序数据库基数爆炸PromQL

2012 年 11 月 24 日,Prometheus 仓库收到第一个公开提交。写下它的 Matt T. Proud 和 Julius Volz 刚刚离开 Google 加入 SoundCloud,手里带着一套用惯了的东西的记忆——Borgmon,Google 内部那个基于时间序列做告警的监控系统。SoundCloud 遇到的麻烦很具体:机器名不再稳定,服务被调度来调度去,而当时主流的监控工具还在假设"一台主机对应一份配置"。

十几年后,Prometheus 成了 CNCF 继 Kubernetes 之后接纳的第二个项目(2016 年 5 月),也是继 Kubernetes 之后第二个毕业的项目(2018 年 8 月 9 日)。2017 年 11 月的 2.0 换掉了整个存储引擎,2024 年 11 月 14 日的 3.0 是七年来第一个大版本。

它的所有设计——为什么拉、为什么这么存、为什么查询语言长这样、为什么坚决不做某些事——都可以从一句判断推出来:监控系统必须比它监控的东西更简单、更可靠。 这句话听起来像口号,但它每一次被兑现,都意味着放弃了某样看起来很有价值的东西。

破除误解

误解一:拉模型要主动去连每一个目标,所以扩展性天然不如推。

方向不重要,量才重要。Julius Volz 在 2016 年 7 月 23 日的官方博客里直接回应过这个说法:谁发起那条 TCP 连接完全无所谓,建连的开销与随后处理数据的开销相比可以忽略;真正的瓶颈从来不是"拉",而是服务器每秒能吞进多少样本。他当时提到的记录是单台服务器每秒摄入 80 万个样本、存储数百万条序列。

拉和推的真实差别不在网络,在谁掌握名单。推模型里,每个被监控实例必须知道采集端的地址;拉模型里,采集端必须知道所有目标的地址。两边都需要一份名单,区别只是这份名单放在一处还是散在几千处。

误解二:Prometheus 是时序数据库,所以可以当成数据仓库长期存下去。

本地存储是单机的,没有副本,没有集群,默认只保留 15 天。官方文档把边界写得很直白:Prometheus 重视可靠性,你在任何故障条件下都能看到关于系统的统计数据;但如果你需要 100% 的准确,比如按请求计费,Prometheus 不是好选择,因为采集到的数据不会足够详细和完整。它被设计成"别的东西全挂了它还活着"的那一个,代价就是它拿到的是采样、是近似、是可以丢的。

误解三:多加一个标签只是多一个维度,成本是线性的。

成本是乘法,而且落在内存里。指标名加上标签的每一种取值组合,都是一条独立的时间序列,各自持有一个正在写入的内存 chunk、一份标签集合、若干索引项。给一个已经有 3 万条序列的指标加上一个有 200 种取值的标签,得到的不是 3 万加 200,是 600 万。

更麻烦的是这笔账不在你加标签的那个仪表盘上结算。它在某个凌晨,以 Prometheus 进程被内核 OOM 杀掉的形式结算。

拉还是推:被决定的其实是"谁掌握名单"

先把两种模型的差别摊开。下面这张表里,每一行都是真实的工程后果,不是偏好问题。

问题拉(Prometheus 抓取 /metrics)推(客户端主动上报)
谁需要知道对方地址采集端需要一份目标名单每个实例都要配置采集端地址
"本该在却不见了"名单里有、抓不到 → up 为 0无法区分"死了"与"没数据可报"
配置变更落在哪采集端一处(服务发现自动更新)每一个被监控实例
反压方向采集端决定频率,天然限流目标端决定,接收端被动承压
跨 NAT / 防火墙需要反向可达,常成障碍出站连接,天然穿透
秒级生命周期的任务抓不到,需要 Pushgateway 或 agent天然适合
本地调试浏览器直接打开目标的 /metrics需要先搭一个接收端

服务发现才是这个模型的心脏

既然拉模型的前提是"名单",那么名单从哪来就成了系统的中心问题。Prometheus 内置了一大批服务发现机制:Kubernetes、Consul、EC2、Azure、DNS SRV 记录,以及最朴素的 file_sd——读一个 JSON 文件,文件变了就重载。

发现出来的每个目标先带着一组以 __ 开头的元标签(比如 __address____meta_kubernetes_pod_label_app),然后经过 relabel_configs 这条重写流水线:决定这个目标要不要保留、抓哪个路径、最终打上什么标签。抓取之后、写入存储之前,还有第二条流水线 metric_relabel_configs,可以丢弃指标或删掉标签——它是后面讲基数问题时最重要的一道闸门。

目标健康本身就是数据

有了名单,"抓不到"才成为一个可以表达的事实。每次抓取结束,Prometheus 会自动为这个目标写入几条序列:

  • up:这次抓取成功了没有,1 或 0。
  • scrape_duration_seconds:这次抓取花了多久。
  • scrape_samples_scraped:目标一共暴露了多少个样本。
  • scrape_samples_post_metric_relabeling:经过过滤之后还剩多少。
  • scrape_series_added:这次抓取里出现了多少条以前没见过的新序列。

于是 up == 0 就是一条最基本的告警规则,而它在推模型里根本写不出来——没有名单,"没收到数据"和"这个东西压根不存在"是同一件事。最后那条 scrape_series_added 更是直接为诊断基数问题准备的:它测量的是"序列翻新速度",后面会解释为什么这个数比总序列数更能预示事故。

为什么丢一次抓取不致命

这是理解 Prometheus 最关键的一步,也是最少被讲清楚的一步。拉模型之所以可行,是因为数据模型是累计值而不是增量。

Prometheus 的计数器(counter)只增不减,暴露的永远是进程启动以来的累计总数,而不是"自从上次上报以来新增了多少"。所以某一次抓取失败,信息并没有丢——下一次抓到的那个值里已经包含了这期间发生的一切,丢掉的只是时间分辨率。仪表盘上少了一个点,总量仍然对得上。

如果协议改成上报增量,情况就完全不同:漏掉的那一份是永久性的损失,采集端必须引入确认、重传、去重,也就是必须变成一个可靠消息系统。那正是 Prometheus 拒绝承担的复杂度。"拉"和"累计计数器"是同一个设计的两面,不是两个独立选择。

拉付出的代价:它永远看不见单次事件

抓取间隔通常是 15 秒或 30 秒。这意味着 Prometheus 从来没有见过任何一次具体的请求,它看到的只是每隔 15 秒的一张状态快照。一次持续三秒、随后自愈的延迟尖峰,在计数器上仍然留下痕迹(总量增加了),但在图上可能只表现为一段几乎看不出来的抬升。而"第 4173 号请求为什么失败了"这类问题,它连回答的形式都没有——那是日志与链路追踪的领域。

什么时候拉真的不适用

最硬的一个场景是短生命周期任务。一个每小时跑一次、执行 8 秒的备份脚本,在任何抓取周期里都不存在,服务发现发现不了它,抓取抓不到它。

官方给的出口是 Pushgateway,但文档对它的措辞相当克制:通常唯一正当的用途,是记录 service-level 批处理任务的结果——那种在语义上不属于某台机器、某个实例的任务,比如"为整个服务清理了多少个失效用户"。它的四条陷阱都写在同一页文档里:

  • 它不是聚合器,是缓存。 多个实例推送同一组标签会互相覆盖,最后一次写入赢。想统计"所有实例一共处理了多少",Pushgateway 帮不了你。
  • 推上去的指标不会自己消失。 任务再也不跑了,Pushgateway 依然把最后一次成功的数值原样暴露,Prometheus 继续抓到它,图上一切正常——直到有人调用 DELETE 接口把它清掉。这是一种主动制造的"岁月静好"假象。
  • up 的含义被偷换了。 Prometheus 抓的是 Pushgateway,不是任务。up 为 1 只说明网关活着。补救办法是让任务自己上报 my_batch_last_success_timestamp_seconds,然后用 time() - my_batch_last_success_timestamp_seconds > 3600 这样的规则报警——把"多久没成功"变回一个可测量的量。
  • 它是单点。 一个网关代理几十个任务,它挂了这些任务全部失明。

另一个出口是 agent 模式(2.32 引入,2021 年 11 月;3.0 起变成 --agent 命令行参数)。它的思路是:在边缘仍然用拉,抓完之后不建本地查询索引,直接 remote write 推到中心。推留给服务器之间,拉留给服务器与目标之间——这个分工避免了让几万个应用进程各自持有中心地址。

时序数据的形状,决定了 TSDB 的形状

要理解 Prometheus 2.0 那套存储引擎(由 Fabian Reinartz 主导重写),先看清这类负载的形状有多特殊。

写入:每到一个抓取周期,一大批样本同时到达,每条序列恰好一个新样本,时间戳几乎都是"现在"。没有随机位置的插入,没有原地更新,历史数据一旦写下就再也不改。

读取:给我这几条(或几百条)序列在某个时间区间里的所有点,按时间顺序。

删除:唯一的删除形式是"扔掉比 X 更早的一切"。

这套形状与 OLTP 数据库假设的随机读写几乎完全相反,所以 B 树那种为原地更新和任意键查找优化的结构在这里是浪费。Prometheus 的答案是:内存里的可写部分 + 只追加的日志 + 磁盘上一批不可变的块。

data/
├── wal/
│   ├── 00000271           ← 预写日志,128 MB 一段
│   ├── 00000272
│   └── checkpoint.000270/ ← 定期检查点,压掉已不需要的记录
├── chunks_head/
│   └── 000014             ← head 里写满封口的 chunk,mmap 到磁盘
└── 01HQ8ZR7F2K3XQ.../     ← 一个持久块,ULID 命名,默认覆盖 2 小时
    ├── chunks/000001      ← 压缩后的样本,段文件上限 512 MB
    ├── index              ← 倒排索引:标签对 → 序列 ID → chunk 位置
    ├── meta.json          ← 时间范围、序列数、由哪些块合并而来
    └── tombstones         ← 删除标记(不原地改数据)
```

head 与 WAL

新样本先进入内存中的 head 块,同时逐条追加进 WAL(预写日志)。WAL 没有任何查询价值,它只为一件事存在:进程崩溃后重放它,把 head 重建出来。

这条设计有一个直接的运维后果:Prometheus 被 OOM 杀掉之后不会立刻恢复。 序列越多,WAL 越长,重放越久。也就是说,基数问题导致的崩溃会自带一段延长的失明期。

每条活跃序列在内存里持有一个正在写的 head chunk。写满 120 个样本、或者跨过 chunk 的时间范围,这个 chunk 就封口,被 mmap 到 chunks_head 目录,内存里只留一个引用(2.19 起的行为)。head 攒够 2 小时,整体压成一个不可变的持久块落盘,对应的 WAL 随之截断。

压缩与过期

相邻的小块会被后台合并成更大的块,上限是保留时长的 10%——默认保留 15 天,因此最大块约 1.5 天。合并的收益不只是文件变少:同一条序列在多个块的索引里各有一份记录,合并之后并成一份。

过期数据的删除是整块删目录,不是逐行 DELETE。时序数据天然按时间聚簇,过期的永远是一整段连续时间,所以保留策略的实现成本接近于零。这是关系型数据库很难白拿的好处。

两种编码:delta-of-delta 与 XOR

一个样本裸着是 16 字节:int64 的毫秒时间戳加 float64 的值。Prometheus 官方文档说平均每样本 1 到 2 字节,这套编码的源头——Facebook 在 2015 年 VLDB 发表的 Gorilla 论文——测得的平均值是 1.37 字节。压缩比大约十倍以上,而且靠的是两个非常朴素的观察。

时间戳用二阶差分(delta-of-delta)。 抓取节奏是固定的,假设间隔 15 秒,时间戳(毫秒)序列是 …, 1749000000000, 1749000015000, 1749000030000, …,一阶差恒为 15000,二阶差恒为 0。而 0 在 Gorilla 的变长编码里只占一个比特。真实抓取会抖动几毫秒,于是二阶差落进分级的区间:小偏差用 7 比特,再大用 9 比特、12 比特,实在跳得离谱才退回 32 比特。

值用异或(XOR)。 相邻两个 float64 通常符号位、指数位和高位尾数完全相同——CPU 使用率从 0.41 变成 0.42,二进制表示的高位几乎没动。把新值和旧值异或,结果高位全是 0,只需要记下"有多少前导零、有多少有效位",再存中间那一小段。值完全没变时异或结果为 0,只花一个比特,所以 up、版本信息这类常量型序列几乎不占空间。

这两个技巧都是押注,因此都有失效场景:

编码押的是什么什么时候退化
delta-of-delta采样节奏规律抓取间隔随机漂移、目标频繁超时重试
XOR相邻值数量级接近数值在量级之间乱跳(比如一个哈希值、一个随机数)

还有一层更深的代价:chunk 是流式编码的位串,没有对齐、没有偏移表。要读第 57 个样本,必须从头解到第 57 个。这换来了体积,也意味着任何查询的最小单位是"整个 chunk 解一遍"。查询成本因此不与"你要的样本数"成正比,而与"碰到的序列数 × chunk 数"成正比。

索引:倒排表

index 文件里存的是倒排索引。对每一个"标签名=标签值"对,维护一个排好序的序列 ID 列表(postings)。查询 http_requests_total{job="api",status="500"} 就是取三个 postings 求交集,再按结果去找 chunk。

所以查询代价的第一驱动因素是候选序列的数量,不是时间范围的长度。正则匹配尤其危险:一个匹配面很宽的正则会把该标签的所有取值全部展开成候选集。

基数爆炸:这个系统最常见的事故

序列 = 指标名 + 全部标签的一种取值组合。改一个标签值就是另一条序列。这句话人人会背,事故照样年年发生,因为直觉总把它当加法,而它是乘法。

一个普通的 HTTP 指标:

http_requests_total{method, status, handler, instance}
      5 种方法 × 8 种状态码 × 150 个 handler × 60 个实例 = 360,000 条序列
```

一个指标名,36 万条序列。这时有人提出"按客户拆分会很有用",加上一个 1000 取值的 customer_id——3.6 亿。官方文档的措辞是:不要用标签存高基数的维度,比如用户 ID、邮箱地址,或者其他无界的取值集合。

为什么后果这么重

因为 head 在内存里。每条活跃序列都要一个正在写的 chunk、一份常驻的标签集合、若干索引项。基数增长不是"磁盘涨得快",是"内存涨得快",而内存耗尽在 Linux 上的处理方式是进程被杀。

最恶劣的是时机。 基数在故障期间会自己涨:平时只出现 200 的 status 标签冒出 500、502、504;平时稳定的路径标签突然多出一批错误路由;重试和超时把从没出现过的组合造了出来。也就是说,监控系统最容易在最需要它的时刻被自己压垮,然后还要花一段时间重放 WAL 才能回来。

churn 比静态基数更阴险

一次滚动发布把所有 Pod 换了名字,instance 标签整体换一批,旧序列并不会立刻消失——它们要等 head 的时间窗口滑过去,而且会留在每一个覆盖过它们的块的索引里,直到那些块过期。一个每天发布 20 次的集群,head 里的"活跃"序列数会显著超过任何一个瞬间真实存在的实例数。

判断一个系统是否在 churn,看 scrape_series_added:它记录每次抓取里有多少条前所未见的新序列。健康系统这个数长期接近 0,持续为正就说明标签里混进了会变的东西。

防线,以及每条防线放弃了什么

手段作用代价
sample_limit单次抓取样本数超限则整次失败该目标全部指标丢失,包括本来正常的那些
label_limit / label_value_length_limit(2.27 起)限制单条序列的标签数量与长度同样是整次抓取失败
target_limit(2.21 起)限制单个 job 发现的目标总数超出的目标直接不被监控
metric_relabel_configs入库前丢弃指标或删掉标签需要人预先知道哪个标签会失控
/api/v1/status/tsdb列出基数最高的指标名与标签事后诊断,不阻止事故

sample_limit 的设计值得单独说一句:超限时它让整次抓取失败、目标的 up 变成 0,而不是悄悄截断。这是刻意选择的"响亮地失败"——宁可让一个目标显式地报错,也不让它把整台服务器拖垮。配套的自监控指标是 prometheus_target_scrapes_exceeded_sample_limit_total,应该对它设告警,而不是等事故中才发现。

结构性的修复:原生直方图

经典直方图是基数问题的一个大户。它把每一个桶实现成一条独立序列(用 le 标签区分),再加上 _sum_count。一个 10 桶的延迟直方图配上 150 个 handler,就是 1800 条序列——只为了一个"延迟"。

原生直方图(native histogram)换了个思路:整个直方图压成一条序列里的一个稀疏指数桶结构,没有观测的桶几乎不占空间,桶边界还能随数据分布自动调整。它把"桶数 × 标签组合"这个乘法直接拆掉,而不是劝人少加标签。这个特性从 2.40(2022 年 11 月)起以实验形式提供,到 3.0 仍需 --enable-feature=native-histograms 才能打开——文本暴露格式与相关函数的设计还在演进。

PromQL:向量语义,不是表

PromQL 的表达式只有四种类型:瞬时向量(每条匹配序列在某一时刻的一个样本)、区间向量(每条匹配序列在一段时间里的一串样本)、标量,以及字符串——文档诚实地注明"目前未使用"。

瞬时向量与"最近"的定义

取瞬时向量,是对每条匹配的序列找查询时刻之前最近的那个样本。"最近"有窗口:默认回看 5 分钟(--query.lookback-delta)。所以当一个目标消失之后,图上那条线还会平着往前走 5 分钟。

2.0 引入的 staleness marker 让这件事有了明确的解:目标消失或指标不再暴露时,写入一个特殊的"陈旧"标记,查询碰到它直接返回空,而不必傻等窗口耗尽。

区间向量不能直接画

http_requests_total[5m] 拿到的是一堆点,不是一条曲线,它只能作为函数的参数。rate()increase()histogram_quantile() 之类都建立在区间向量之上。

二元运算是标签集合上的连接

两个瞬时向量做运算时,Prometheus 按标签集合配对:默认要求除指标名外的所有标签完全相同才算同一行,on() / ignoring() 调整参与匹配的标签,group_left / group_right 处理多对一。计算错误率的标准写法:

promql
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
  /
sum by (job) (rate(http_requests_total[5m]))
```

顺序不能反过来。 计数器会因为进程重启而归零,rate() 靠"这个样本比上一个小"识别重置并补偿。如果先 sumrate,多条序列已经被加成一条,某个实例重启造成的下降在总和里表现为一次普通的回落,无法与真实下降区分,补偿也就补偿错了。先 rate 再 sum,永远不要先 sum 再 rate——这是 PromQL 里最重要的一条经验规则,而它的根源在数据模型,不在语法。

rate() 还有两个边界值得记住:窗口里至少要有两个样本才算得出来,经验做法是窗口取抓取间隔的四倍以上;它对窗口边缘做外推,所以 increase() 算一个整数计数器有时会给出非整数结果——那是估计值,不是计数。

3.0 改了一个看似无害的定义

3.0 把区间选择和回看窗口从"左闭右闭"改成了"左开右闭"。这看起来是个数学细节,实际影响很硬:foo[1m:1m] 这类子查询可能从返回两个点变成一个点,建立在它上面的 rate 直接算成空。官方迁移指南的建议是把窗口放宽,比如改成 foo[2m:1m]。这是个诚实的例子——语义定义得越精确,升级时越容易踩到。

它做不到什么

没有跨指标的任意 JOIN,连接键只能是标签集合;没有"这一次请求发生了什么"的查询;没有事务与一致性读。查询代价与命中的序列数成正比,一个匹配面过宽的选择器足以打死一台服务器。

它明确不做的事

不做为什么该用什么
长期存储本地 TSDB 单机无副本,默认保留 15 天remote write 到 Thanos / Cortex / Mimir 等
分布式一致性没有选举、没有复制,两台实例数据本就不同双跑相同配置 + Alertmanager 去重
日志事件级数据与指标的存储形状完全不同官方 FAQ 原话:"Don't!" 用 Grafana Loki 之类
链路追踪同上Tempo / Jaeger;exemplar 只是从桶指向 trace
计费数据是采样、可丢、可近似事件流与账本系统

单机无副本这一点值得说透。 一台 Prometheus 死了,它本地的数据就没了。所谓高可用是跑两台配置相同的服务器抓同一批目标——它们的抓取时刻不同,因此两边的数据点永远对不齐,图表会有细微差别。这不是缺陷,是拒绝为一致性引入共识协议的直接后果。共识意味着 leader、选举、多数派落盘,也意味着这套机制自己会出故障、需要被监控。etcd 那样的复杂度对于"随时都得能用"的东西是不可接受的,所以 Prometheus 把去重推给了下游:Alertmanager 用 gossip 协议在多个实例间去重告警,那是最终一致的,也够用了。

remote write 是这个边界的出口。 3.0 的 Remote Write 2.0 原生支持元数据、exemplar、创建时间戳和原生直方图,并用字符串驻留(string interning)压缩重复的标签名——一次远程写请求里绝大部分字节本来就是反复出现的相同标签字符串。3.0 还把 OTLP 变成一等公民:可以直接作为接收端在 /api/v1/otlp/v1/metrics 上收 OpenTelemetry 指标,指标名与标签名也开始允许全部合法的 UTF-8 字符,不必再把点号改写成下划线。

把这些放在一起,Prometheus 的形状就清楚了。它假设数据是周期采样的累计状态,所以敢用拉;它假设写入永远追加在最新时刻,所以敢用不可变块加流式压缩;它假设保留期以天计而不是以年计,所以敢把过期处理做成删目录;它假设自己必须比被监控系统简单,所以拒绝集群化。

基数爆炸之所以成为它最常见的事故,是因为这是唯一一个使用者单方面就能打破的假设:给代码加一个标签不需要改 Prometheus 的任何配置、不需要任何人审批,而代价全部落在 Prometheus 的内存里。

跨域连接

  • 信号处理:每 15 秒抓一次,本质上是对一个连续变化的量做等间隔采样。采样定理的约束在这里原样成立:抓取间隔决定了可分辨的最高频率,比它更快的波动不会变成"小一点的波动",而是被折叠成完全错误的形状——一段周期恰好接近抓取间隔的负载震荡,在图上可能表现为一条平线或一条缓慢漂移的假趋势。Prometheus 用两个办法让这件事变得可以忍受:计数器只增不减,等价于先积分再采样,高频成分被吸收进累积量,丢一个采样点只损失分辨率不损失总量;rate() 在窗口上做差分再平均,是一个粗糙的低通滤波器,窗口越长越平滑也越迟钝,这正是"窗口取抓取间隔四倍以上"这条经验规则的来历。反过来看压缩:delta-of-delta 就是时域上的二阶差分,它能把时间戳压到一个比特,靠的完全是采样时钟规律;时钟一抖,差分不再接近零,压缩率立刻退化。
  • 概率论:仪表盘上的 p99 延迟不是一个测量值,是从直方图桶里估计出来的量。经典直方图存的是若干累计计数桶,histogramquantile 在命中的那个桶内部做线性插值,也就是假设桶内均匀分布——而延迟分布在尾部恰恰极不均匀,桶边界因此直接决定了分位数的精度上限;落进最高那个无上界桶的请求,只知道"比上界慢",慢多少无从得知。更常见的错误是对分位数求平均:多个实例的 p99 取平均没有任何统计意义,因为分位数不是可加量,正确做法是先把各实例同一桶的计数相加、在合并后的分布上再求分位数,这正是 PromQL 里要先 sum by (le) 再 histogramquantile 的原因。计数器重置补偿同样是估计问题:rate() 在窗口边缘外推,用有限样本推断连续速率,给出的从来是一个带偏差的估计而非精确值。
  • 工业工程与质量管理:监控本质上是抽样检验加控制图,这套方法学在制造业里比在软件业里成熟得多。休哈特在 1920 年代提出控制图,解决的正是同一个问题:如何用有限抽样区分"正常波动"与"过程失控",并且不因为前者频繁误报。Prometheus 告警规则里的 for 子句——条件必须持续多久才真正触发——就是同一思想的最小实现,用持续时间过滤掉单点噪声。成本结构也一致:全检等于零间隔采样,代价无限;抽样频率的选择永远是漏检风险与测量成本之间的权衡,而基数爆炸恰恰是把"多测一点"的边际成本从加法变成乘法的地方。制造业还贡献了一条软件工程常忘的经验:测量系统本身需要被评估和校准(量具的重复性与再现性分析),否则测量误差会被当成过程波动。报警疲劳就是这条经验失效的典型后果——报警一多人就不看了,此时覆盖率再高也等于零。
  • 电网:SCADA 系统几十年前就在做同一件事——调度中心按固定周期轮询远方终端,而不是让终端自行上报。理由与 Prometheus 完全一致:轮询方手里有一份"本该在线的设备名单",才能把"没有回应"当成一个事件,而不是一片沉默;如果改成设备自报,一台掉电的设备与一台一切正常但暂时无话可说的设备,在中心看来一模一样。电网还展示了这个模型的两条真实边界:其一,采样周期决定了能看见什么,秒级轮询看不见毫秒级暂态过程,所以故障录波必须用另一套独立装置——对应 Prometheus 看不见单次请求、需要日志与链路补位;其二,测量系统必须比被测系统更耐故障,调度中心的供电与通信通道都要独立于它监控的那张网。Prometheus 拒绝集群化、坚持单机自足,正是同一条工程直觉的软件版本:监控链路上每多一个需要共识的组件,就多一个会与被监控系统同时失效的东西。
  • 认知偏差:仪表盘上有的东西会被当成系统的全部,仪表盘上没有的东西等于不存在。这是路灯效应最昂贵的形态——不是在有光的地方找钥匙,而是相信钥匙一定在有光的地方。指标只覆盖被埋点的那些维度,故障却经常发生在没埋点的地方,于是每次复盘的结论都是"再加一个指标",长期累积成基数问题;换句话说,基数失控往往不是技术疏忽,而是一种认知补偿行为的沉淀。同一个偏差在告警上换了副面孔:因为报警响过而没出事,人会把"没出事"归因于"这个报警不重要",逐步调高阈值直到它彻底失效——这是典型的结果偏差,用结果反推决策质量。Prometheus 把 up、scrapesamplesscraped 这类关于采集过程本身的指标也写成时间序列,恰好是对这种偏差的一点结构性对冲:它让"我现在看不见"变成一个可查询、可报警的显式事实,而不是一片安静的空白。

参考文献

  • Pelkonen, T., Franklin, S., Teller, J., et al. Gorilla: A Fast, Scalable, In-Memory Time Series Database. Proceedings of the VLDB Endowment, 2015.(delta-of-delta 与 XOR 编码的原始论文,16 字节样本平均压到 1.37 字节)
  • The Prometheus Authors. Prometheus Documentation — Storage, Querying Basics, Configuration, Naming Practices, FAQ.(2 小时块、128 MB WAL 段、15 天默认保留、5 分钟回看窗口、每样本 1–2 字节、标签基数指引的官方口径)
  • Volz, J. Pull Doesn't Scale — or Does It? Prometheus Blog, 2016 年 7 月 23 日.(拉模型扩展性的原始论证,以及"服务发现才是真问题"的表述)
  • The Prometheus Authors. When to Use the Pushgateway. Prometheus Documentation — Practices.(Pushgateway 唯一正当用途与四条已知陷阱的官方来源)
  • The Prometheus Authors. Announcing Prometheus 3.0Prometheus 3.0 Migration Guide. 2024 年 11 月 14 日.(UTF-8 指标名、OTLP 接收端点、Remote Write 2.0、区间选择器改为左开右闭)
  • Beyer, B., Jones, C., Petoff, J., Murphy, N. R. (eds.) Site Reliability Engineering. O'Reilly, 2016, 第 10 章.(Borgmon 的设计,Prometheus 的直接思想来源)

延伸阅读

  • Brazil, B. Prometheus: Up & Running. O'Reilly, 2018.(从抓取配置到告警规则的系统性入门,作者是早期核心开发者)
  • Reinartz, F. Writing a Time Series Database from Scratch. 2017.(2.0 存储引擎的设计自述,解释了为什么放弃 1.x 的按序列一文件方案)
  • Vernekar, G. Native Histograms in Prometheus. PromCon EU, 2022.(原生直方图如何把桶数与序列数解耦,以及迁移路径)