后端系统工程 36:指标采集与告警生命周期——缺数据不能算恢复
本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的
batch-4/目录(examples/、evidence/等子目录);业务数据均为合成数据。
告警系统一次只回答一个问题:现在是否需要叫人。一条规则从采样到通知要经过五个环节:样本是否到达、条件是否持续、多条告警怎样合并、哪些应当暂时不发、什么时候可以宣布恢复。任何一环把“没有数据”当成“数据正常”,值班人员都会在故障还在的时候收到一条恢复通知。
本篇用一个离散时钟模型实现这条链路,覆盖数据缺失、触发、重复抑制、静默、抑制、恢复和规则变更,并用负向对照证明错误做法确实会出错。模型借用 Prometheus 与 Alertmanager 的概念命名,行为以本文契约为准;凡是与官方实现有差异的地方,正文单独说明。
1. 需求与不变量
监控对象是一个合成的 checkout 服务,两个实例 c1、c2,每 15 秒采集一次 error_ratio(错误请求占比)和 up(采集是否成功)。告警规则有两条:
| 规则 | 条件 | for |
keep_firing_for |
缺数据宽限 |
|---|---|---|---|---|
HighErrorRate |
60 秒窗口平均错误率 > 0.05,且窗口内至少 2 个样本 | 60 秒 | 60 秒 | 120 秒 |
TargetDown |
30 秒窗口内 up 平均 < 0.5 |
30 秒 | 0 | 120 秒 |
通知侧按 service 分组,group_wait=30 秒、group_interval=300 秒、repeat_interval=3600 秒(默认场景);TargetDown 对同一 instance 的 HighErrorRate 及其缺数据告警生效抑制。渠道是本地假渠道,只记录收到了什么。
需要成立的不变量:
- I1 缺失样本不推进 pending 计时,也不解除 firing。只有拿到足够的新样本并且条件不成立,告警才进入恢复判定。
- I2 期望存在的序列持续缺失超过宽限期,产生独立的
NoData告警;期望实例清单来自服务发现,不来自“最近出现过的序列”。 - I3 同一告警组在内容不变时,两次通知间隔不小于
group_interval;持续 firing 时按repeat_interval重发。 - I4 被静默或抑制的告警不发通知;接收方从未收到过 firing 的告警,也不发送它的恢复。
- I5 通知以幂等键投递,渠道重试不在接收端产生重复——前提是接收端按键去重。
- I6 规则变更不让正在 firing 的告警瞬间消失,也不让旧条件下积累的 pending 时长转移到新条件。
规模假设只服务于实验:2 个实例、2 条规则、单分组。告警评估成本与序列数成正比,生产规模下的评估耗时与存储压力不在本篇讨论。
2. 为什么“没有结果”会变成“恢复”
Prometheus 的告警规则在每次评估时执行一个查询表达式,返回的每个序列成为一个活跃告警元素;带 for 的元素先处于 pending,持续满足后进入 firing。官方文档同时说明,没有 keep_firing_for 的规则会在“第一次条件不满足的评估”时解除。问题在于,查询表达式对一个已经不再上报的序列不返回任何元素。Prometheus 的过期机制会在采集不再返回某个序列时把它标记为 stale,源码注释写明失败的采集与空采集同样会触发 stale 标记。结果是:采集链路一断,error_ratio > 0.05 对这个实例就没有结果,原本 firing 的告警按“条件不满足”处理。
Prometheus 为此提供了 absent() 和 absent_over_time(),前者在输入向量为空时返回一个值为 1 的单元素向量,后者用于“某个指标与标签组合在一段时间内都不存在”时告警。也就是说,官方工具链把“数据缺失”交给另一条规则表达,而不是让业务规则自己区分。本篇的实现把这两件事放在同一个评估器里,每个实例的评估结果要么是可信的数值,要么是 NODATA(窗口内样本不足),状态机对两者分别处理。这样做的代价是评估器需要知道“应该有哪些实例”。
图 36-1 是本文实现的状态机。
---
title: 图 36-1 单个告警实例的状态机(缺数据不改变 pending/firing)
---
stateDiagram-v2
[*] --> inactive
inactive --> pending: 有样本且条件成立
pending --> inactive: 有样本且条件不成立
pending --> firing: 条件持续 ≥ for
firing --> firing: 条件成立 / 或距最后一次成立 < keep_firing_for
firing --> inactive: 有样本、条件不成立且距最后一次成立 ≥ keep_firing_for
pending --> pending: NODATA(计时不前进到 firing)
firing --> firing: NODATA(不解除)
note right of firing
NODATA 持续 ≥ 宽限期
另行产生 XxxNoData 告警
end note
3. 评估器的关键实现
评估结果由窗口内样本数决定是否可信。min_samples 把“只有一个迟到样本”的情况也归入 NODATA,避免一个样本代表整个窗口。
1 | |
状态推进只在拿到数值时发生;NODATA 只记录缺失起点。
1 | |
评估器遍历的是 targets(服务发现给出的期望实例),不是样本库中出现过的序列。一个刚扩容、从未上报过的实例,在这种遍历方式下会在宽限期后得到 NoData 告警;如果只遍历已有序列,它永远不会被检查。
规则变更时,评估器比较规则版本。版本不同意味着条件的含义改变,处于 pending 的实例清零重来;处于 firing 的实例继续按新条件评估,通过 keep_firing_for 正常退出,而不是在变更那一轮消失。这是本文的契约选择。Prometheus 的实现另有一套 for 状态恢复机制(--rules.alert.for-outage-tolerance 等参数用于重启后恢复 for 计时),与这里的“规则版本”不是同一件事。
4. 通知侧:分组、重复与幂等键
评估器每轮把所有 firing 告警交给通知器,本轮缺席的 firing 告警被视为恢复。这与 Alertmanager 的 Alerts API 约定一致:客户端应持续重发 firing 告警,告警在 endsAt 过去后视为已解决。Prometheus 源码中,每次发送把 endsAt 设为当前时间加 4 倍的 max(评估间隔, 重发延迟),因此 Alertmanager 的 resolve_timeout 对来自 Prometheus 的告警不起作用,官方配置文档对此有明确说明。
分组的三个计时器语义来自 Alertmanager 文档:新组等待 group_wait 后首发;之后每个 group_interval 检查组内是否有新 firing 或新恢复;repeat_interval 在 group_interval 的检查点上判断是否到期,文档建议它取 group_interval 的整数倍。本实现用组内可见告警的 (指纹, 状态) 摘要判断“内容是否变化”。
---
title: 图 36-2 通知管线(静默与抑制在摘要计算之前生效)
---
flowchart LR
E[评估器: 每轮全部 firing] --> R[接收: 缺席者记为 resolved]
R --> G{到达 group_wait / group_interval?}
G -- 否 --> W[等待下一轮]
G -- 是 --> M[去掉静默/抑制/从未通知过的恢复]
M --> D{摘要变化 或 repeat 到期?}
D -- 否 --> W
D -- 是 --> N["生成通知:幂等键 = 组键@首见时间#35;序号"]
N --> C[假渠道: 失败按 15/30/60 秒退避重试]
幂等键的作用域在实验中出过一次错。最初的键是“组键 + 组内序号”。当一个组的告警全部恢复后,组被删除;下一次同组告警出现,新组从序号 1 重新计数,生成的键与上一轮第一条通知完全相同,按键去重的假渠道把它当作重复丢掉了。回归测试 test_unscoped_ids_drop_real_notifications 保留了这个负向对照。修正后的键包含组的首见时间:
1 | |
结论可以推广:幂等键必须覆盖产生它的对象的完整生命周期,对象被删除重建时,键空间不能复用。
5. 实验
运行入口:examples/ch36_alerting/,python3 -m unittest test_alerting -v,16 个用例全部通过;各场景时间线由 dump_timelines.py 写入 evidence/36/timelines.jsonl。模型使用固定时钟(步长 15 秒),结果可完全重放。以下时间均为模型秒。
5.1 正常触发与恢复
c1 在 300–900 秒错误率为 0.2,其余时间 0.01。观测到:300 秒进入 pending,360 秒 firing,390 秒发出第一条通知(group_wait=30);错误率在 900 秒回落,但 60 秒窗口直到 945 秒才完全不含高值样本,最后一次条件成立在 930 秒,keep_firing_for=60 使恢复发生在 990 秒,恰好也是 690 秒检查点之后的下一个 group_interval,恢复通知在 990 秒发出。整个 firing 期间只有一条 firing 通知(I3)。把 repeat_interval 改为 600 秒、firing 延长到 1800 秒,得到 390 秒首发、990 和 1590 秒两次 repeat、1890 秒恢复,相邻通知间隔都不小于 300 秒。
5.2 边界:短尖峰与振荡
每 120 秒出现一个单样本尖峰(0.2),60 秒窗口平均值在 4 个评估点超过阈值,pending 持续 45 秒,不足 for 的 60 秒,没有任何通知。振荡场景中,firing 之后错误率以 150 秒为周期“90 秒高、60 秒低”往复,group_interval 设为 60 秒以放大差异:
keep_firing_for |
进入 firing 次数 | 收到的通知数 |
|---|---|---|
| 0 | 6 | 12 |
| 90 秒 | 1 | 2 |
keep_firing_for 让短暂回落不触发恢复,接收方看到的是一次持续的事件,而不是 6 组“触发—恢复”通知。代价是真实恢复的通知晚到:本场景在 1500 秒彻底恢复,恢复通知在 1590 秒。
5.3 失败:采集中断
c1 在 300–600 秒错误率 0.2,600–1200 秒两个指标的样本全部缺失,1200 秒之后恢复为 0.01。
| 实现 | HighErrorRate/c1 的恢复通知 |
缺数据告警 |
|---|---|---|
| 本文评估器 | 1290 秒(数据恢复之后) | HighErrorRateNoData/c1、TargetDownNoData/c1 在 990 秒随组通知发出,1290 秒恢复 |
| 负向对照:缺失补 0 | 690 秒(采集中断期间) | 无 HighErrorRateNoData |
负向对照在 690 秒向值班人员宣布恢复,而此时没有任何证据表明错误率已经下降。这一行为与“查询无结果即条件不满足”的规则语义同构。缺数据告警在 735 和 750 秒就已产生,但该组的上一次检查点是 690 秒,通知要等到 990 秒。group_interval 在降低噪声的同时,也给“同组新增告警”加入了最长一个间隔的延迟。
从未上报过的 c2(服务发现中存在、样本库中没有)在 120 秒产生 NoData,150 秒随组首发;负向对照对 c2 的 HighErrorRate 什么也没有报。补零对 TargetDown 这类“值越小越坏”的规则又会得到相反的结果:负向对照里 up 被补零就会立刻触发目标不可达。补零让每条规则的缺数据行为取决于阈值方向,这种行为难以审查。
5.4 抑制、静默与丢失的恢复
目标在 600–1200 秒不可达(up=0,业务指标缺失)。TargetDown/c1 在 645 秒 firing,690 秒的通知只有它;HighErrorRateNoData/c1 在 750 秒产生后一直被抑制。1290 秒的恢复通知包含 HighErrorRate/c1 与 TargetDown/c1,不包含从未通知过的 HighErrorRateNoData/c1(日志记为 resolved_never_notified,I4)。
维护静默覆盖 c2 的 300–900 秒。c2 在 300–1200 秒高错误,第一条通知在 990 秒,即静默结束后的第一个检查点。
一个反例场景暴露了静默的副作用:c1 的 firing 在 390 秒已经通知,600 秒有人对 c1 创建静默(到 1500 秒),告警在 990 秒恢复。恢复发生在静默期内,被丢弃,接收方手里的事件永远停留在 firing。日志中留下 resolved_dropped_while_muted。只要实现把静默期内的恢复与 firing 一并丢弃,就会出现同样的现象。可选的处理是让静默只拦截 firing、放行“对已通知告警的恢复”,或者在静默到期时补发一次组状态。本实现保留了现象并用测试固定下来,便于后续决策。
5.5 渠道失败
渠道脚本为“未送达超时、已送达但响应丢失、成功”。通知器在 390、405、435 秒共尝试 3 次,三次使用同一个幂等键。按键去重的假渠道只记录 1 条;关闭去重后记录 2 条内容完全相同的通知。连续 4 次未送达时,通知器在耗尽退避后计数 notify_failed。这个计数本身应当成为一条元告警,并且要走另一条通道——用同一个坏掉的渠道去报告它自己坏掉,是无效的。
5.6 规则变更
阈值在 705 秒从 0.05 调整为 0.1,c1 的错误率是 0.08。firing 没有在 705 秒消失,最后一次条件成立在 690 秒,恢复发生在 750 秒,恢复通知在 1020 秒随组发出。pending 场景中,360 秒的规则变更清零了此前的 pending 计时,firing 不早于 420 秒。
6. 告警链路自身的缺数据
I2 解决的是“被监控对象没有数据”,链路本身同样会沉默:评估器进程停止、规则组评估超时被跳过、通知器卡在一个永远超时的渠道上。三种情况在接收方看来完全一样——没有任何通知,与“一切正常”无法区分。
本篇实现中有两处观察点用来打破这种对称。第一处是 notify_failed 计数:退避耗尽的通知不会静默消失,而是累加计数并写入日志。第二处是把评估器自身当成一个被监控实例:评估器每轮写一个心跳样本,另一套独立部署的检查器用同样的三值逻辑判断心跳是否缺失。心跳规则的条件永远成立(类似“始终 firing 的看门狗告警”),接收端在它停止到达时报警,而不是在它到达时报警。这样,评估器挂掉、网络分区、通知器失效都会表现为“该到的东西没到”,属于可以检测的缺失。
这两处在测试中只覆盖了前者(test_exhausted_retries_counted)。心跳与独立检查器需要第二个进程和第二条通知通道,单进程模型无法证明它们的有效性,记为 NOT_RUN。
告警链路的操作契约也需要写清楚,否则“恢复”的含义会随接收者理解而变化:
| 字段 | 含义 | 本实现 |
|---|---|---|
| 指纹 | 告警身份:规则名 + 标签集合 | sha1(labels) 前 10 位 |
starts_at |
进入 firing 的评估时刻 | 评估器时钟 |
ends_at |
恢复判定时刻;firing 时为空 | 只在有样本且条件不成立后设置 |
| 通知幂等键 | 组键@组首见时间#组内序号 |
渠道重试复用同一键 |
reason |
change 或 repeat |
用于区分新事件与提醒 |
注解 rule_version |
产生该状态的规则版本 | 规则变更后可追溯 |
接收方据此可以做三件事:按指纹合并同一事件的多条通知,按幂等键丢弃重复,按 rule_version 判断阈值调整前后的告警是否可比。
7. 方案比较
| 做法 | 缺数据行为 | 适用 | 代价 |
|---|---|---|---|
| 规则表达式只写业务条件 | 无结果即解除 | 指标极少缺失、可以接受误报恢复 | 采集中断期间宣布恢复 |
业务规则 + 独立 absent_over_time 规则 |
业务告警解除,另一条告警触发 | Prometheus 原生做法 | 两条告警的生命周期要人工关联;absent() 只保留选择器中的等值匹配标签 |
| 评估器内三值逻辑(本文) | 不解除,并产生 NoData | 需要“恢复”具有强含义的告警 | 评估器必须掌握期望实例清单 |
| 缺失补 0 或补上次值 | 由阈值方向决定 | 不推荐 | 行为难以审查 |
8. 限制
- 模型只验证状态机、计时器与幂等逻辑。真实 Prometheus/Alertmanager 的行为以官方文档与源码为准,本文没有运行这两个组件。
- 假渠道送达不代表任何真实通知服务送达;接收端是否支持按键去重要逐个渠道确认。
- 评估间隔与采集间隔相同(15 秒),未覆盖评估超时被跳过、采集时间戳漂移、远端写入迟到等情况。
- 通知器的分组状态和去重记录都在内存中,进程重启会丢失,可能导致重复通知;Alertmanager 用集群与通知日志处理这一点,本模型没有实现。
- 告警策略本身(应该对哪些症状告警、阈值如何来自服务目标)不在本篇范围。
9. 来源
核对日期 2026-10-18。Prometheus 与 Alertmanager 的文档从官方仓库对应版本的源文件读取。
- Prometheus v3.15.0,
docs/configuration/alerting_rules.md:for与 pending、keep_firing_for、无keep_firing_for时首次不满足即解除。https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/ - Prometheus v3.15.0,
docs/querying/basics.mdStaleness:采集不再返回样本时标记 stale,5 分钟回看。https://prometheus.io/docs/prometheus/latest/querying/basics/#staleness - Prometheus v3.15.0,
scrape/scrape.go注释:失败的采集与空采集同样触发 stale 标记。 - Prometheus v3.15.0,
docs/querying/functions.md:absent()、absent_over_time()。 - Prometheus v3.15.0,
rules/alerting.go、rules/manager.go:ValidUntil = ts + 4×max(interval, resendDelay),作为EndsAt发送。 - Alertmanager v0.34.1,
docs/configuration.md:group_wait30s、group_interval5m、repeat_interval4h 默认值;resolve_timeout对 Prometheus 告警无效;抑制规则的equal。https://prometheus.io/docs/alerting/latest/configuration/ - Alertmanager v0.34.1,
docs/alerts_api.md:客户端持续重发 firing 告警,endsAt过去即解决。https://prometheus.io/docs/alerting/latest/alerts_api/
本批导航
- 下一篇:37 超时预算与负载保护
- 本批其余:38 数据备份、恢复与地域切换、39 综合方案评审与交付
- 相关章节:33 通知渠道的结果未知、35 文件上传与异步处理



