凌晨两点,告警群里的“这个要不要叫人”
「回声」的告警群里有 47 个人,包括研发、运维、客服主管和两位产品经理。凌晨两点十分,支付回调服务开始返回错误,群里的机器人连续推送了十几条消息:下游超时、重试队列堆积、回调失败率上升。值班同学在群里问了一句:“这个要不要叫人?”
没有人立刻回答。有人说“先看看是不是对方抖动”,有人说“失败率才百分之零点几,没到阈值”,还有人说“等早上再看,客服还没上班”。二十分钟后,回调失败率回落,群里安静了。第二天上午九点,客服收到大量投诉:用户付了钱,订单还是待支付;商家看到订单,却收不到款。此时距离第一声告警已经过去近七个小时。
事后统计,受影响用户约三万人,涉及金额约两百万元(案例假设值)。从技术指标看,HTTP 5xx 比例只有 0.2%,远低于 5% 的告警阈值;从业务指标看,这是一个标准的大面积支付故障。两套指标给出了相反的结论,而值班同学只能看到技术指标。
这一节要解决的不是“怎么让告警更灵敏”,而是先回答:一个故障到底该由谁、在多长时间内、以什么方式响应。分级不是给事故贴标签,而是决定资源如何被唤醒。
分级看影响面,不看告警条数
很多团队的分级规则写在告警系统里:CPU 超过 90% 是 P1,错误率超过 5% 是 P0。这套规则的问题是,它描述的是机器状态,不是用户状态。机器很忙但用户无感,和机器很闲但用户无法付款,应该得到完全不同的响应。
「回声」最终把分级输入收敛成三个字段:受影响用户数、预计资金损失、持续分钟数。前两个决定初始等级,第三个用于事故记录和后续升级。等级与响应的对应关系如下:
| 等级 | 用户影响 | 资金影响 | 响应方式 | 是否呼人 | 典型场景 |
|---|---|---|---|---|---|
| sev1 | 10 万以上 | 10 万元以上 | 立即成立应急群,15 分钟内到场 | 是 | 核心交易全面不可用 |
| sev2 | 1 万以上 | 1 万元以上 | 30 分钟内到场,同步业务方 | 是 | 支付回调大面积失败 |
| sev3 | 100 以上 | 1000 元以上 | 工作时间处理,进工单 | 否 | 单个功能降级、部分报表失败 |
| sev4 | 100 以下 | 1000 元以下 | 记录,按迭代修复 | 否 | 个别用户报错、样式异常 |
表里的数字都是案例假设值,不是行业标准。真正的阈值要由产品、客服、财务和工程一起校准:同样是一万用户,对社交产品和对 B 端 SaaS 的意义完全不同;同样是一千元,对客单价十万元的产品可能只是毛毛雨,对九块九的产品可能是大面积退款。
分级表最重要的不是数字,而是“取更严重的一档”。用户影响和资金影响任意一个达到阈值,就应该升级,而不是等两个都达到。支付回调故障就是一个典型:受影响用户数够 sev1,资金损失也够 sev1,但 HTTP 错误率很低,如果只看技术指标,它会被判成 sev4。
持续时长为什么不在首次定级里降档
有人会问:如果只影响了一分钟,也要按 sev1 叫人吗?答案是首次定级不降档,但可以在确认影响范围后由值班负责人降级,并留下记录。原因很简单:故障刚发生时,没有人知道它会持续一分钟还是六小时。先按最坏情况叫人,确认后再降级,成本是一次被打断的睡眠;先按乐观情况不叫人,代价可能是六小时的业务损失。
持续时长更适合用于“升级”而不是“降级”。一个 sev3 如果持续两个小时仍未缓解,应该自动升为 sev2;一个 sev2 如果影响到资金对账,应该升为 sev1。时长是加速度,不是减震器。
呼人规则要能自动执行
分级表如果只存在于文档里,值班同学仍然要在群里问“要不要叫人”。呼人必须是一条自动规则:给定等级,系统直接决定是否打电话、发短信、拉群,而不是等人判断。人可以做的是“确认后升级或降级”,不是“从零开始决定要不要响应”。
自动呼人还需要一条兜底:如果 sev1 在 15 分钟内没有人认领,自动升级到第二值班人;如果第二值班人也没有响应,升级到技术负责人。这条链路要定期演练,否则真出事时才发现通讯录里的号码已经离职。
谁在事故里做决定
自动呼人解决“把人叫醒”,但没有解决“谁做决定”。群里人越多,越容易出现三种场面:每个人都在提建议,没有人拍板;有人主张回滚,有人主张扩容,争论十分钟;客服问“怎么回复客户”,工程答“还在看”。这些都不是技术问题,而是角色缺失。
一个最小可用的应急角色只有三个:事故负责人负责拍板和对外同步,技术负责人负责定位与恢复,沟通负责人负责客服、商务和管理层的口径。三个角色可以由两三个人兼任,但必须在事故开始时明确写出来。没有明确角色时,默认由第一个到场的高级工程师担任事故负责人,直到有人接手。
角色明确之后,分级才有意义。sev1 和 sev2 需要三个角色同时在线,sev3 只需要一个 owner,sev4 不需要应急群。角色和等级绑定,能避免“所有人都进来但没人负责”的混乱。
升级和降级都要留记录。什么时间、依据什么影响面、由谁决定,这三项必须写进事故时间线。没有记录,事后复盘就无法判断“当时为什么按 sev2 响应”,也无法校准阈值。记录不是形式主义,它是下一次定级的训练数据。
阈值校准最好每季度做一次,而不是只在事故后临时调整。
一次被技术指标掩盖的支付故障
回到开头的事故。支付回调服务的错误率之所以只有 0.2%,是因为大部分回调请求仍然返回了 HTTP 200,只是响应体里写着“渠道处理失败”。监控系统按 HTTP 状态码统计,把这些请求算成了成功;业务侧的订单状态没有推进,但技术侧的 SLI 看起来很正常。
更麻烦的是,失败请求集中在少数渠道。整体 QPS 很大,失败请求被稀释成很小的比例;如果按渠道拆分,某个渠道的失败率已经超过 40%。告警规则没有按渠道维度配置,值班同学看到的是一张“总体平稳”的曲线。
凌晨两点二十分,有人提议“先重启一下回调服务”。重启后,积压的回调被重新消费,短时间内失败率看起来下降了,大家以为问题缓解。实际上,重启只是把内存里的重试队列清空,未处理的消息被丢回队列,稍后又堆积起来。没有一个人意识到,此时已经有超过两万笔订单处于“用户已付款、系统未入账”的状态。
直到上午九点客服上班,问题才被真正看见。客服主管在群里发了一张截图:十七个商家在问同一件事。值班同学这才去查订单表,发现待支付订单数量是平日的三十倍。从第一声告警到成立应急群,过去了六小时四十分钟。
复盘时,团队列出的直接原因有三条:监控按 HTTP 状态码判断成功,没有校验业务响应体;告警没有按渠道维度拆分;值班同学没有权限查看订单状态。更深层的原因是分级规则只看技术指标,没有把“用户是否完成支付”作为输入。如果分级规则要求值班同学在五分钟内回答“影响了多少用户、多少钱”,这个故障不可能被拖到早上。
改进动作包括:把支付回调的成功定义改为“订单状态推进成功”,而不是 HTTP 200;按渠道配置失败率告警;把受影响用户数和预估金额接进告警消息;sev1/sev2 的呼人链路改为自动执行并每月演练。每一条都有 owner 和截止时间,而不是“加强监控”。
用影响面计算等级与呼人
配套代码 internal/slo/incident.go 只做两件事:根据影响面给出初始等级,根据等级决定是否呼人。它不读告警系统,也不发消息。把判断收敛成纯函数,是为了让“这个故障该不该叫人”在事故前就能被测试,而不是在凌晨两点靠感觉。
// file: internal/slo/incident.go
// tier: L1
// verify: go test ./internal/slo/
package slo
// Severity 是故障等级,数字越小越严重。
// sev1 表示核心链路大面积不可用,sev4 表示没有用户可感知影响。
type Severity int
const (
Sev1 Severity = iota + 1
Sev2
Sev3
Sev4
)
// Impact 是判断故障等级时使用的三个输入。
// Users 是受影响的去重用户数,RevenueCents 是预计损失金额(分),
// Minutes 是影响持续分钟数,用于事故记录和事后定级复核。
type Impact struct {
Users int64
RevenueCents int64
Minutes int
}
// Classify 按用户影响与资金影响给出初始等级,取两者中更严重的一档。
// 阈值是案例假设值,必须由产品、客服和财务一起校准,不能照搬。
// 初始定级只看影响面;持续时长用于后续升级,不在首次定级时降档。
func Classify(i Impact) Severity {
users := i.Users
if users < 0 {
users = 0
}
revenue := i.RevenueCents
if revenue < 0 {
revenue = 0
}
switch {
case users >= 100000 || revenue >= 10000000:
return Sev1
case users >= 10000 || revenue >= 1000000:
return Sev2
case users >= 100 || revenue >= 100000:
return Sev3
default:
return Sev4
}
}
// Page 决定是否立即呼叫值班人员。
// 只有 sev1 和 sev2 打断休息时间;sev3 进工单,sev4 不触发告警。
// 未知等级一律不呼人,避免错误输入造成半夜误报。
func Page(s Severity) bool {
return s == Sev1 || s == Sev2
}
几个设计点值得展开。
Severity 用从 1 开始的整数,而不是字符串。整数便于比较和排序,也便于在告警系统里做“大于等于 sev2 才呼人”这类判断。字符串看起来更可读,但一旦拼写不一致,比较就会静默失效。
Impact 只放影响面,不放 CPU、错误率、延迟这些技术指标。这不是说技术指标没用,而是它们不应该直接决定等级。技术指标适合用来发现异常和定位原因,等级应该由异常造成的用户与资金后果决定。把两类指标混在一个结构里,很容易又回到“CPU 高就叫人”的老路。
Classify 对负数做了归零处理。上游可能传入负数:统计口径错误、时区问题、去重逻辑把用户数算成了负值。负数不应该让等级变得“更轻”,也不应该让函数 panic;归零后按“无影响”处理,同时上游应该记录并修复数据质量问题。
Classify 使用 || 而不是 &&。用户影响和资金影响任意一个达到阈值就升级,因为两个维度衡量的是不同的伤害:大量用户无法使用,和小部分用户损失大额资金,都应该被认真对待。
Page 对未知等级返回 false。这是一个偏保守的选择:如果传入 0 或 99,说明调用方有 bug,贸然呼人会造成误报;不呼人则可能在真故障时漏报。两害相权,选择不呼人,但调用方应该在入口校验等级,而不是把未知值传进来。
代码没有解决“影响面怎么统计”。用户数要去重、资金损失要按订单金额而不是请求数、时间窗口要和故障窗口对齐。这些统计逻辑比等级判断更容易出错,也更需要和业务方对齐口径。纯函数只能保证“给定影响面,等级和呼人结果一致”,不能保证影响面本身可信。
测试:阈值、组合与未知等级
等级判断最怕两种情况:边界值算错,以及两个维度组合时取错档。下面的测试把每个阈值、组合情况和未知等级都固定下来。
// file: internal/slo/incident_test.go
// tier: L1
// verify: go test ./internal/slo/
package slo
import "testing"
func TestClassify(t *testing.T) {
cases := []struct {
name string
in Impact
want Severity
}{
{"大量用户受影响", Impact{Users: 100000}, Sev1},
{"大额资金损失", Impact{RevenueCents: 10000000}, Sev1},
{"中等用户规模", Impact{Users: 10000}, Sev2},
{"中等资金损失", Impact{RevenueCents: 1000000}, Sev2},
{"小范围用户", Impact{Users: 100}, Sev3},
{"小额资金损失", Impact{RevenueCents: 100000}, Sev3},
{"几乎无影响", Impact{Users: 99, RevenueCents: 99999}, Sev4},
{"无影响", Impact{}, Sev4},
{"负值按零处理", Impact{Users: -5, RevenueCents: -100}, Sev4},
{"用户与资金取更严重档", Impact{Users: 100, RevenueCents: 10000000}, Sev1},
}
for _, c := range cases {
if got := Classify(c.in); got != c.want {
t.Fatalf("%s: Classify(%+v) = %d, want %d", c.name, c.in, got, c.want)
}
}
}
func TestPage(t *testing.T) {
if !Page(Sev1) {
t.Fatal("sev1 must page")
}
if !Page(Sev2) {
t.Fatal("sev2 must page")
}
if Page(Sev3) {
t.Fatal("sev3 must not page")
}
if Page(Sev4) {
t.Fatal("sev4 must not page")
}
if Page(Severity(0)) {
t.Fatal("unknown severity must not page")
}
}
TestClassify 的表驱动写法把“用例名、输入、期望”放在一起,失败时能直接看出是哪条规则错了。每个阈值都测了刚好达到和刚好差一点两种情况,例如 100000 是 sev1、99999 配 99999 分是 sev4。边界值是最容易写错 > 和 >= 的地方。
“用户与资金取更严重档”这条用例专门锁住组合逻辑:用户数只够 sev3,但资金损失够 sev1,结果必须是 sev1。如果实现里误用了 &&,这条用例会立刻失败。类似的组合还应该覆盖“用户 sev2、资金 sev3”和“用户 sev1、资金 sev4”,核心是确认任意一个维度都能独立升级。
“负值按零处理”锁住数据质量问题的行为。测试不期望报错,因为等级判断不应该因为上游传了一个负数就中断;但生产环境应该对负值单独告警,说明统计链路有问题。
TestPage 覆盖了四个已知等级和未知等级 0。未知等级不呼人是一个产品决策,测试把它固定下来,避免以后有人“顺手”改成“未知也呼人”却没有评估误报成本。
测试没有覆盖 Minutes 字段,因为当前实现不参与首次定级。这并不意味着它不重要:持续时长应该由告警系统在事故进行中触发升级,而不是由 Classify 在第一次调用时决定。如果未来把时长纳入定级,必须补充“短时高影响”和“长时低影响”两类用例。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/slo/ -run 'TestClassify|TestPage' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestClassify
--- PASS: TestClassify (0.00s)
=== RUN TestPage
--- PASS: TestPage (0.00s)
PASS
ok webapp/internal/slo 0.239s
两个用例全部通过,退出码为 0。-run 只筛选了故障分级相关的用例;同包还有 SLO 计算和复盘动作的测试,需要全量确认时去掉 -run 再跑一次。
小结
故障分级的输入应该是用户影响和资金影响,而不是 CPU、错误率或告警条数。技术指标负责发现异常,影响面负责决定响应。两个维度任意一个达到阈值就升级,首次定级不因时长短而降档,确认后再由负责人降级并留痕。
呼人规则必须自动执行,并且有超时升级链路。规则写在文档里、靠值班同学在群里问“要不要叫人”,等于把最需要速度的决定交给了最疲惫的人。等级判断写成纯函数,是为了让规则在事故前就能被测试和演练。
下一节讨论事故之后的复盘:分级和应急解决“当下怎么办”,复盘要解决“下次怎么不再发生”,而这两者之间最容易断掉的环节,是改进动作没有 owner 和截止时间。
阅读导航:上一节:6.1 SLO 与错误预算 · 下一节:6.3 复盘与改进闭环 。
常见错误与边界
- 用技术指标直接定级。CPU 高但用户无感,和错误率低但用户无法付款,响应应该完全不同。
- 只看整体比例,不看维度拆分。整体 0.2% 的失败率,拆到单个渠道可能是 40%。
- 把 HTTP 200 当成业务成功。响应体里的失败码不会被状态码统计捕获。
- 用
&&组合用户与资金影响。任意一个维度达到阈值就应该升级。 - 首次定级按时长降档。故障初期没人知道会持续多久,应先按最坏情况响应。
- 呼人规则靠人工判断。群里问“要不要叫人”的每一分钟,都是故障在扩大。
- 没有超时升级链路。第一值班人没响应时,故障就停在那里了。
- 未知等级默认呼人或默认不呼人却不校验。两种做法都要有明确理由和入口校验。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。