三百条告警的夜里
《裂隙战场》周末晚高峰,机房网络抖动持续了 4 分钟(示例值)。告警群在 10 分钟(示例值)内刷出 300 多条消息(示例值):网关超时、战斗服心跳丢失、匹配队列积压、支付回调失败、日志采集延迟,每条都带着不同的实例名和阈值。值班同学一边往上翻,一边处理最密集的“网关超时”,20 分钟(示例值)后才发现夹在中间的支付回调失败才是真正的 P0。
这次事故里,监控并没有漏掉任何东西。恰恰相反,它把所有东西都报了出来,却没有告诉值班员“先看哪一条”。告警设计的目标不是“发生异常就发消息”,而是“在正确的时间,把可行动的信息,交给正确的人”。
症状告警与原因告警
| 类型 | 例子 | 优点 | 风险 | 适合的去处 |
|---|---|---|---|---|
| 症状告警 | 登录失败率、匹配成功率、支付失败率 | 直接对应玩家影响 | 可能滞后、可能多因 | 呼叫值班 |
| 原因告警 | CPU 高、连接池满、依赖 5xx | 早于症状、指向明确 | 可能没有玩家影响 | 看板或工单 |
只有症状告警可以叫醒人。原因告警默认进看板或工单,除非已经证明它必然导致症状。把“CPU 超过 80%”设成 P0 是最常见的错误:容量正常的服务也会短时冲高,而真正该被叫醒的是“登录失败率超过阈值”。
反过来,只有症状告警也不行。症状出现时玩家已经受损,原因告警的价值是缩短定位时间。合理的组合是:症状告警负责触发,原因告警负责解释,两者用同一个 incident 关联起来。
分级与响应时限
| 级别 | 判定标准 | 响应时限 | 止损目标 | 通知方式 |
|---|---|---|---|---|
| P0 | 核心玩法或支付大面积不可用 | 5 分钟 | 15 分钟 | 电话 + 群 + 工单 |
| P1 | 部分玩家或单一模式受损 | 30 分钟 | 1 小时 | 群 + 电话备选 |
| P2 | 体验下降,无阻断 | 4 小时 | 下一个工作日 | 工单 |
表中的时限是示例值,必须和团队的实际容量匹配。一个只有几名工程师的团队承诺“P0 5 分钟响应”,但夜里只有一个人值班,这个承诺就是假的。承诺不了就不要写进制度;写了却做不到,比没有制度更伤信任。
分级还要回答“谁有权止损”。如果值班员只能重启服务,不能切流、不能回滚、不能关闭某个玩法,那么 P0 的 15 分钟目标也无法实现。权限和时限必须一起设计。
降噪四件套
| 手段 | 做法 | 误用后果 | 边界 |
|---|---|---|---|
| 去重 | 同一 fingerprint 在窗口内折叠,计数递增 | 窗口太长会漏掉复发 | 窗口按告警类型分别设置 |
| 聚合 | 同一服务、同一根因合并为一条 | 聚合过粗会隐藏不同故障 | 保留子事件列表 |
| 抑制 | 高等级原因告警抑制低等级症状告警 | 抑制过宽会吞掉真故障 | 只允许高抑制低、同服务 |
| 维护窗口 | 发布或活动期间静默 | 忘记结束会长期失明 | 必须有结束时间与 owner |
四件套的顺序很重要。先去重再抑制,重复告警才不会在抑制解除后集中爆发;先判断维护窗口再去重,维护期间的告警不会污染计数。顺序错了,降噪本身会制造新的噪声。
维护窗口是否豁免 P0,是一个必须提前决定的问题。本文的示例实现统一静默,P0 依赖人工值守兜底;更稳妥的做法是让 P0 绕过维护窗口,但需要额外的值班安排。两种都可以,不能“到时候再说”。
告警必须可行动
一条告警在发出前要能回答五个问题。答不上来的,应该降级为看板或直接删除。
| 检查项 | 不合格的表现 | 合格的标准 |
|---|---|---|
| Owner | 不知道谁负责 | 明确到团队,P0 明确到人 |
| Runbook | 只有“请排查” | 有步骤、命令、回滚与升级联系人 |
| 阈值 | 没有方向和时间窗 | 写明大于/小于、持续多久 |
| 影响 | 说不清玩家感受 | 能描述受影响的功能与规模 |
| 止损 | 没有可执行动作 | 有降级、限流、切流或回滚选项 |
| 恢复 | 只看指标回落 | 有验证玩家恢复的标准 |
Runbook 不是事故后补的文档,而是告警的一部分。没有 runbook 的告警,等于把排查成本转嫁给半夜被叫醒的人。
升级链与值班交接
| 时间 | 对象 | 动作 |
|---|---|---|
| 0 分钟 | 一线值班 | 确认告警、开始止损 |
| 5 分钟 | 二线或主管 | 未确认则升级 |
| 15 分钟 | 值班经理 | 未止损则升级 |
| 30 分钟 | 跨团队指挥 | 协调依赖方与对外沟通 |
时间是示例值。升级不是惩罚,而是让有决策权的人尽早进入。很多事故拖长,不是因为没人会修,而是因为能拍板回滚的人一直不知道。
值班交接要交四样东西:未闭环的告警、进行中的变更、已知风险和联系人。交接必须双人确认,不能只在群里发一句“我下班了”。新值班上岗先看过去一天的告警排行,知道哪些是噪声、哪些是尚未解决的老问题。
从告警到工单的闭环
告警恢复不等于事件关闭。一个完整的闭环是:告警触发 → 建立 incident → 止损 → 根因分析 → 行动项 → 验证 → 关闭。
其中最容易断的是“行动项”。复盘会开完,结论写在文档里,没有人认领,下个季度同样的故障再来一次。行动项必须有 owner、截止日期和验证方式,并且进入和功能需求同一个 backlog,而不是躺在复盘文档里。
工单系统还要能反查:一条告警对应哪个 incident、影响了哪些玩家、做了哪些止损动作。没有这层关联,季度复盘只能靠回忆。
数据模型与接口契约
代码把告警处理拆成四层:
Alert:告警的身份、级别、开始时间、owner、runbook 和“症状/原因”标记;Router:按维护窗口、去重窗口、抑制规则决定是否投递;Plan:升级链,每一步有等待时间和接手对象;Breach:按级别判断确认是否超时。
一个关键契约是:告警没有 owner 或 runbook 时直接拒绝,而不是“先发出去再说”。拒绝发生在入口,能逼着团队在告警上线前把行动方案写清楚。
另一个契约是 Decision 必须带原因。只返回“发/不发”不够,值班和复盘时需要知道它是被去重、被抑制、落在维护窗口,还是正常投递。原因字段也是降噪规则调试的唯一线索。
可运行片段(一):去重、抑制与维护窗口
router.go 把四件事按固定顺序处理:校验、维护窗口、去重、抑制。顺序不是随意的,后面读代码会解释。
// file: internal/alertoncall/router.go
// tier: L1
// verify: go test ./internal/alertoncall/
package alertoncall
import (
"fmt"
"time"
)
// Window is a maintenance interval: start inclusive, end exclusive.
type Window struct {
Start time.Time
End time.Time
}
func (w Window) Contains(t time.Time) bool {
return !t.Before(w.Start) && t.Before(w.End)
}
func (w Window) Validate() error {
if !w.Start.Before(w.End) {
return fmt.Errorf("alertoncall: window start must precede end")
}
return nil
}
type Policy struct {
DedupWindow time.Duration
MuteWindows []Window
InhibitLower bool
}
type Router struct {
policy Policy
seen map[string]time.Time
counts map[string]int
active map[string]Alert
}
func New(p Policy) (*Router, error) {
if p.DedupWindow < 0 {
return nil, fmt.Errorf("alertoncall: dedup window must be >= 0")
}
for _, w := range p.MuteWindows {
if err := w.Validate(); err != nil {
return nil, err
}
}
return &Router{
policy: p,
seen: make(map[string]time.Time),
counts: make(map[string]int),
active: make(map[string]Alert),
}, nil
}
// Decision explains why an alert was or was not delivered. Count is how many
// occurrences were folded into the current notification.
type Decision struct {
Deliver bool
Reason string
Count int
}
func (r *Router) Ingest(a Alert) (Decision, error) {
if err := a.Validate(); err != nil {
return Decision{}, err
}
for _, w := range r.policy.MuteWindows {
if w.Contains(a.StartedAt) {
return Decision{Reason: "maintenance-window", Count: 1}, nil
}
}
if prev, ok := r.seen[a.Key]; ok {
delta := a.StartedAt.Sub(prev)
if delta >= 0 && delta <= r.policy.DedupWindow {
r.counts[a.Key]++
return Decision{Reason: "deduplicated", Count: r.counts[a.Key]}, nil
}
}
if r.policy.InhibitLower && a.Symptom {
if sev, ok := r.highest(a.Service); ok && sev.rank() < a.Severity.rank() {
return Decision{Reason: "inhibited-by-" + string(sev), Count: 1}, nil
}
}
r.seen[a.Key] = a.StartedAt
r.counts[a.Key] = 1
r.active[a.Key] = a
return Decision{Deliver: true, Reason: "deliver", Count: 1}, nil
}
func (r *Router) highest(service string) (Severity, bool) {
var best Severity
found := false
for _, a := range r.active {
if a.Service != service {
continue
}
if !found || a.Severity.rank() < best.rank() {
best, found = a.Severity, true
}
}
return best, found
}
// Resolve removes a firing alert. Dedup history is kept so a flapping alert
// does not page again just because it recovered for one evaluation cycle.
func (r *Router) Resolve(key string) {
delete(r.active, key)
}
读代码:为什么顺序不能换
Ingest 先校验,再判断维护窗口。校验失败直接返回错误,不写入任何状态;如果先记录再去重,一条缺 runbook 的告警会占掉去重窗口,等它补全后反而被当成重复。
维护窗口排在去重之前。维护期间的告警不应该进入计数,否则窗口结束后,计数从一个很大的值开始,第一条真实告警会被误判为“已重复很多次”。
去重使用 delta >= 0 的判断。如果一条迟到的旧告警先到,delta 为负,不参与去重,避免时间倒流把新告警吞掉。代价是乱序场景下可能多投递一次,这个代价比漏报低。
抑制只作用于 Symptom 为真的告警,并且只在同一服务存在更高等级的活动告警时生效。原因告警永远不被抑制,因为它可能正是解释症状的线索。抑制方向只有“高抑制低”,不允许反向或同级互相抑制,否则容易形成环,最后全部静默。
Resolve 只删除活动告警,保留去重历史。一个告警恢复后立刻再次触发,如果历史被清空,它会重新呼叫;保留历史可以让它在窗口内继续折叠,避免抖动刷屏。
可运行片段(二):升级链与响应超时
escalation.go 定义升级链和确认超时。升级链的每一步都必须比上一步晚,且第一步从 0 开始。
// file: internal/alertoncall/escalation.go
// tier: L1
// verify: go test ./internal/alertoncall/
package alertoncall
import (
"fmt"
"time"
)
type Step struct {
After time.Duration
Target string
}
type Plan struct {
Steps []Step
}
func (p Plan) Validate() error {
if len(p.Steps) == 0 {
return fmt.Errorf("alertoncall: plan has no steps")
}
for i, s := range p.Steps {
if s.Target == "" {
return fmt.Errorf("alertoncall: step %d needs a target", i)
}
if i == 0 && s.After != 0 {
return fmt.Errorf("alertoncall: first step must start at zero")
}
if i > 0 && s.After <= p.Steps[i-1].After {
return fmt.Errorf("alertoncall: step %d must be later than step %d", i, i-1)
}
}
return nil
}
// At returns the step responsible after elapsed time. The last matching step
// wins, so the plan can move from team to lead to incident commander.
func (p Plan) At(elapsed time.Duration) (Step, bool) {
var out Step
found := false
for _, s := range p.Steps {
if elapsed >= s.After {
out, found = s, true
}
}
return out, found
}
// Next returns the next step and how long the caller must wait from now. ok is
// false when the plan is exhausted.
func (p Plan) Next(elapsed time.Duration) (Step, time.Duration, bool) {
for _, s := range p.Steps {
if s.After > elapsed {
return s, s.After - elapsed, true
}
}
return Step{}, 0, false
}
// Breach reports whether an alert was acknowledged later than its severity
// allows. A zero ack means it is still unacknowledged.
func Breach(a Alert, ackedAt, now time.Time) (bool, error) {
limit, err := a.Severity.Response()
if err != nil {
return false, err
}
if ackedAt.IsZero() {
return now.Sub(a.StartedAt) > limit, nil
}
return ackedAt.Sub(a.StartedAt) > limit, nil
}
读代码:升级链是状态机,不是通讯录
Plan.Validate 强制第一条的等待时间为 0,后续严格递增,目标不能为空。这三条约束把“升级链”从一张联系人表变成了可执行的状态机:任何时刻都有唯一的当前责任人,任何时刻都能算出下一步是谁、还要等多久。
At 返回最后一个满足 elapsed >= After 的步骤,所以时间越往后,责任级别越高。Next 只返回严格大于当前时间的步骤,避免在边界上重复升级。两者配合,值班系统可以在每次心跳时问“现在该谁”,而不需要自己维护计时器。
Breach 用严格大于判断超时。恰好卡在截止时间上的确认算按时,不算超时;这个边界看起来小,但决定了统计口径,必须在制度里写清楚。
失败模式与兜底
| 失败模式 | 兜底 |
|---|---|
| 阈值太敏感,告警疲劳 | 加持续时间,例如连续 5 分钟满足才触发 |
| 去重 key 含实例 ID | key 用服务 + 规则,不带实例 |
| 抑制规则成环 | 只允许高抑制低,单方向 |
| 维护窗口忘记结束 | 窗口必须有结束时间与 owner |
| 告警没有 runbook | 入口校验,CI 拦截 |
| 升级链没人接 | 电话轮值 + 备份联系人 |
| 告警恢复即关单 | 必须有根因与行动项才算关闭 |
常见错误与边界
| 常见错误 | 边界 |
|---|---|
| 把原因告警设成 P0 | 只有症状告警可以叫醒人 |
| 所有告警发同一个群 | 按级别与服务拆分投递 |
| 只设阈值不设持续时间 | 瞬时抖动会制造噪声 |
| 值班只挂名不交接 | 交接必须双人确认 |
| 升级链只有一级 | 无人响应时没有后备 |
| 用告警数量衡量监控质量 | 应看有效告警与止损时间 |
小结
告警设计的目标是“可行动”,不是“全覆盖”。症状告警负责触发,原因告警负责解释;P0/P1/P2 的时限要和团队容量匹配;去重、聚合、抑制、维护窗口要按固定顺序执行;每一条告警都要有 owner、runbook 和止损动作;升级链要让有决策权的人尽早进入。
这一节的 alertoncall 把去重、抑制、维护窗口、升级链和响应超时做成了可执行代码。下一章进入 SLO,讨论如何用本章的指标和告警数据,定义“服务到底该有多好”,以及错误预算怎么反过来约束发布节奏。
阅读导航:上一节:5.2 游戏专属指标:帧、房间、匹配、经济 · 下一节:6.1 游戏服务端怎么定义 SLO 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。