指标把大量系统事件压缩为可比较的时间序列,告警则把其中一部分变化转化为人的行动。前者首先是测量问题:统计的对象、时间窗和分母是什么;后者首先是决策问题:什么情况需要谁在多久内处理。
本文讨论测量与推理方法。具体 Prometheus 命令见 Prometheus 操作手册 ,单个请求的因果分析见 分布式追踪 。
从用户问题反推测量口径
“服务是否正常”必须具体化。以查询接口为例,可定义:有效请求中,成功且在 300 ms 内完成的比例。这里至少有四个选择:什么是有效请求;哪些返回算成功;延迟从哪里开始计时;统计窗口多长。
应用入口埋点能看到进入应用的请求,却看不到到达应用前的 DNS、TLS 或网关失败。若目标是端到端可用性,需要结合边缘观测或外部探测。把应用内成功率直接称为用户可用性,会遗漏测量边界外的失败。
4xx 是否计入错误也不是固定答案:错误参数可能不属于服务责任,错误鉴权配置导致所有正常用户被拒绝则是服务故障。应先写清业务口径,再写查询表达式。
Counter、Gauge 与分布
类型 记录什么 典型用法 常见误读
Counter 累计事件量,重启可能归零 请求数、失败数、处理字节数 把累计值当成每秒速率
Gauge 当前状态,可升可降 队列长度、在途请求、剩余空间 对任意 gauge 求 rate 并称为吞吐
Histogram 观测值的分布及数量 延迟、响应大小 只看平均值忽略长尾
Summary 数量、总和及可选客户端分位数 单实例分布统计 把多个实例的分位数直接平均
以 Counter 求速率时,应先对每条序列做 rate,再按服务聚合,让重启归零在实例级被处理。下面示例约定 http_requests_total 带 service、instance、status 标签:
# 每服务请求速率
sum by (service) (rate(http_requests_total[5m]))
# 每服务 5xx 比例;只在该服务有请求时解释结果
sum by (service) (rate(http_requests_total{status=~"5.."}[5m]))
/
sum by (service) (rate(http_requests_total[5m]))
应预先初始化需要的错误标签序列,或在规则中显式处理缺失;“没有错误序列”与“错误率恰好为零”不是相同的数据形态。完全无流量时分母为零,也不应伪装为可用性百分之百。
先聚合 Counter,会把一个实例的归零扩散到其他实例
假设三次同时抓取,A 的计数为 100、110、5,B 为 100、120、130。A 在后两次之间重启,重启后的值是 5;B 没有重启。按实例分别处理归零,观察到的增量为 A:10+5=15,B:20+10=30,共 45。
若先相加,序列变成 200、230、135。再把下降当成整个聚合计数器归零,得到 30+135=165,把 B 仍然保有的累计值也算成了新事件。
def observed_increase ( samples ) :
return sum ( current - previous if current >= previous else current
for previous , current in zip ( samples , samples [ 1 : ] ) )
A , B = [ 100 , 110 , 5 ] , [ 100 , 120 , 130 ]
assert observed_increase ( A ) + observed_increase ( B ) == 45
assert observed_increase ( [ a + b for a , b in zip ( A , B ) ] ) == 165
这只是离散样本的归零模型,没有实现 Prometheus rate 的时间外推。抓取间隔里重启前发生但未被观察的事件,也无法由这三个样本完整恢复。例子的目的在于保留每条 Counter 的生命周期边界,再聚合速率,而不是从样本制造精确全量账本。
为什么不能平均 P99
假设一个实例处理 100 个请求,另一个处理 10000 个请求。两者的 P99 即使相同,也不能告诉我们把所有请求混合后的精确 P99;分位数丢掉了分布内部的信息。简单平均还额外忽略了样本权重。
经典 Histogram 保存累计桶计数,可以先聚合桶,再估算整体分位数。桶边界必须保留在聚合中:
histogram_quantile(
0.99,
sum by (service, le) (
rate(http_request_duration_seconds_bucket[5m])
)
)
这里假设各实例使用可兼容的经典桶边界。估计误差与桶宽有关;若目标阈值是 300 ms,配置一个 0.3 秒桶能直接统计阈值内比例,而不必先估 P99。Native Histogram 的表示与查询形式不同,应按实际指标类型选择。见 Prometheus Histograms and Summaries 。
均值仍有价值:总耗时除以请求数描述平均工作量;分位数描述尾部体验;超过目标阈值的比例则直接连接服务目标。三者回答的问题不同。
即使按请求数加权,局部分位数仍无法合并
用最近秩定义说明:P99 取排序后第 ceil(0.99*N) 个样本。实例 A 的 10000 个请求均为 1 ms,实例 B 的 100 个请求均为 1000 ms。局部 P99 分别是 1 与 1000,但合并后的第 9999 个样本仍在 A 的快请求里,因此总体 P99 为 1 ms。
from fractions import Fraction
from math import ceil
def p99 ( values ) :
return sorted ( values ) [ ceil ( Fraction ( 99 , 100 ) * len ( values ) ) - 1 ]
A , B = [ 1 ] * 10000 , [ 1000 ] * 100
assert p99 ( A ) == 1 and p99 ( B ) == 1000
assert p99 ( A + B ) == 1
simple_average = Fraction ( p99 ( A ) + p99 ( B ) , 2 )
weighted_average = Fraction ( p99 ( A ) * len ( A ) + p99 ( B ) * len ( B ) , len ( A ) + len ( B ) )
assert simple_average == Fraction ( 1001 , 2 )
assert weighted_average == Fraction ( 1100 , 101 )
assert simple_average != p99 ( A + B ) and weighted_average != p99 ( A + B )
这不是说 100 个慢请求无关紧要。若目标是识别局部实例劣化,B 的分布很有价值;若目标是总体用户请求的分位数,就必须按总体分布计算。不同统计软件的分位数插值约定有所不同,但都不能普遍通过平均局部分位数恢复全量分位数。
若采用共同的 300 ms 桶,本例阈值内请求比例可直接由两实例累计桶相加得到 10000/10100≈99.01%。它明确暴露约 0.99% 的请求超标,而总体 P99 仍为 1 ms;阈值达标率和分位数各自保留的信息不同。
整体 P99 是排序位置,不能平均两台机器的 P99
实例 A
10000 次 × 1 ms;P99=1
实例 B
100 次 × 1000 ms;P99=1000
总体 10100 次
第 9999 个仍为 1 ms
简单平均 500.5 ms,加权平均约 10.89 ms,均不是总体 P99。
慢请求仍然存在;使用阈值达标率、分位数和实例维度共同解释。
RED 与 USE:服务结果和资源解释
RED 观察 Rate、Errors、Duration,即服务处理多少、失败多少、耗时多久。USE 观察 Utilization、Saturation、Errors,即资源占用、等待压力和错误。前者帮助发现用户侧症状,后者帮助解释资源瓶颈。
对象 使用率 饱和/等待 错误
CPU 忙碌时间比例 可运行任务排队、CPU pressure 相关硬件或调度异常另行观测
内存 工作集与可用空间 回收、换页、memory pressure OOM 事件、分配失败
存储 设备忙碌等利用指标 队列深度、等待时间 I/O 错误
线程池 活跃线程数 待执行任务队列 拒绝任务、执行失败
OOM score 是进程选择相关信息,不能直接代替内存饱和度。CPU 使用率高也不一定是故障:若吞吐高、队列稳定、延迟达标,可能只是有效利用资源。反之,单线程瓶颈可能让整机平均 CPU 很低,却让请求严重排队。相关方法见 USE Method 与 Google SRE 的监控信号 。
SLI、SLO、SLA 与错误预算
SLI 是按确定口径测出的指标;SLO 是希望在一个窗口中达到的目标;SLA 是对外约定及相关后果,三者不能互相替代。
若 30 天请求成功率 SLO 为 99.9%,允许失败比例就是 0.1%。对一百万个有效请求,预算为一千次失败。这是请求数预算,不能直接换算成“允许宕机 43.2 分钟”:请求流量可能随时间变化。只有使用按时间衡量的可用性口径时,分钟换算才有对应含义。
错误预算:消耗速度决定响应紧迫度
目标容许错误率
0.1%
当前观察错误率
1.0%
燃烧率 = 1% ÷ 0.1% = 10 倍
同一测量口径下,持续 10 倍燃烧会以目标容许速度的 10 倍消耗预算。
定义燃烧率 B = 当前坏事件比例 / (1 - SLO)。若 SLO 为 99.9%,当前坏事件占 1%,则 B=10。若测量口径与流量分布近似稳定,持续这种速度,30 天预算约在 3 天内耗尽。窗口变化和流量突变会影响这个预测。
短窗口敏感但容易被毛刺影响,长窗口稳健但恢复慢。多窗口策略把同一燃烧率条件同时施加到长、短窗口,确认问题既持续存在、又尚未恢复。具体阈值应从预算消耗和响应时间推导,参考 Google SRE:Alerting on SLOs 。
14.4 倍的阈值可以从预算消耗反算
假设预算窗口是 30 天,即 720 小时,并用稳定流量近似估计消耗速度。若希望“一小时消耗总预算的 2%”触发高优先级响应,阈值满足 B×1/720=0.02,所以 B=14.4。对于 99.9% 的目标,对应观测错误比例是 1.44%。
from fractions import Fraction
window_hours = 30 * 24
burn = Fraction ( 2 , 100 ) * window_hours / 1
bad_fraction = burn * Fraction ( 1 , 1000 )
assert burn == Fraction ( 72 , 5 ) assert bad_fraction == Fraction ( 9 , 625 )
def page ( long_burn , short_burn ) :
return long_burn > burn and short_burn > burn
assert page ( 20 , 20 )
assert not page ( 20 , 0 ) assert not page ( 2 , 20 )
短窗可以选长窗的一小部分,用于确认当前仍在快速消耗;这里仅演示组合逻辑,不规定所有服务都应使用相同阈值。对强烈日周期流量或极低请求量,短窗错误比例可能非常不稳定,时间比例也未必准确代表请求预算比例,需结合实际事件量调整告警策略。Google SRE 预算告警方法
同样的燃烧率,为什么预算消耗不同?
燃烧率 B 是比例之比,不是已经花掉的预算百分比。固定窗口总请求数 N、观察段请求数 n、该段坏事件比例 e,预算贡献为 ne/[N(1−SLO)]=B·n/N。只有流量近似稳定,才可进一步用时间占比近似 n/N。下面保持错误比例不变而改变请求量,可以直接看到 B 不变、预算消耗却改变。
budget fraction = N ( 1 − SLO ) n e = B N n
正在呈现知识画面 重新加载
同样的燃烧率,为什么预算消耗不同? 燃烧率比较坏事件比例与允许比例;实际消耗的请求预算还取决于这段时间处理了多少请求。时间占比不能无条件替代请求占比。
{"id":"slo-rate-and-budget","title":"同样的燃烧率,为什么预算消耗不同?","summary":"燃烧率比较坏事件比例与允许比例;实际消耗的请求预算还取决于这段时间处理了多少请求。时间占比不能无条件替代请求占比。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"requests\" data-i18n=\"requests\"></label><output for=\"requests\" id=\"requests-value\"></output><input id=\"requests\" type=\"range\" min=\"1000\" max=\"100000\" value=\"10000\" step=\"1000\"></div><div class=\"control\"><label for=\"rate\" data-i18n=\"rate\"></label><output for=\"rate\" id=\"rate-value\"></output><input id=\"rate\" type=\"range\" min=\"0\" max=\"2\" value=\"1\" step=\"0.1\"></div><div class=\"metrics\"><div><span data-i18n=\"burn\"></span><strong id=\"burn\"></strong></div><div><span data-i18n=\"errors\"></span><strong id=\"errors\"></strong></div><div><span data-i18n=\"spent\"></span><strong id=\"spent\"></strong></div></div><p class=\"hint\" data-i18n=\"budget\"></p><div id=\"budget\" class=\"budget\"></div><p class=\"formula\" id=\"equation\"></p><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"remaining\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"exceeded\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.metrics{grid-template-columns:repeat(3,minmax(0,1fr))}#equation{min-height:4.8em}.budget{display:grid;grid-template-columns:repeat(20,minmax(0,1fr));gap:3px}.budget i{height:17px;background:var(--surface);border:1px solid var(--rule)}.budget i.on{background:var(--second)}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}function draw(){const n=+$('#requests').value,r=+$('#rate').value,errors=Math.round(n*r/100),burn=r/.1,spent=errors/1000*100;set('requests-value',n);set('rate-value',r.toFixed(1));set('burn',burn.toFixed(1)+'×');set('errors',errors);set('spent',spent.toFixed(1));$('#budget').replaceChildren(...Array.from({length:100},(_,i)=>{const e=document.createElement('i');const fraction=Math.min(1,Math.max(0,(errors-i*10)/10));e.style.background='linear-gradient(to right,var(--second) '+fraction*100+'%,var(--surface) '+fraction*100+'%)';return e;}));set('equation',r.toFixed(1)+'% / 0.1% = '+burn.toFixed(1)+'×; '+errors+' / 1000 = '+spent.toFixed(1)+'%');set('explanation',t(errors>=1000?'exceeded':'remaining'));}document.querySelectorAll('input').forEach(e=>e.oninput=draw);draw();","audio":false,"strings":{"budget":"每格代表 10 次允许错误,共 1000 次","burn":"燃烧率","errors":"本段错误数","exceeded":"本段已达到或超过整个窗口预算;格子填满不表示多余错误被忽略。","heading":"同样的燃烧率,为什么预算消耗不同?","intro":"30 天窗口固定有 100 万个有效请求,SLO=99.9%。调整其中本次观察段的请求量与错误比例。","rate":"本段错误比例 / %","remaining":"尚未用尽本窗口预算;图中只计本段贡献。","requests":"本段有效请求数","reset":"重置","scope":"固定完整窗口一百万请求、允许一千次错误;只计算所选段对总预算的贡献,其余段暂不计错。不预测未来流量,不是实时告警配置。","scopeLabel":"模型范围与约定","spent":"总预算已消耗 / %"}}
从图表到可行动告警
高优先级通知应对应需要及时介入的影响或迫近风险,包括错误、延迟、数据正确性、容量耗尽等。不能规定“只有 4xx/5xx 才通知”:服务返回 200 但延迟数十秒,同样可能严重违背目标。
告警规则应说明服务和责任人、用户影响、观测窗口、优先级、第一步检查入口与恢复判断。短时间波动可以用 for 等持续条件过滤,但延长 for 也增加发现延迟,不能靠无限延后让报表安静。
还需明确缺失数据语义。抓取失败可能让业务序列消失;序列消失不能自动视为业务恢复。应分别监控目标是否可抓取、规则评估是否正常、通知链路是否通畅。
标签基数:一次分类就是一组时间序列
假设一个指标有 20 个路由、5 个状态类别、50 个实例,完整组合已达 5000 条序列。再把十万个用户 ID 放成标签,潜在组合会迅速放大。即使不是所有组合都出现,持续增长的标签也会增加存储与查询压力。
使用稳定路由模板而不是完整 URL;请求 ID、订单 ID 等适合进入日志或追踪,避免用作无界指标标签。单位、命名和标签设计见 Prometheus Metric and Label Naming 。
Prometheus 高可用:每个实例都有状态
Prometheus 双副本:独立采集,查询与通知分别去重
监控目标
Prometheus A
独立 TSDB / WAL
Prometheus B
独立 TSDB / WAL
查询层
按副本标签去重
Alertmanager 集群
告警聚合与通知去重
每个副本都有本地状态;采集高可用不等于共享存储或自动全链路去重。
Prometheus 保存本地时间序列、WAL 和规则相关运行状态,不能称为无状态服务。双实例通常独立采集同一目标,查询层按配置识别副本、避免重复计算;告警层则单独处理聚合与通知去重。查询高可用与通知高可用不是同一件事。
远程写入、长期存储与本地保留策略又是独立设计维度。双副本并不自动备份所有历史数据,也不保证远程写入永不积压。存储模型见 Prometheus Storage 。具体查询后端的副本标签、去重算法和故障行为应按其文档配置。
参考
关键词:Counter、Gauge、Histogram、P99、RED、USE、SLI、SLO、错误预算、燃烧率、标签基数、Prometheus HA