主题
告警设计:让每一次叫醒都指向一件值得做的事
“CPU 超过 80%”是一条判断条件,还不是完整的告警。收到消息的人仍然需要知道:哪项服务受影响、是否必须现在处理、先查什么、能做什么,以及没人响应时找谁。把阈值调得更灵敏,无法补上这些缺口。
一条好的告警,把可相信的证据接到一个明确的响应动作。本文先设计这条链,再讨论分级、去重和收敛,最后用可运行的错误预算模型说明为什么 SLO 告警经常需要两个时间窗口。代码在 Go 1.27.2、Linux/amd64 实测;示例事故与流量均为合成数据,没有向任何值班系统发送通知。
先写响应契约,再写表达式
| 必须回答的问题 | 可操作的写法 | 只有阈值时容易发生什么 |
|---|---|---|
| 影响是什么 | 订单接口失败率持续升高,影响提交订单 | 看到机器异常,却不知道是否影响用户 |
| 证据是什么 | 明确的 SLI、窗口、受影响服务和环境 | 通知无法复算,也无法验证恢复 |
| 为什么现在处理 | 按当前速度会快速耗尽错误预算 | 所有异常都成为同等紧急的电话 |
| 第一个动作是什么 | 查看变更与错误分布,核对是否可回滚 | 值班者从零猜测 |
| 谁负责 | 主值班、备份、所属服务和升级路径 | 多个人都以为别人已经处理 |
| 什么算恢复 | 用户侧 SLI 恢复并持续观察,队列可消化 | 机器指标正常就过早关闭事件 |
Google SRE 的监控原则强调对用户可感知的症状发出可操作告警,诊断信息用于解释症状。这样不是放弃 CPU、磁盘和连接池监控,而是把“需要叫醒人”和“排障时需要看”区分开。[1]
也有需要直接对资源告警的情况:磁盘将在值班者来得及处理之前写满、证书临近过期、队列积压将突破业务时限。此时资源指标对应了可说明的风险和提前量,动作可能是扩容、清理、续期或降载。阈值必须根据剩余时间与处置时间选择,不能机械照搬百分比。
正在绘制图示…
查看图源代码
flowchart LR
S[SLI / 探测 / 资源证据] --> E[规则评估]
E --> P[持续条件成立<br/>pending → firing]
P --> A[分组、去重、抑制和路由]
A --> O[主值班确认]
O --> I[判断影响并建立事件]
I --> M[止损、观察、升级]
M --> R[按恢复条件验证]
E -.数据缺失.-> C[单独检查遥测覆盖]
A -.发送失败.-> N[通知链路健康检查]
pending 是 Prometheus 的规则状态,通知分组是 Alertmanager 的工作,二者不是同一计时器。没有配置 for 的规则可在一次评估后进入 firing;for 要求条件持续满足,不能简单理解成“数据窗口有多长”。[2]
分级按响应紧迫度,不按看板颜色
下面是团队可以采用的一种约定,不是行业统一分级标准。真正的响应时限需要团队确认并演练。
| 等级示例 | 条件 | 响应方式 | 不适合放在这一层的内容 |
|---|---|---|---|
| 紧急 Page | 显著用户影响正在持续,或短期内将造成高损失;现在有可做的动作 | 叫醒主值班,按既定时限自动升级 | 没有处置动作的常态抖动 |
| 工作时段处理 | 预算缓慢消耗、容量风险仍有足够处理时间 | 工单/任务队列,明确负责人和期限 | 会在下一工作日前造成严重后果的问题 |
| 信息/诊断 | 发布、单次可恢复错误、低风险状态变化 | 看板、日志、变更流 | 需要即时响应却只写入无人看的频道 |
同一服务可以同时有快速燃尽的 Page 和慢速恶化的工单,但要说明二者如何去重。不能让所有等级最终都打到同一个电话,也不能以“降低打扰”为由把真正紧急的问题改成无主通知。
SLO 告警:关注错误预算消耗速度
假设某服务的 SLO 是:30 天内,99.9% 的合格请求成功。合格请求如何定义、哪些响应算失败、超时或被拒绝是否计入,必须先写清。用于 SLI 的分母不能悄悄剔除用户已经受到影响的失败。按请求计算的 SLO,也不同于按分钟计算的可用性目标。[3]
这个目标允许的失败比例是 1 - 0.999 = 0.001。某窗口内失败率为 2% 时,燃尽率为:
text
burn rate = observed error ratio / allowed error ratio
= 0.02 / 0.001
= 20它表示错误预算相对于允许比例消耗得有多快。若未来流量与当前窗口相当,且错误率保持不变,可用它估计预算消耗;流量剧烈变化时,按时间推算的预算比例只是近似,最终仍应以约定的请求口径核算。
Google SRE Workbook 给出多窗口、多燃尽率方法:较长窗口要求异常有足够分量,较短窗口确认问题仍在持续。常见的 30 天示例为 [1h, 5m] > 14.4,以及 [6h, 30m] > 6。它们对应在长期窗口内约消耗 2% 和 5% 的整周期预算,前提是使用相应的时间与流量近似。它们是推导出来的起点,不是所有服务的默认最佳阈值。[4]
为什么两个窗口用 AND
本次模型每分钟有 1000 个请求、20 个失败,共 60 分钟。page 要求 1 小时与最后 5 分钟的燃尽率都大于 14.4:
go
long, _ := burn(xs, .999)
short, _ := burn(xs[55:], .999)
firing := long > 14.4 && short > 14.4输出:
text
ongoing long=20.00 short=20.00 page=true
recovered long=18.33 short=0.00 page=false第二行只把最后五分钟的失败数改成零。长窗口仍记着前面的错误,但短窗口表明近期已经恢复,因而不再触发这个 Page 条件。如果把两个窗口用 OR 连接,长窗口会在恢复后继续拉响;如果只用短窗口,一次很短的波动更容易触发。
这个取舍也有成本:短窗口恢复会让规则很快解除,间歇性故障可能在解除和触发间切换;两个窗口都满足才发出的告警也不是零延迟。必须用事故回放评估检测延迟、漏检和恢复行为,而不是只看平稳时的通知数量。
把窗口模型翻译成 PromQL 时,先约定输入
示例规则假定存在两个 Counter:demo_request_total 记录所有合格请求,demo_request_bad_total 记录其中的失败;它们每个序列都有 service、environment,失败 Counter 即使尚未发生失败也要初始化为零。Counter 的实例级重置先由 rate 处理,再按服务与环境求和。[5]
promql
sum by (service, environment) (rate(demo_request_bad_total[5m]))
/
sum by (service, environment) (rate(demo_request_total[5m]))同样计算 1h、30m、6h 的比例后,Page 条件是:
text
(1h > 14.4 × 0.001 AND 5m > 14.4 × 0.001)
OR
(6h > 6 × 0.001 AND 30m > 6 × 0.001)这里窗口内是 AND,两套响应速度之间是 OR。完整 rules.yml 在文末,字段与表达式依据 Prometheus 官方文档;本次没有安装 Prometheus/promtool,没有执行真实规则引擎。Go 模型验证的是有限整数样本上的算术和窗口逻辑,不覆盖抓取外推、序列消失、规则评估时刻、重启和标签匹配的实际行为。
零流量和没有数据,都不是“服务正常”
模型另外输出:
text
zero_traffic: no traffic
missing: missing data
budget_fraction_at_threshold=0.020零流量没有比例分母;数据缺失甚至不知道有没有请求。把这两种情况统一填成零失败率,会把监控故障画成绿色。PromQL 中零分母、缺失序列的表现与这里的 Go error 不同,因此生产必须另行检查 up、关键序列缺失、抓取延迟和端到端探测。[2:1][5:1]
低流量服务还有统计问题:一小时只有一次请求,失败率在 0% 和 100% 间跳,没有足够样本让比例稳定。加最小流量门槛能减轻噪声,也会漏掉少量但重要的失败;可同时考虑合成探测、绝对失败数、业务时限和人工关注的关键事件,不能用一个流量门槛抹掉覆盖缺口。
收敛要减少重复通知,不能删掉独立故障
| 手段 | 做什么 | 适用场景 | 需要防住的风险 |
|---|---|---|---|
| Grouping 分组 | 把相关告警合成一条通知 | 同一服务、环境和告警名下多个实例异常 | 分得太粗把不同地区或不同事故混在一起 |
| Deduplication 去重 | 避免相同告警反复通知 | 多评估周期、重复上报 | 误把不同标签的影响当重复 |
| Inhibition 抑制 | 当更有解释力的告警存在时,压住相关通知 | 同一故障下低等级重复告警 | 依赖关系猜错,压住第二个独立事故 |
| Silence 静默 | 在限定时间和标签范围停止通知 | 已批准维护、正在处理的重复提醒 | 范围过大、没有到期时间、掩盖新影响 |
for 持续条件 | 等待规则连续满足 | 容忍可恢复短抖动 | 每次短暂清零都会重置等待,长期间歇问题漏报 |
Alertmanager 的 group_wait 是第一批通知前的等待,group_interval 管理同组更新,repeat_interval 管理重复通知;不要把三者都调大来“消除噪声”。值班人确认收到,也不意味着用户影响已经消失。[6]
文末的教学配置按 service, environment, alertname 分组,只让相同三个字段的 critical 告警抑制 warning;还要求服务和环境非空,避免缺失标签误匹配。它使用无发送渠道的 local-noop receiver,不能当成可用的值班通知配置。这里刻意不给真实电话、Webhook 或第三方账号。
抑制前应能回答:“被压住的这条告警若单独出现,是否还会被发现?”如果答案不确定,就先保留通知、改分组或缩小匹配条件。减少告警疲劳的目标是减少无用打扰,同时保留关键覆盖。
值班流程:谁在什么时候接手什么
On-Call 是一套交接与升级机制,不只是把某个人加入通知列表。Google 的值班与事故管理章节强调明确指挥、操作和沟通职责;小团队可以一人兼任,但发生复杂故障后仍应显式分工。[7][8]
| 阶段 | 当班者做什么 | 需要留下什么 |
|---|---|---|
| 接班前 | 检查排班、通知可达、权限、当前事故和临时措施 | 已知风险、静默到期时间、回滚限制 |
| 收到 Page | 确认收到,查看影响范围与监控是否可信 | 事件编号、主负责人、初步严重程度 |
| 止损 | 根据预案回滚、限流、降级或切换;记录假设和动作 | 操作时间、变更内容、执行者、结果 |
| 升级 | 超过响应时限、影响扩大、缺少权限或判断能力时找备份 | 已检查的证据与待决问题,避免让接手者重来 |
| 沟通 | 给有关人员更新影响、已采取动作和下一更新时间 | 不把猜测写成确定根因,不编造恢复时间 |
| 恢复后 | 按用户侧 SLI 验证,检查积压与数据一致性,解除临时措施 | 实际恢复时间、未清理风险、后续负责人 |
响应时限和通知频率是团队约定,应按业务风险制定。不要把本文的示例窗口或分组时间误当成已经批准的响应承诺。值班期间也不应为了“尽快恢复”随意执行没有权限的高风险操作;预案应提前说明哪些动作允许自动化,哪些必须升级审批。
如何知道告警真的变好了
先保留一次事故或合成回放的输入、期望触发时间和恢复时间,再做规则测试。至少包含持续故障、短暂尖峰、缓慢恶化、零流量、抓取缺失、Counter 重启、标签变化及恢复后反复抖动。Prometheus 提供 promtool test rules 来测试表达式与时序规则;上线前应在固定部署版本执行,而非用本文的 Go 模型代替。[9]
衡量改动时同时看四件事:有多少通知确实需要人处理;重要事故是否被覆盖;从用户受影响到通知有多长;恢复以后还多打扰多久。仅统计“本周少了多少条”会奖励把规则关掉,却看不见漏报。
每条长期无人采取动作的告警,都值得问清它为什么存在。可以改规则、补预案、调整等级或删除已经无意义的规则;对还存在的真实风险,必须先找到替代检测方式。疲劳来自噪声,也来自没有权力处理、没有备份和反复被叫醒却没有改进的值班安排。
依据与验证范围
以下一级资料于 2026-10-13 核对。Alertmanager 配置语义参考固定 0.28 文档,其余 Prometheus 官方动态文档需与实际部署版本复核。这里不声称 0.28 是当前最新版。
实测内容是 Go 模型中的燃尽率、双窗口、恢复、尖峰与无效输入;run/test/race/vet 均通过。YAML 是完整教学配置,未通过真实 Prometheus/Alertmanager 运行验收,没有发出通知。没有生产数据,无法报告本团队的误报率、覆盖率或值班负荷改善。
完整代码、测试与教学配置
把三个 Go 文件放在同一目录,执行 go run .、go test ./...、go test -race ./...、go vet ./...。负例在测试内构造并断言拒绝。两个 YAML 文件是部署前进一步做规则与路由测试的起点,不是运行 Go 模型的依赖。
go.mod
go.mod
module example.com/observability/alerts
go 1.27.0main.go
代码块 · 80 行 · 点击收起 / 展开
go
package main
import (
"errors"
"fmt"
"math"
)
type minute struct {
Total, Bad int
Present bool
}
// burn requires every expected bucket to be present. This is an offline model,
// not an implementation of Prometheus staleness, rate, or rule timing.
func burn(xs []minute, slo float64) (float64, error) {
if len(xs) == 0 || slo <= 0 || slo >= 1 || math.IsNaN(slo) {
return 0, errors.New("invalid input")
}
total, bad := 0, 0
for _, x := range xs {
if !x.Present {
return 0, errors.New("missing data")
}
if x.Total < 0 || x.Bad < 0 || x.Bad > x.Total {
return 0, errors.New("invalid counts")
}
total += x.Total
bad += x.Bad
}
if total == 0 {
return 0, errors.New("no traffic")
}
return (float64(bad) / float64(total)) / (1 - slo), nil
}
func page(xs []minute) (bool, float64, float64, error) {
if len(xs) != 60 {
return false, 0, 0, errors.New("need 60 minutes")
}
long, e := burn(xs, .999)
if e != nil {
return false, 0, 0, e
}
short, e := burn(xs[55:], .999)
if e != nil {
return false, 0, 0, e
}
return long > 14.4 && short > 14.4, long, short, nil
}
func scenario(total, bad int) []minute {
xs := make([]minute, 60)
for i := range xs {
xs[i] = minute{total, bad, true}
}
return xs
}
func main() {
active := scenario(1000, 20)
firing, long, short, err := page(active)
if err != nil {
panic(err)
}
fmt.Printf("ongoing long=%.2f short=%.2f page=%v\n", long, short, firing)
recovered := scenario(1000, 20)
for i := 55; i < 60; i++ {
recovered[i].Bad = 0
}
firing, long, short, err = page(recovered)
if err != nil {
panic(err)
}
fmt.Printf("recovered long=%.2f short=%.2f page=%v\n", long, short, firing)
_, _, _, err = page(scenario(0, 0))
fmt.Println("zero_traffic:", err)
missing := scenario(1000, 0)
missing[2].Present = false
_, _, _, err = page(missing)
fmt.Println("missing:", err)
fmt.Printf("budget_fraction_at_threshold=%.3f\n", 14.4*1/(30*24.0))
}main_test.go
代码块 · 37 行 · 点击收起 / 展开
go
package main
import (
"math"
"testing"
)
func TestWindows(t *testing.T) {
yes, l, s, e := page(scenario(1000, 20))
if e != nil || !yes || math.Abs(l-20) > 1e-8 || math.Abs(s-20) > 1e-8 {
t.Fatalf("%v %v %v %v", yes, l, s, e)
}
recovered := scenario(1000, 20)
for i := 55; i < 60; i++ {
recovered[i].Bad = 0
}
yes, _, _, e = page(recovered)
if e != nil || yes {
t.Fatal("recovered service paged")
}
spike := scenario(1000, 0)
spike[59].Bad = 100
yes, _, _, e = page(spike)
if e != nil || yes {
t.Fatal("isolated spike passed long window")
}
}
func TestInvalid(t *testing.T) {
for _, x := range [][]minute{nil, {{0, 0, true}}, {{1, 2, true}}, {{1, 0, false}}} {
if _, e := burn(x, .999); e == nil {
t.Fatalf("accepted %+v", x)
}
}
if _, e := burn(scenario(1, 0), 1); e == nil {
t.Fatal("accepted slo=1")
}
}rules.yml
代码块 · 43 行 · 点击收起 / 展开
yaml
groups:
- name: checkout-slo-demo
interval: 30s
rules:
- record: service:request_error_ratio:rate5m
expr: |
sum by (service, environment) (rate(demo_request_bad_total[5m]))
/
sum by (service, environment) (rate(demo_request_total[5m]))
- record: service:request_error_ratio:rate1h
expr: |
sum by (service, environment) (rate(demo_request_bad_total[1h]))
/
sum by (service, environment) (rate(demo_request_total[1h]))
- record: service:request_error_ratio:rate30m
expr: |
sum by (service, environment) (rate(demo_request_bad_total[30m]))
/
sum by (service, environment) (rate(demo_request_total[30m]))
- record: service:request_error_ratio:rate6h
expr: |
sum by (service, environment) (rate(demo_request_bad_total[6h]))
/
sum by (service, environment) (rate(demo_request_total[6h]))
- alert: ErrorBudgetBurn
expr: |
(
(service:request_error_ratio:rate1h > 14.4 * 0.001)
and
(service:request_error_ratio:rate5m > 14.4 * 0.001)
)
or
(
(service:request_error_ratio:rate6h > 6 * 0.001)
and
(service:request_error_ratio:rate30m > 6 * 0.001)
)
labels:
severity: critical
team: checkout
annotations:
summary: 'Error budget is burning for {{ $labels.service }}'
description: 'Check customer errors and recent deployments. Confirm affected routes before mitigation.'alertmanager.yml
yaml
# Teaching-only receiver: no notifications are sent anywhere.
route:
receiver: local-noop
group_by: [service, environment, alertname]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receivers:
- name: local-noop
inhibit_rules:
- source_matchers:
- 'severity="critical"'
- 'service=~".+"'
- 'environment=~".+"'
target_matchers:
- 'severity="warning"'
- 'service=~".+"'
- 'environment=~".+"'
equal: [service, environment, alertname]Google SRE:Monitoring distributed systems:症状、可操作告警和监控目的。 ↩︎
Prometheus alerting rules:pending/firing、
for、规则标签与注释。 ↩︎ ↩︎Google SRE Workbook:Implementing SLOs:SLI 与目标口径。 ↩︎
Google SRE Workbook:Alerting on SLOs:燃尽率、多窗口与低流量的取舍。 ↩︎
Prometheus query functions:rate、Counter 重置、缺失检测函数。 ↩︎ ↩︎
Alertmanager concepts;Alertmanager 0.28 configuration:分组、抑制、静默和通知间隔。 ↩︎
Google SRE:Being on-call:值班安排、负担与响应。 ↩︎
Google SRE:Managing incidents:指挥、沟通和操作职责。 ↩︎
Prometheus unit testing rules:规则测试工具及输入格式;本文未执行 promtool。 ↩︎