《游戏服务端架构·平台化与架构治理》5.2 游戏专属指标:帧、房间、匹配、经济

同时在线十万,匹配却越来越慢,房间数也正常。问题出在指标口径:在线人数没有定义、平均延迟掩盖长尾、房间总数看不见空房。本文把帧、房间、匹配、经济四类指标写成字典,给出分子分母、时间窗与聚合维度,并用 Go 实现分位、tick 抖动、房间利用率、匹配失败归因、通胀率与基尼系数。

一个全部绿色的看板

《群岛纪元》大版本上线后,运营看板一切正常:同时在线 10.2 万(示例值),比上周还高;开放房间 1.8 万(示例值),与历史持平;平均匹配时长 8 秒(示例值),在阈值内。但社区里全是“排队排了半分钟”“进去一个人都没有”的抱怨。把指标拆开以后,问题才露出来:匹配时长 p99 是 95 秒(示例值),空房率 62%(示例值),同时在线被大量挂机小号拉高,真正在对局里的玩家反而下降了。四个“正常”指标,没有一个回答玩家体验。

指标不是把数字画成线。指标是一份定义:分子是什么、分母是什么、在哪个窗口、按什么维度切。定义错了,看板越漂亮,误导越强。

口径先于图表:四个问题

问题常见错误后果正确做法
分子分母成功率用成功数除以在线人数分母漂移,趋势失真分母用尝试数,并说明取消是否算失败
时间窗瞬时值与小时均值混用活动洪峰被抹平发现用短窗,容量用长窗,图表标注窗口
聚合维度只算全局平均分区、分段问题被掩盖按服、模式、MMR 段分别看
平均与分位只报平均延迟长尾被快请求拉低同时报 p50、p95、p99

四个问题里,分母最容易被忽略。一个指标如果没有写清分母,它就不是“率”,只是一个计数。

帧与同步:先分清服务端 tick 和客户端帧率

服务端关心的是“这一帧有没有在预算内算完”,客户端关心的是“画面顺不顺”。两者会互相影响,但不能合成一个“卡顿”指标。

指标定义窗口示例阈值失败症状
tick p99单帧服务端耗时 p991 分钟预算 33ms 时 p99 ≤ 50ms技能判定延迟、状态不同步
late tick ratio超预算帧数 / 总帧数5 分钟≤ 0.1%偶发但集中的卡顿
rollback frames回滚帧数 / 总帧数1 分钟大于 0 必须能解释客户端预测频繁被推翻
input latency p95输入到服务端确认的 p955 分钟≤ 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、max1 天看趋势尾部异常

通胀率的分母是消耗,不是产出。用产出做分母时,产出翻倍只会让比率从 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 告警设计与值班 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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