《游戏服务端架构·平台化与架构治理》5.3 告警设计与值班

一次网络抖动,值班员收到三百条告警,真正的 P0 被埋在中间。告警设计的问题往往不是阈值不准,而是没有分级、去重、抑制和升级路径。本文把症状告警与原因告警分开,给出 P0/P1/P2 的响应时限、维护窗口、值班交接与 runbook 检查表,并用 Go 实现告警去重、抑制、升级链和响应超时判定。

三百条告警的夜里

《裂隙战场》周末晚高峰,机房网络抖动持续了 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 含实例 IDkey 用服务 + 规则,不带实例
抑制规则成环只允许高抑制低,单方向
维护窗口忘记结束窗口必须有结束时间与 owner
告警没有 runbook入口校验,CI 拦截
升级链没人接电话轮值 + 备份联系人
告警恢复即关单必须有根因与行动项才算关闭

常见错误与边界

常见错误边界
把原因告警设成 P0只有症状告警可以叫醒人
所有告警发同一个群按级别与服务拆分投递
只设阈值不设持续时间瞬时抖动会制造噪声
值班只挂名不交接交接必须双人确认
升级链只有一级无人响应时没有后备
用告警数量衡量监控质量应看有效告警与止损时间

小结

告警设计的目标是“可行动”,不是“全覆盖”。症状告警负责触发,原因告警负责解释;P0/P1/P2 的时限要和团队容量匹配;去重、聚合、抑制、维护窗口要按固定顺序执行;每一条告警都要有 owner、runbook 和止损动作;升级链要让有决策权的人尽早进入。

这一节的 alertoncall 把去重、抑制、维护窗口、升级链和响应超时做成了可执行代码。下一章进入 SLO,讨论如何用本章的指标和告警数据,定义“服务到底该有多好”,以及错误预算怎么反过来约束发布节奏。

阅读导航:上一节:5.2 游戏专属指标:帧、房间、匹配、经济 · 下一节:6.1 游戏服务端怎么定义 SLO 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查