《游戏服务端编程实践》15.1 游戏指标体系设计

从 DAU、WAU、留存、付费率、ARPU 到 QPS、P99、错误率与队列积压,说明业务指标和技术指标的口径定义、分子分母、去重窗口、时区与分层成本,并用可运行的 Go Counter、Gauge、Histogram 演示计数、分位数估算和窗口重置,最后复盘同名不同口径导致的线上分析事故与常见边界。

一、指标不是报表数字,而是口径契约

很多团队做监控时,第一反应是“先把数据埋上去,报表以后再说”。结果就是埋点上线三个月,运营、策划、数据、服务端各自拉出一套数字,会议上花了半小时争论“到底哪个在线人数是真的”。

指标的本质不是数字,而是一份口径契约:谁在什么时间、按什么条件、对哪些对象去重、用什么分母计算。契约不清楚,数字越精确,分歧越大。

本文把游戏服指标分成两层:业务指标回答“玩家和收入怎么样”,技术指标回答“系统现在稳不稳”。两层共用同一套定义方法,只是关注对象不同。

层次典型问题典型指标主要读者
业务指标玩家是否留下来、是否付费DAU、WAU、留存、付费率、ARPU、在线峰值运营、策划、管理层
技术指标服务是否健康、容量是否够QPS、P99、错误率、队列积压、tick 耗时服务端、运维、SRE
过程指标某个改动是否生效登录成功率、匹配耗时、掉线率项目组

同一份原始事件可以同时服务三层,但每一层都必须先写清楚定义,再决定埋什么、聚合什么、展示什么。

二、业务指标:先定分子分母,再谈趋势

业务指标看起来简单,实际最容易被“约定俗成”坑住。下表给出游戏项目里最常见的六个指标及其口径要素。

指标分子分母去重窗口常见坑
DAU当日有有效行为的去重用户1自然日 00:00–24:00用登录次数代替去重人数
WAU近 7 个自然日内有有效行为的去重用户1滚动 7 天,固定时区与 DAU 使用不同的“活跃”定义
次日留存新增 cohort 在第 2 天活跃的去重用户当日新增 cohort按 cohort 日对齐分母混入老用户或回流用户
付费率周期内付费的去重用户同期活跃去重用户自然周或自然月分子分母时间窗错位
ARPU周期内总流水同期活跃用户自然月把付费用户当分母,算成了 ARPPU
在线峰值同一采样点去重在线角色1固定采样间隔,如 1 秒用网关连接数代替去重人数

“有效行为”必须枚举,而不是留给读者猜。登录成功算不算活跃?只打开登录界面算不算?在副本里挂机、没有任何操作算不算?这些问题的答案会直接改变 DAU 的量级。

一个可执行的做法是维护一份活跃事件白名单,例如 login_success、enter_world、battle_start、purchase_success。只有命中白名单的事件才进入活跃集合,新增事件必须走评审,避免某次版本更新悄悄把活跃口径扩大。

2.1 留存的分母是 cohort,不是“昨天所有用户”

次日留存的正确写法是:取某天新注册(或首次启动)的用户集合作为分母,看这批人在第 2 天是否再次活跃。分母是“那一天的 cohort”,不是“昨天所有在线用户”。

如果分母写成“昨天所有用户”,老用户会不断稀释分子,留存曲线看起来“稳定”,实际上完全无法比较不同渠道、不同版本的新增质量。

D1、D7、D30 还要统一“第 n 天”的算法:是按自然日对齐,还是按注册后 24 小时的倍数?跨时区发行时,按自然日对齐更符合运营直觉,但必须固定一个统计时区,并在报表上标注。

2.2 ARPU 与 ARPPU 不是一回事

ARPU 的分母是活跃用户,ARPPU 的分母是付费用户。两者混用会让“付费率低但 ARPPU 高”的产品看起来像“ARPU 很高”,进而做出错误的买量决策。

指标公式回答的问题
ARPU总流水 ÷ 活跃用户数每个活跃用户平均贡献多少收入
ARPPU总流水 ÷ 付费用户数每个付费用户平均花多少钱
付费率付费用户数 ÷ 活跃用户数有多少活跃用户愿意付费

三个指标必须一起看。只看 ARPPU,会忽略付费人数的变化;只看付费率,会忽略大额付费用户的集中度。

三、技术指标:平均值会掩盖尾部

技术指标的核心不是“有没有报警”,而是“报警能否对应玩家体感”。玩家感受到的是登录转圈、技能延迟、战斗回滚,这些体验通常由尾部请求决定,而不是平均值。

