《游戏服务端编程实践》15.4 实战:构建游戏监控看板

按业务总览、系统健康、单服下钻三层拆解游戏监控看板,说明症状告警优于原因告警、持续时长与抑制静默的配合、登录成功率和对局完成率的 SLO 与错误预算落地,并用 Go 实现跨分片指标聚合与带持续时长、恢复通知的阈值告警状态机,最后给出值班可执行性检查清单。

一、看板分三层,每层只回答一个问题

监控看板最常见的失败不是图画得不好看,而是所有信息堆在同一页:业务同学找不到在线人数,值班同学找不到哪个区服在报错,开发同学又看不到单进程的队列积压。解决办法是按使用者和问题分层。

层级使用者回答的问题更新频率
业务总览制作人、运营、值班主管玩家现在能不能正常登录、匹配、完成对局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、内存等资源指标,玩家已经登录失败却没人知道。
  • 规则没有持续时长,瞬时抖动反复呼叫值班,最终被整体静音。
  • 直接平均各分片的延迟,没有按请求数加权,结论与真实体验相反。
  • 告警没有看板链接和处置步骤,值班只能凭经验猜。
  • 抑制规则过宽,把真正独立的故障一起压掉。
  • 静默没有过期时间,计划内变更结束后告警仍然不响。
  • 把错误预算当成绩效考核,团队倾向于隐藏故障而不是暴露问题。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist