一、看板分三层,每层只回答一个问题
监控看板最常见的失败不是图画得不好看,而是所有信息堆在同一页:业务同学找不到在线人数,值班同学找不到哪个区服在报错,开发同学又看不到单进程的队列积压。解决办法是按使用者和问题分层。
| 层级 | 使用者 | 回答的问题 | 更新频率 |
|---|---|---|---|
| 业务总览 | 制作人、运营、值班主管 | 玩家现在能不能正常登录、匹配、完成对局 | 1 分钟 |
| 系统健康 | 值班开发、SRE | 服务是否健康,错误和延迟是否越界 | 30 秒到 1 分钟 |
| 单服下钻 | 负责该区服或模块的开发 | 哪个区服、哪个进程、哪个环节出了问题 | 15 秒到 1 分钟 |
三层之间要有链接关系:总览发现登录成功率下降,点进去能看到是哪个区服,再点进去能看到该区服的错误日志和追踪。看板不是三个孤立的页面,而是一条从“现象”到“证据”的路径。
二、业务总览:先看玩家是否玩得下去
业务总览不应该堆满日活、月活、付费率这类滞后指标。值班时最需要的是“现在有没有玩家玩不了”,因此第一屏应该放登录成功率、匹配成功率、对局完成率和在线人数。
| 指标 | 分子 | 分母 | 用途 |
|---|---|---|---|
| 登录成功率 | 登录成功的请求数 | 全部有效登录请求 | 判断入口是否可用 |
| 匹配成功率 | 成功进入对局的匹配请求 | 全部匹配请求 | 判断匹配池是否健康 |
| 对局完成率 | 正常结束的对局数 | 开始的对局数 | 判断战斗服是否稳定 |
| 在线人数 | 当前有效会话数 | 不适用 | 判断容量和活动效果 |
| 支付成功率 | 支付回调成功数 | 发起支付数 | 判断商业化链路 |
这些指标都要明确排除项:健康检查、机器人、压测流量、客户端主动取消。否则一次压测就能让登录成功率跌到看起来像线上故障。
三、系统健康:资源和错误率要一起看
系统健康层回答“服务本身是否正常”。CPU、内存、Goroutine 数、GC 暂停、文件描述符是资源视角;QPS、P99、错误率、队列积压、连接数是服务视角。两者要放在一起,因为资源正常不代表服务正常,服务正常也不代表没有隐患。
| 类别 | 指标 | 异常时先看什么 |
|---|---|---|
| 资源 | CPU、内存、Goroutine、GC | 是否有泄漏、是否有热点 |
| 流量 | QPS、连接数、带宽 | 是否突增、是否被攻击 |
| 延迟 | P50、P95、P99、tick 延迟 | 是普遍变慢还是长尾 |
| 错误 | 错误率、超时数、断线数 | 按错误码和区服拆分 |
| 积压 | 消息队列长度、待处理请求 | 消费者是否卡住 |
资源指标适合做容量规划,不适合单独做告警。CPU 高可能只是批处理任务在跑,CPU 低也可能是线程全部阻塞在锁上。告警应该优先看玩家可感知的症状。
四、单服下钻:把问题定位到区服和进程
单服下钻层按区服、分片、进程三个维度展开。第一列通常是区服列表,每行显示在线人数、登录成功率、对局完成率、错误率和 P99;点进某个区服后,再按进程或模块拆分。
下钻页要有对比基线:和昨天同一时段比、和其他区服比、和发布前比。没有基线的绝对值很难判断严重程度。比如某区服 P99 是 80 毫秒,看起来不高,但如果它平时是 20 毫秒,就已经是四倍劣化。
下钻页还要提供跳转:从错误率跳到日志查询,从慢请求跳到追踪,从发布标记跳到变更记录。值班同学不需要记住所有查询语句,只需要从看板点过去。
五、从指标到聚合:先算清楚分子分母
看板上的一个数字,往往来自多个分片的原始值。聚合方式不同,结论可能完全相反。
| 聚合方式 | 适用场景 | 陷阱 |
|---|---|---|
| Sum | 请求数、错误数、在线人数 | 分片重复上报会重复计数 |
| Avg | 平均延迟、平均队列长度 | 各分片权重不同,直接平均会失真 |
| Max | 最慢分片、最大积压 | 单个异常分片会拉高整体 |
| Min | 最低成功率、最少余量 | 容易忽略整体趋势 |
平均值尤其容易骗人。两个分片分别有 1000 个请求和 10 个请求,延迟分别是 100ms 和 1000ms,直接平均是 550ms,按请求数加权则约为 109ms。看板必须说明用的是哪种口径,告警规则也要用同一种口径。
六、用 Go 实现聚合与告警状态机
下面的代码把两件事拆开:Aggregator 保存每个分片的最新值并在读取时聚合;Evaluator 负责“超过阈值并持续一段时间才触发”,恢复时发出一次恢复事件。时间源可以注入,测试能精确推进时间,不依赖真实等待。
// file: ch15/dashboard/dashboard.go
// tier: L1
// verify: go test ./ch15/dashboard/
// Package dashboard 提供游戏监控看板所需的最小指标聚合与阈值告警状态机。
//
// Aggregator 负责把多个分片的最新值合并成 Count/Sum/Min/Max;
// Evaluator 负责“超过阈值并持续一段时间才触发”,并在恢复时
// 发出一次恢复事件。时间由外部注入,测试可以精确控制推进。
package dashboard
import (
"errors"
"math"
"sync"
)
var (
// ErrInvalidSeries 表示指标名或分片名为空。
ErrInvalidSeries = errors.New("dashboard: metric and shard names must not be empty")
// ErrNonFinite 表示聚合值不是有限数。
ErrNonFinite = errors.New("dashboard: non-finite value")
// ErrInvalidRule 表示告警规则缺少必要字段或存在冲突。
ErrInvalidRule = errors.New("dashboard: invalid alert rule")
)
// Aggregate 是一次快照中某个指标跨分片的聚合结果。
type Aggregate struct {
Count int
Sum float64
Min float64
Max float64
}
// Avg 返回算术平均值;Count 为 0 时返回 0。
func (a Aggregate) Avg() float64 {
if a.Count == 0 {
return 0
}
return a.Sum / float64(a.Count)
}
// Aggregator 按“指标 + 分片”保存最新值,读取时再聚合。
//
// 保存最新值而不是累加历史,是为了让看板展示“当前状态”;
// 历史趋势应该由 Prometheus 这类时序库负责,而不是看板进程。
type Aggregator struct {
mu sync.RWMutex
series map[string]map[string]float64
}
// NewAggregator 创建一个空聚合器。
func NewAggregator() *Aggregator {
return &Aggregator{series: make(map[string]map[string]float64)}
}
// Set 覆盖某个分片的指标值。
func (a *Aggregator) Set(metric, shard string, value float64) error {
if metric == "" || shard == "" {
return ErrInvalidSeries
}
if math.IsNaN(value) || math.IsInf(value, 0) {
return ErrNonFinite
}
a.mu.Lock()
defer a.mu.Unlock()
shards := a.series[metric]
if shards == nil {
shards = make(map[string]float64)
a.series[metric] = shards
}
shards[shard] = value
return nil
}
// Delete 移除某个分片;指标下没有分片时一并移除指标。
func (a *Aggregator) Delete(metric, shard string) {
a.mu.Lock()
defer a.mu.Unlock()
shards := a.series[metric]
if shards == nil {
return
}
delete(shards, shard)
if len(shards) == 0 {
delete(a.series, metric)
}
}
// Snapshot 返回所有指标的聚合副本。
func (a *Aggregator) Snapshot() map[string]Aggregate {
a.mu.RLock()
defer a.mu.RUnlock()
out := make(map[string]Aggregate, len(a.series))
for metric, shards := range a.series {
agg := Aggregate{Min: math.Inf(1), Max: math.Inf(-1)}
for _, value := range shards {
agg.Count++
agg.Sum += value
if value < agg.Min {
agg.Min = value
}
if value > agg.Max {
agg.Max = value
}
}
if agg.Count == 0 {
agg.Min, agg.Max = 0, 0
}
out[metric] = agg
}
return out
}
// Metric 返回单个指标的聚合结果。
func (a *Aggregator) Metric(metric string) (Aggregate, bool) {
agg, ok := a.Snapshot()[metric]
return agg, ok
}
完整包还包含 evaluator.go(规则校验与状态查询),这里展开聚合与观测的主路径。
聚合器只保存最新值,不保存历史。历史趋势应该由 Prometheus 这类时序库负责,看板进程的职责是回答“现在是多少”。快照时同时计算 Count、Sum、Min、Max,平均值由调用方决定是否使用,避免把“平均的平均”藏进实现里。
// file: ch15/dashboard/observe.go
// tier: L1
// verify: go test ./ch15/dashboard/
package dashboard
import (
"math"
"time"
)
// Observe 提交某个指标的一次观测,返回本次产生的状态迁移事件。
//
// 高于等于阈值时进入 pending;持续达到 For 才转为 firing,
// 且只通知一次。低于阈值时,firing 会发出一次恢复事件,
// pending 则静默复位,避免短时抖动产生告警。
func (e *Evaluator) Observe(metric string, value float64) []Event {
if math.IsNaN(value) || math.IsInf(value, 0) {
return nil
}
e.mu.Lock()
indices := e.byMetric[metric]
if len(indices) == 0 {
e.mu.Unlock()
return nil
}
now := e.now()
var events []Event
for _, idx := range indices {
rule := e.rules[idx]
rs := &e.states[idx]
if value >= rule.Threshold {
if rs.state == StateOK {
rs.state = StatePending
rs.since = now
}
if rs.state == StatePending && now.Sub(rs.since) >= rule.For {
rs.state = StateFiring
events = append(events, Event{
Rule: rule.Name, Metric: metric, State: StateFiring, Value: value, At: now,
})
}
continue
}
if rs.state == StateFiring {
events = append(events, Event{
Rule: rule.Name, Metric: metric, State: StateOK, Value: value, At: now,
})
}
rs.state = StateOK
rs.since = time.Time{}
}
notify := e.notify
e.mu.Unlock()
if notify != nil {
for _, event := range events {
notify(event)
}
}
return events
}
告警状态机有三个状态:ok、pending、firing。高于等于阈值时进入 pending 并记录起始时间;持续达到 For 才转为 firing,且只通知一次;低于阈值时,firing 发出一次恢复事件,pending 则静默复位。这样短时抖动只会让状态在 pending 和 ok 之间切换,不会打扰值班。
回调在锁外执行,避免回调里写日志或发消息时再次进入状态机造成死锁。Observe 同时返回本次产生的事件,调用方既可以注册回调,也可以直接处理返回值。
七、症状告警优于原因告警
原因告警指向“可能的原因”,症状告警指向“玩家已经受到影响”。CPU 高、GC 频繁、某个下游超时,都是原因;登录失败率上升、对局完成率下降,才是症状。
| 告警 | 类型 | 是否应该直接呼叫值班 |
|---|---|---|
| 登录成功率低于 99% 持续 5 分钟 | 症状 | 是 |
| 对局完成率低于 99% 持续 5 分钟 | 症状 | 是 |
| 单区服 P99 超过 500ms 持续 10 分钟 | 症状 | 是 |
| CPU 超过 80% 持续 5 分钟 | 原因 | 通常否,作为线索 |
| GC 暂停超过 100ms | 原因 | 通常否,先看是否影响延迟 |
| 某个下游错误率上升 | 原因 | 视依赖重要性而定 |
原因告警不是不能有,而是不应该直接升级为最高级别。它们更适合放在系统健康层作为线索,或者通过抑制规则挂到症状告警下面。值班被 CPU 告警叫醒,却发现玩家一切正常,这种告警会很快失去信任。
八、持续时长、抑制与静默
持续时长解决“瞬时抖动”。阈值刚被触碰时先进入 pending,持续一段时间仍不恢复才触发。持续时长太短会刷屏,太长会延迟发现故障,通常根据业务容忍度选择 2 到 10 分钟。
抑制解决“告警风暴”。当登录成功率已经触发时,可以抑制同一区服的“下游超时”“错误率上升”等原因告警,避免一个故障产生几十条通知。抑制规则要谨慎,不能把真正独立的故障一起压掉。
静默解决“计划内变更”。发布、维护、压测期间可以临时静默指定告警,但必须设置过期时间,并在变更记录里写明原因。永久静默等于删除告警,只是没人记得。
| 机制 | 解决的问题 | 配置要点 |
|---|---|---|
| 持续时长 | 瞬时抖动 | 与 SLO 窗口匹配,避免过长 |
| 抑制 | 告警风暴 | 只抑制从属告警,保留主症状 |
| 静默 | 计划内变更 | 必须有过期时间和原因 |
| 分组 | 同类告警合并 | 按区服、服务、故障域分组 |
九、SLO 与错误预算在游戏场景的落地
SLO 是把“玩家体验”翻译成可计算的承诺。游戏服常用的两个 SLO 是登录成功率和对局完成率。登录成功率可以定义为“成功登录请求 / 有效登录请求”,对局完成率可以定义为“正常结束对局 / 开始对局”。
| SLO | 目标示例 | 统计窗口 | 错误预算 |
|---|---|---|---|
| 登录成功率 | 99.9% | 30 天 | 约 43 分钟不可用 |
| 对局完成率 | 99.5% | 30 天 | 约 3.6 小时不可用 |
| 支付成功率 | 99.9% | 30 天 | 约 43 分钟不可用 |
这些是示例值,不是所有游戏都适用的标准。重要的是错误预算的用法:预算充足时,发布可以更激进;预算消耗过快时,暂停非必要变更,把精力放在稳定性上。错误预算不是考核指标,而是团队对风险的共同语言。
多窗口燃烧率比单窗口更实用。短窗口发现突发故障,长窗口发现缓慢劣化,两者同时越界才升级告警,可以减少误报。
十、失败场景:没有 For 的 CPU 告警
某区服配置了一条“CPU 超过 80% 立即告警”的规则。某天凌晨,一个定时补偿任务运行了 40 秒,CPU 冲到 85%,告警立刻呼叫值班。值班起床、登录、查看看板,发现玩家在线和对局都正常,任务已经结束。
| 时间 | 事件 | 后果 |
|---|---|---|
| T+0s | 补偿任务启动 | CPU 升到 85% |
| T+5s | 规则立即触发 | 呼叫值班 |
| T+40s | 任务结束 | CPU 恢复 |
| T+3min | 值班登录看板 | 找不到异常 |
| T+5min | 值班关闭告警 | 信任被消耗 |
修复不是简单把阈值调到 90%,而是三件事一起做:给规则加持续时长、把 CPU 告警降级为线索、增加登录成功率和 P99 的症状告警。CPU 仍然值得看,但它不应该拥有直接叫醒人的权限。
十一、值班可执行性检查清单
一条告警只有能指导动作才有价值。上线前逐条检查:
- 告警名称是否说明了现象,而不是只写“CPU 高”。
- 是否写清楚了影响范围:全区服、单区服、单进程。
- 是否附带了看板链接和日志/追踪查询入口。
- 是否有明确的处置步骤,第一步做什么、升级给谁。
- 是否有持续时长、抑制和静默配置。
- 是否在最近一次演练中验证过会触发、也能恢复。
- 是否区分了“需要立刻处理”和“工作时间处理”。
如果一条告警无法回答“我现在该做什么”,它就不应该出现在值班手机上。
小结
看板分三层:业务总览回答玩家能不能玩,系统健康回答服务是否正常,单服下钻回答问题出在哪个区服和进程。聚合要先明确分子分母和权重,告警要优先看症状而不是原因,并用持续时长、抑制和静默控制噪声。SLO 和错误预算把玩家体验变成可讨论的风险语言,而每一条告警都必须可执行。
常见错误与边界
- 看板把所有指标堆在一页,没有分层,也没有从总览到下钻的路径。
- 告警只看 CPU、内存等资源指标,玩家已经登录失败却没人知道。
- 规则没有持续时长,瞬时抖动反复呼叫值班,最终被整体静音。
- 直接平均各分片的延迟,没有按请求数加权,结论与真实体验相反。
- 告警没有看板链接和处置步骤,值班只能凭经验猜。
- 抑制规则过宽,把真正独立的故障一起压掉。
- 静默没有过期时间,计划内变更结束后告警仍然不响。
- 把错误预算当成绩效考核,团队倾向于隐藏故障而不是暴露问题。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。