指标定义采样/聚合用途
QPS单位时间进入服务的请求数按秒计数,再按分钟聚合容量与限流基线
P9999% 请求的耗时不超过该值固定窗口分位数观察尾部延迟
错误率失败请求数 ÷ 总请求数按分钟或按接口判断故障面
队列积压队列中等待处理的消息数瞬时值,按秒采样发现消费能力不足
队列年龄最早一条消息已等待的时长瞬时值区分“积压但能消化”和“已经卡死”
tick 耗时单帧逻辑处理耗时固定窗口分位数判断世界服是否过载

QPS 要区分“入口 QPS”和“业务 QPS”。网关入口可能因为重连风暴冲到很高,但真正进入世界的请求并不多;只看入口 QPS 会误判容量,只看业务 QPS 又会漏掉接入层压力。

错误率要定义“什么算失败”。网络超时、业务校验失败、客户端主动取消、限流拒绝,是否都算错误?比较稳妥的口径是:只有服务端未能给出预期业务结果的请求才算错误,客户端取消和主动限流单独计数。

队列积压要同时看深度和年龄。深度 10 万但消费速度稳定,可能只是短暂波峰;深度 1 万但最早消息已经等了 30 秒,说明消费者已经卡住。只看深度会错过后者。

四、口径必须先定义再埋点

埋点是不可逆的:事件一旦上报,历史数据就按当时的字段和语义固化。上线后再改口径,旧数据无法自动修正,只能双口径并行一段时间。

因此顺序必须是“定义 → 评审 → 埋点 → 验证 → 发布”,而不是“先埋了再说”。定义文档至少包含以下字段。

字段说明示例
指标名全局唯一,避免同名不同义active_users_daily
业务含义一句话说明回答什么问题当日有有效行为的去重用户数
分子事件、过滤条件、去重键active_event 且 result=ok,按 user_id 去重
分母固定为 1 还是某个 cohort1
时间窗自然日、滚动窗口或滑动窗口自然日,UTC+8
时区统计与展示使用的时区Asia/Shanghai
数据源原始事件表、聚合表或日志ods_event 的 3 个事件
负责人口径变更时的审批人数据组 + 项目负责人

4.1 同名不同口径是分析灾难

以“在线人数”为例,至少存在三种常见定义。

定义计算方式数值倾向适用场景
连接数网关当前 TCP 连接数偏高,含重连重叠和挂机连接容量与带宽评估
去重在线角色心跳窗口内出现的去重 role_id接近玩家体感运营活动与在线峰值
场景内角色已进入世界且未退出的角色偏低,不含大厅和登录排队世界服负载评估

这三种数字都没有错,错的是在同一个会议上把它们都叫“在线人数”。一旦报表不标注口径,读者会默认它们可以互相比较,进而得出“在线掉了 30%”或“服务器还能再装一倍人”的错误结论。

4.2 去重窗口决定数字的稳定性

去重窗口越短,数字越接近瞬时;窗口越长,数字越平滑。在线人数用 1 秒窗口会剧烈抖动,用 5 分钟窗口会把刚下线的玩家继续算进去。

选择窗口的原则是:窗口长度要大于一次正常心跳间隔,小于一次运营活动的观察粒度。如果心跳是 30 秒,去重窗口至少取 60 秒;如果活动按 5 分钟统计,窗口不要超过 5 分钟,否则前后两个区间会大量重叠。

时区同样必须固定。跨区发行时,UTC、UTC+8、UTC-8 同时存在,若报表按服务器本地时区切日,同一份数据在不同区域会得到不同的 DAU。建议原始事件统一存 UTC,聚合和展示层再按业务时区切分,并在字段里保留时区标识。

五、指标分层:原始事件、聚合与看板

指标系统通常分三层。分层的目的不是“看起来专业”,而是把存储成本、查询延迟和口径变更隔离开。

层次内容存储成本查询延迟变更成本
原始事件层每条埋点的完整字段最高,按事件量线性增长高,需要扫描低,可重算
聚合层按分钟/小时/天的计数、去重、分位数中等低中,需要重跑
看板层面向业务的宽表与图表低最低高,依赖聚合结果

原始事件层是指标系统的“事实来源”。它的价值在于可回溯:口径改了可以重算,发现问题可以下钻。代价是存储和查询成本高,不能把所有看板都直接建在原始事件上。

聚合层要保存可合并的中间结果。计数器可以直接相加;去重人数不能直接把每天的 UV 相加,必须保存去重集合或使用可合并的草图结构;分位数不能把每天的 P99 再平均,必须保存桶或样本。

