Skip to content

告警设计:让每一次叫醒都指向一件值得做的事 ​

“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[通知链路健康检查]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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.0

main.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]

  1. Google SRE:Monitoring distributed systems:症状、可操作告警和监控目的。 ↩︎

  2. Prometheus alerting rules:pending/firing、for、规则标签与注释。 ↩︎ ↩︎

  3. Google SRE Workbook:Implementing SLOs:SLI 与目标口径。 ↩︎

  4. Google SRE Workbook:Alerting on SLOs:燃尽率、多窗口与低流量的取舍。 ↩︎

  5. Prometheus query functions:rate、Counter 重置、缺失检测函数。 ↩︎ ↩︎

  6. Alertmanager concepts;Alertmanager 0.28 configuration:分组、抑制、静默和通知间隔。 ↩︎

  7. Google SRE:Being on-call:值班安排、负担与响应。 ↩︎

  8. Google SRE:Managing incidents:指挥、沟通和操作职责。 ↩︎

  9. Prometheus unit testing rules:规则测试工具及输入格式;本文未执行 promtool。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准