本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的 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
2
3
4
5
def evaluate(self, store: SampleStore, instance: str, now: int):
vals = store.window(self.metric, instance, now - self.window, now)
if len(vals) < self.min_samples:
return NODATA
return sum(vals) / len(vals)

状态推进只在拿到数值时发生;NODATA 只记录缺失起点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if v == NODATA:
# 缺失数据不推进 pending,也不解除 firing。
st.nodata_since = st.nodata_since if st.nodata_since is not None else now
else:
...
if rule.holds(v):
st.last_true = now
if st.state == "inactive":
st.state, st.since = "pending", now
if st.state == "pending" and now - st.since >= rule.for_s:
st.state, st.since = "firing", now
else:
if st.state == "pending":
st.state, st.since = "inactive", None
elif st.state == "firing" and now - st.last_true >= rule.keep_firing_for:
alerts.append(Alert(..., "resolved", ...))
st.state, st.since = "inactive", None

评估器遍历的是 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
"notification_id": f"{g.key}@{g.first_seen}#{g.seq}" if self.scoped_ids else f"{g.key}#{g.seq}",

结论可以推广:幂等键必须覆盖产生它的对象的完整生命周期,对象被删除重建时,键空间不能复用。

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 的文档从官方仓库对应版本的源文件读取。

本批导航