看板层只消费稳定口径。看板一旦被业务引用,就不应该频繁改定义;需要改时,新增一个指标名并标注版本,让旧看板和新看板并行一段时间。

5.1 成本控制的三条线

第一条线是采样。不是所有事件都需要全量保留。登录、付费、关键战斗结果应全量;高频的位置同步、普通 UI 点击可以按比例采样,但采样率必须记录在事件里,否则聚合结果无法还原。

第二条线是基数。给指标加 user_id、device_id、role_id 这类高基数字段作为标签,会让时间序列数量爆炸。标签只保留少量可枚举维度,例如区服、渠道、版本、平台。

第三条线是保留周期。原始事件保留 30 到 90 天,聚合结果保留一年,看板只保留业务需要的粒度。保留周期要在埋点评审时一起确定,而不是等磁盘告警再删数据。

六、可运行实现:Counter、Gauge 与 Histogram

下面的实现只依赖标准库,覆盖三类最常用的指标原语。

  • Counter 用原子整数,只增不减,适合请求数、错误数、登录成功数。
  • Gauge 用 float64 的位模式做 CAS,适合在线人数、队列深度这类可升可降的瞬时值。
  • Histogram 用固定大小的环形窗口保存最近样本,分位数采用最近邻秩估算,返回值一定是真实观测过的样本,不做线性插值。

固定窗口的取舍很明确:内存有界、实现简单、始终反映最近一段时间的尾部;代价是无法回答“过去一年”的分位数,那应该交给聚合层或专门的时序数据库。

// file: ch15/metrics/metrics.go
// tier: L1
// verify: go test ./ch15/metrics/

// Package metrics 提供游戏服最常用的三类指标原语。
//
// Counter 只增不减,适合请求数、错误数;Gauge 可升可降,
// 适合在线人数、队列长度;Histogram 用固定窗口保存最近样本,
// 用分位数观察尾部延迟,而不是只看平均值。
package metrics

import (
	"math"
	"sort"
	"sync"
	"sync/atomic"
)

// Counter 是单调递增的计数器。
type Counter struct {
	v atomic.Int64
}

// Inc 将计数加一。
func (c *Counter) Inc() { c.Add(1) }

// Add 增加 delta;非正数会被忽略,避免计数器回退污染聚合。
func (c *Counter) Add(delta int64) {
	if delta <= 0 {
		return
	}
	c.v.Add(delta)
}

// Value 返回当前累计值。
func (c *Counter) Value() int64 { return c.v.Load() }

// Reset 清零,通常只在窗口切换或测试时调用。
func (c *Counter) Reset() { c.v.Store(0) }

// Gauge 是可增可减的瞬时值,用 float64 的位模式做原子存取。
type Gauge struct {
	bits atomic.Uint64
}

// Set 设置当前值。
func (g *Gauge) Set(v float64) { g.bits.Store(math.Float64bits(v)) }

// Add 在当前值上增加 delta。
func (g *Gauge) Add(delta float64) {
	for {
		old := g.bits.Load()
		next := math.Float64bits(math.Float64frombits(old) + delta)
		if g.bits.CompareAndSwap(old, next) {
			return
		}
	}
}

// Value 返回当前值。
func (g *Gauge) Value() float64 { return math.Float64frombits(g.bits.Load()) }

// Histogram 保存最近 window 个观测值,用于估算分位数。
//
// 固定窗口的代价是内存有界、计算简单;收益是它始终反映
// “最近一段时间”的尾部,不会被数小时前的慢请求稀释。
type Histogram struct {
	mu      sync.Mutex
	window  int
	samples []float64
	next    int
	size    int
	count   int64
	sum     float64
}

// NewHistogram 创建窗口大小为 window 的直方图;window 小于 1 时按 1 处理。
func NewHistogram(window int) *Histogram {
	if window < 1 {
		window = 1
	}
	return &Histogram{window: window, samples: make([]float64, window)}
}

// Observe 记录一个观测值。
func (h *Histogram) Observe(v float64) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.samples[h.next] = v
	h.next = (h.next + 1) % h.window
	if h.size < h.window {
		h.size++
	}
	h.count++
	h.sum += v
}

// Size 返回当前保留的样本数,最多等于窗口大小。
func (h *Histogram) Size() int {
	h.mu.Lock()
	defer h.mu.Unlock()
	return h.size
}

// Count 返回自上次 Reset 以来的观测总数,不受窗口限制。
func (h *Histogram) Count() int64 {
	h.mu.Lock()
	defer h.mu.Unlock()
	return h.count
}

