一个全部绿色的看板
《群岛纪元》大版本上线后,运营看板一切正常:同时在线 10.2 万(示例值),比上周还高;开放房间 1.8 万(示例值),与历史持平;平均匹配时长 8 秒(示例值),在阈值内。但社区里全是“排队排了半分钟”“进去一个人都没有”的抱怨。把指标拆开以后,问题才露出来:匹配时长 p99 是 95 秒(示例值),空房率 62%(示例值),同时在线被大量挂机小号拉高,真正在对局里的玩家反而下降了。四个“正常”指标,没有一个回答玩家体验。
指标不是把数字画成线。指标是一份定义:分子是什么、分母是什么、在哪个窗口、按什么维度切。定义错了,看板越漂亮,误导越强。
口径先于图表:四个问题
| 问题 | 常见错误 | 后果 | 正确做法 |
|---|---|---|---|
| 分子分母 | 成功率用成功数除以在线人数 | 分母漂移,趋势失真 | 分母用尝试数,并说明取消是否算失败 |
| 时间窗 | 瞬时值与小时均值混用 | 活动洪峰被抹平 | 发现用短窗,容量用长窗,图表标注窗口 |
| 聚合维度 | 只算全局平均 | 分区、分段问题被掩盖 | 按服、模式、MMR 段分别看 |
| 平均与分位 | 只报平均延迟 | 长尾被快请求拉低 | 同时报 p50、p95、p99 |
四个问题里,分母最容易被忽略。一个指标如果没有写清分母,它就不是“率”,只是一个计数。
帧与同步:先分清服务端 tick 和客户端帧率
服务端关心的是“这一帧有没有在预算内算完”,客户端关心的是“画面顺不顺”。两者会互相影响,但不能合成一个“卡顿”指标。
| 指标 | 定义 | 窗口 | 示例阈值 | 失败症状 |
|---|---|---|---|---|
| tick p99 | 单帧服务端耗时 p99 | 1 分钟 | 预算 33ms 时 p99 ≤ 50ms | 技能判定延迟、状态不同步 |
| late tick ratio | 超预算帧数 / 总帧数 | 5 分钟 | ≤ 0.1% | 偶发但集中的卡顿 |
| rollback frames | 回滚帧数 / 总帧数 | 1 分钟 | 大于 0 必须能解释 | 客户端预测频繁被推翻 |
| input latency p95 | 输入到服务端确认的 p95 | 5 分钟 | ≤ 120ms | 操作“粘手” |
| tick jitter | 帧间隔标准差 / 中位数 | 15 分钟 | 突增即异常 | 调度抖动、GC 或邻居干扰 |
tick p99 和 late tick ratio 必须一起看。p99 高但 late ratio 低,说明只有极少数帧慢,可能是单房间的异常逻辑;两者同时高,才更像全局的资源或调度问题。
回滚帧不能只看总量。回滚是补偿网络延迟的正常手段,关键是“回滚后是否收敛”。如果回滚帧比例稳定但玩家投诉增加,问题可能在表现层而不是同步算法。
房间与匹配:房间数不是供给
| 指标 | 定义 | 聚合维度 | 示例阈值 | 说明 |
|---|---|---|---|---|
| 开放房间 | 已分配且未销毁的房间 | 模式、服 | 仅作背景 | 包含空房 |
| 空房率 | 无人房间 / 开放房间 | 模式、时段 | > 30% 预警 | 供给过剩或分配错误 |
| 座位利用率 | 已用座位 / 开放座位 | 模式、MMR 段 | < 50% 预警 | 房间开得过大 |
| 匹配时长 p95 | 入队到匹配成功的 p95 | 模式、时段 | > 60 秒预警 | 分段过窄或人少 |
| 匹配成功率 | 成功 / 尝试 | 模式、版本 | < 95% 预警 | 超时、无房、版本不兼容 |
| 失败原因占比 | 各原因 / 总失败 | 原因 | 单一原因 > 40% 预警 | 指向具体修复 |
房间总数正常、空房率却很高,通常说明分配器按“最大并发”预开房,或者玩家被塞进了很多小房间。前者是容量策略问题,后者是匹配策略问题,二者的修复动作完全不同。
匹配成功率的分母必须是“尝试”,不是“成功记录”。取消、超时、版本不匹配都不会产生成功记录,如果分母只算成功,成功率会永远接近 100%。
经济:产出、消耗与存量
| 指标 | 定义 | 窗口 | 示例阈值 | 失败症状 |
|---|---|---|---|---|
| 产出 | 系统发放的货币与道具总量 | 1 天 | 与设计值对比 | 通胀源头 |
| 消耗 | 系统回收的总量 | 1 天 | 与产出对比 | 消耗不足 |
| 通胀率 | (产出 - 消耗) / 消耗 | 1 天、7 天 | > 5% 预警 | 货币贬值 |
| 沉淀覆盖率 | 消耗 / 产出 | 7 天 | < 0.9 预警 | 经济持续扩张 |
| 异常交易率 | 偏离同段中位数 5 倍的交易 / 总交易 | 1 小时 | > 0.5% 预警 | 工作室或刷取 |
| 资产基尼 | 玩家资产分布的基尼系数 | 7 天 | > 0.6 预警 | 财富过度集中 |
| 资产分位 | p50、p99、max | 1 天 | 看趋势 | 尾部异常 |
通胀率的分母是消耗,不是产出。用产出做分母时,产出翻倍只会让比率从 0 变到 0.5,看起来“只涨了一点”,而实际货币池已经翻倍。分母选错,阈值就失去意义。
基尼系数要按“活跃玩家”算,不是按全量注册账号。注册后从未上线的账号资产为 0,会把基尼推高,掩盖真实的分化。异常交易也要先剔除补偿、活动发放等系统行为,否则大额补偿会被当成工作室。
指标字典:一张表管住口径
每个指标上线前填完这张表。填不出来的指标,说明还没想清楚,不应该进看板。
| 字段 | 说明 |
|---|---|
| 名称 | 稳定英文名,例如 match_duration_p95_ms |
| 一句话定义 | 玩家能听懂的解释 |
| 分子 | 计数或求和的对象 |
| 分母 | 为空的指标叫计数,不叫率 |
| 时间窗 | 1 分钟、5 分钟、1 小时或 1 天 |
| 聚合维度 | 服、模式、版本、MMR 段 |
| 阈值与方向 | 大于还是小于告警,示例值必须标注 |
| Owner | 谁负责解释和修复 |
| 数据源 | 埋点表、日志或追踪 |
反模式:看起来正常,实际误导
| 反模式 | 问题 | 替代指标 |
|---|---|---|
| 同时在线人数 | 登录去重、挂机、多端口径不一 | 活跃对局玩家数 + 会话数 |
| 平均延迟 | 长尾被大量快请求拉低 | p50、p95、p99 |
| 房间总数 | 掩盖空房与座位浪费 | 空房率、座位利用率 |
| 成功率 100% | 分母只算成功记录 | 成功 / 尝试 |
| 经济只看产出 | 不看消耗与存量 | 通胀率、沉淀覆盖率 |
| 基尼按全量账号 | 僵尸号推高分化 | 活跃玩家资产基尼 |
数据模型与接口契约
代码把指标分成三组,每组有自己的边界:
Quantile与TickReport:回答“尾部长什么样”,原始样本适合低 QPS,固定桶适合高 QPS;RoomSnapshot与MatchSummary:回答“供给和匹配是否健康”,分母必须显式;Flow与Gini:回答“经济是否失衡”,零分母必须报错而不是返回无穷。
一个关键取舍是原始样本还是直方图。匹配时长每天只有百万级,保留原始样本可以精确算任意分位;tick 每秒数万条,保留原始样本不现实,只能用固定桶近似。直方图的误差在桶边界,桶设计要覆盖预算附近的区间,否则最该看清的 p99 反而最模糊。
可运行片段(一):分位与 tick 抖动
metrics.go 用线性插值计算分位,并把 tick 的健康度收敛成一个报告:预算、样本数、超预算次数、p99 和最大值。
// file: internal/gamemetrics/metrics.go
// tier: L1
// verify: go test ./internal/gamemetrics/
package gamemetrics
import (
"errors"
"fmt"
"math"
"sort"
"time"
)
var (
ErrEmpty = errors.New("gamemetrics: empty sample")
ErrInvalid = errors.New("gamemetrics: invalid sample")
)
// Quantile uses linear interpolation between the two closest ranks, the same
// definition as numpy's default. q must be in (0,1] and samples must be >= 0.
func Quantile(samples []float64, q float64) (float64, error) {
if len(samples) == 0 {
return 0, ErrEmpty
}
if q <= 0 || q > 1 {
return 0, fmt.Errorf("gamemetrics: quantile %g outside (0,1]", q)
}
xs := append([]float64(nil), samples...)
sort.Float64s(xs)
for _, x := range xs {
if x < 0 || math.IsNaN(x) {
return 0, ErrInvalid
}
}
pos := q * float64(len(xs)-1)
lo := int(math.Floor(pos))
hi := int(math.Ceil(pos))
if lo == hi {
return xs[lo], nil
}
frac := pos - float64(lo)
return xs[lo]*(1-frac) + xs[hi]*frac, nil
}
func durationQuantile(samples []time.Duration, q float64) (time.Duration, error) {
xs := make([]float64, len(samples))
for i, d := range samples {
xs[i] = float64(d)
}
v, err := Quantile(xs, q)
if err != nil {
return 0, err
}
return time.Duration(v), nil
}
// TickReport summarises server tick health against a frame budget. P99 is the
// tail players actually feel; LateRatio is how often the budget was missed.
type TickReport struct {
Budget time.Duration
Samples int
Late int
P99 time.Duration
Max time.Duration
}
func AnalyzeTicks(samples []time.Duration, budget time.Duration) (TickReport, error) {
if len(samples) == 0 {
return TickReport{}, ErrEmpty
}
if budget <= 0 {
return TickReport{}, fmt.Errorf("gamemetrics: budget must be positive")
}
rep := TickReport{Budget: budget, Samples: len(samples)}
for _, d := range samples {
if d < 0 {
return TickReport{}, ErrInvalid
}
if d > budget {
rep.Late++
}
if d > rep.Max {
rep.Max = d
}
}
p99, err := durationQuantile(samples, 0.99)
if err != nil {
return TickReport{}, err
}
rep.P99 = p99
return rep, nil
}
func (r TickReport) LateRatio() float64 {
if r.Samples == 0 {
return 0
}
return float64(r.Late) / float64(r.Samples)
}
读代码:分位数不是“第 99 个”
Quantile 先复制样本再排序,避免修改调用方切片。位置按 q × (n-1) 计算,落在两个样本之间时做线性插值。样本量很小时,p99 实际上是最后两个值的插值,统计意义有限;样本量不足时直接拿 p99 去告警,噪声会大于信号。
AnalyzeTicks 先遍历一次统计超预算次数和最大值,再单独算 p99。两次遍历看起来浪费,但它把“计数”和“分位”分开,读数时不会混淆:LateRatio 的分母是样本数,不是时间窗口内的请求数。durationQuantile 把 time.Duration 转成 float64 再复用 Quantile。纳秒级整数在 float64 中有精度上限,但对秒级以内的时长足够;如果要做跨天的长周期统计,应该先换算成毫秒或秒,避免精度悄悄丢失。
可运行片段(二):匹配与经济
economy.go 把匹配成功率、失败归因、通胀率、沉淀覆盖率和基尼系数放在一起。它们的共同点是:分母必须显式,零分母必须报错。
// file: internal/gamemetrics/economy.go
// tier: L1
// verify: go test ./internal/gamemetrics/
package gamemetrics
import (
"fmt"
"sort"
"time"
)
// MatchSummary keeps attempts as the denominator, not the number of recorded
// matches: cancelled and timed-out requests never produce a match record.
type MatchSummary struct {
Attempts int
Failures map[string]int
Durations []time.Duration
}
func (m MatchSummary) SuccessRate() (float64, error) {
if m.Attempts <= 0 {
return 0, fmt.Errorf("gamemetrics: attempts must be positive")
}
failed := 0
for _, n := range m.Failures {
if n < 0 {
return 0, ErrInvalid
}
failed += n
}
if failed > m.Attempts {
return 0, fmt.Errorf("gamemetrics: failures %d exceed attempts %d", failed, m.Attempts)
}
return float64(m.Attempts-failed) / float64(m.Attempts), nil
}
// FailureShare divides by failures, not attempts: it answers "of the players
// who failed, why did they fail", which is the routing decision for a fix.
func (m MatchSummary) FailureShare() (map[string]float64, error) {
failed := 0
for _, n := range m.Failures {
if n < 0 {
return nil, ErrInvalid
}
failed += n
}
if failed == 0 {
return map[string]float64{}, nil
}
out := make(map[string]float64, len(m.Failures))
for reason, n := range m.Failures {
out[reason] = float64(n) / float64(failed)
}
return out, nil
}
func (m MatchSummary) P(q float64) (time.Duration, error) {
return durationQuantile(m.Durations, q)
}
type Flow struct {
Produced int64 // faucet: drops, quest rewards, compensation
Consumed int64 // sink: upgrades, repairs, fees
}
// Inflation is net issuance over consumption. A zero denominator is undefined,
// not infinite: a server with production but no sink has no equilibrium yet.
func (f Flow) Inflation() (float64, error) {
if f.Produced < 0 || f.Consumed < 0 {
return 0, ErrInvalid
}
if f.Consumed == 0 {
return 0, fmt.Errorf("gamemetrics: consumed must be positive")
}
return float64(f.Produced-f.Consumed) / float64(f.Consumed), nil
}
// SinkCoverage is the share of production removed by sinks. Above 1 means the
// economy is draining, which is a problem in the other direction.
func (f Flow) SinkCoverage() (float64, error) {
if f.Produced <= 0 {
return 0, fmt.Errorf("gamemetrics: produced must be positive")
}
if f.Consumed < 0 {
return 0, ErrInvalid
}
return float64(f.Consumed) / float64(f.Produced), nil
}
// Gini measures asset inequality in [0,1]. An all-zero economy is defined as
// 0 rather than undefined, because a fresh server is not an incident.
func Gini(assets []int64) (float64, error) {
if len(assets) == 0 {
return 0, ErrEmpty
}
var sum int64
for _, a := range assets {
if a < 0 {
return 0, ErrInvalid
}
sum += a
}
if sum == 0 {
return 0, nil
}
xs := append([]int64(nil), assets...)
sort.Slice(xs, func(i, j int) bool { return xs[i] < xs[j] })
var weighted float64
for i, a := range xs {
weighted += float64(i+1) * float64(a)
}
n := float64(len(xs))
g := 2*weighted/(n*float64(sum)) - (n+1)/n
if g < 0 {
return 0, nil
}
return g, nil
}
读代码:分母决定结论
SuccessRate 的分母是 Attempts,失败数由 Failures 求和。如果失败数超过尝试数,直接报错而不是返回负数:这通常意味着埋点重复或口径混用,继续计算只会产生一个看似合理的错误数字。
FailureShare 的分母是失败总数,不是尝试总数。它回答“失败的人里,各原因占多少”,用于排修复优先级;如果分母用尝试数,所有原因加起来会远小于 1,读起来像“还有大量未知原因”。
Inflation 在消耗为 0 时报错。有人会把它定义成“无穷大”,但无穷大无法比较、无法告警,也无法判断是“没有消耗”还是“数据没上报”。报错迫使调用方先补数据。
Gini 对全零资产返回 0。新服开服第一天所有人都是 0,如果返回错误,监控会在最正常的时候报警。公式本身对负资产没有定义,所以负值直接拒绝。房间利用率的代码在同包 rooms.go:Occupancy 用座位做分母,EmptyRate 用开放房间做分母。两个比率回答不同问题,不能互相替代。
失败模式与兜底
| 失败模式 | 兜底 |
|---|---|
| 指标先采样再聚合 | 指标链路不采样,或使用加权统计 |
| player_id 进标签 | 只进日志与追踪,指标按区间分桶 |
| 图表窗口不一致 | 所有对比图固定窗口并标注 |
| 分母定义随版本变化 | 指标字典版本化,变更走评审 |
| 阈值全服统一 | 按模式、MMR 段、时段分组标定 |
| 工作室污染经济指标 | 剔除系统补偿,按活跃玩家统计 |
常见错误与边界
| 常见错误 | 边界 |
|---|---|
| 把同时在线当唯一健康指标 | 不能代表对局人数与留存 |
| 只报平均延迟 | 快请求变多会掩盖长尾 |
| 用房间总数判断供给 | 空房率高时房间多反而是浪费 |
| 成功率不写分母 | 分母一换,趋势与历史都不可比 |
| 通胀率用产出做分母 | 产出翻倍时比率变化不明显 |
| 基尼按全量账号算 | 僵尸号推高分化,掩盖活跃玩家 |
| 分位样本太少 | 样本量不足时 p99 噪声大于信号 |
小结
游戏指标的价值不在数量,而在口径。帧要看 tick 与回滚,房间要看空房与座位,匹配要看尝试与失败原因,经济要看产出、消耗与存量。每个指标都要能回答:分子是什么、分母是什么、窗口多长、按什么维度切、谁负责。
这一节的 gamemetrics 把分位、tick 抖动、房间利用率、匹配归因、通胀与基尼做成了可执行代码。下一节讨论告警:指标已经定义清楚,接下来要决定哪些异常值得叫醒人,以及叫醒之后谁来处理。
阅读导航:上一节:5.1 日志、指标与链路追踪 · 下一节:5.3 告警设计与值班 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。