// Sum 返回自上次 Reset 以来的观测值总和。
func (h *Histogram) Sum() float64 {
	h.mu.Lock()
	defer h.mu.Unlock()
	return h.sum
}

// Quantile 用最近邻秩估算分位数,q 落在 [0,1]。
//
// 空窗口返回 0;q<=0 返回窗口最小值,q>=1 返回窗口最大值。
// 最近邻秩不会做线性插值,因此返回值一定是真实观测过的样本,
// 不会出现“P99 比任何一次请求都慢”的奇怪数字。
func (h *Histogram) Quantile(q float64) float64 {
	h.mu.Lock()
	defer h.mu.Unlock()
	if h.size == 0 {
		return 0
	}
	sorted := make([]float64, h.size)
	copy(sorted, h.samples[:h.size])
	sort.Float64s(sorted)
	if q <= 0 {
		return sorted[0]
	}
	if q >= 1 {
		return sorted[h.size-1]
	}
	rank := int(math.Ceil(q*float64(h.size))) - 1
	if rank < 0 {
		rank = 0
	}
	if rank >= h.size {
		rank = h.size - 1
	}
	return sorted[rank]
}

// Reset 清空样本、计数与总和,用于窗口切换。
func (h *Histogram) Reset() {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.samples = make([]float64, h.window)
	h.next = 0
	h.size = 0
	h.count = 0
	h.sum = 0
}

测试覆盖了计数器的并发累加、Gauge 的正负增减、直方图的 P50/P95/P99、环形窗口覆盖旧样本以及 Reset 后窗口清空。分位数使用最近邻秩,因此 1 到 100 的样本在 P99 上返回 99,而不是插值出的 99.01。

七、失败场景:三个“在线人数”同时出现在周会

某项目周会上,运营看到网关连接数 12 万,数据组报表显示去重在线角色 8.2 万,世界服监控显示场景内角色 6.5 万。三个数字都被称为“在线”,会议结论却完全不同:运营认为还可以继续买量,服务端认为世界服已经接近容量上限,管理层则质疑“到底哪个是真的”。

时间事件各方反应
周一运营活动开启,大量玩家重连网关连接数快速上升
周一部分玩家停留在大厅排队场景内角色增长慢于连接数
周二周会看到三个不同数字对容量判断产生分歧
周二排查后发现口径不同决定统一指标字典
周三报表增加口径说明与时间窗争议减少,但历史数据无法直接比较

这个事故里没有哪一方在造假。网关连接数包含重连重叠和挂机连接,去重在线角色按 5 分钟心跳窗口统计,场景内角色只统计已进入世界的玩家。三个指标分别服务于容量、运营和世界服负载,本来就不应该相等。

真正的问题是报表没有标注口径,也没有说明时间窗。修复方式不是“选一个数字当唯一真相”,而是给每个指标补上定义、时间窗和适用场景,并在看板上直接展示口径说明。

另一个常见失败是留存口径天天变。周一按注册日算,周二按首启日算,周三又把回流用户算进新增。每次变化都会让曲线出现一个无法解释的跳变,最后团队不再相信留存数据,转而凭感觉做决策。

小结

指标系统的第一产物不是图表,而是口径契约。业务指标要先固定分子、分母、去重窗口和时区;技术指标要同时看分位数、错误率和队列年龄,不能只看平均值。原始事件、聚合层和看板层各自承担不同职责,成本和口径变更要分层处理。工程实现上,Counter、Gauge 和固定窗口 Histogram 足以覆盖大多数游戏服场景,关键是让每个数字都能回答“它是怎么算出来的”。下一篇 /game-server-in-action-15-2/ 会把口径落到埋点与日志系统上。

常见错误与边界

  • 用登录次数代替去重用户数,DAU 随重连风暴虚高。
  • 留存分母混入老用户或回流用户,不同渠道的新增质量无法比较。
  • ARPU 与 ARPPU 混用,买量决策被错误放大。
  • 在线人数用连接数代替去重角色,容量评估偏乐观。
  • 只报平均值,不报 P99 和队列年龄,尾部故障被掩盖。
  • 去重窗口小于心跳间隔,在线人数出现锯齿状抖动。
  • 跨区发行不固定统计时区,同一份数据算出不同 DAU。
  • 指标改名不改口径,历史曲线出现无法解释的断点。
  • 给所有指标加高基数用户标签,时序数量失控、查询变慢。
  • 看板直接扫描原始事件,数据量上来后查询超时。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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