一、指标不是报表数字,而是口径契约
很多团队做监控时,第一反应是“先把数据埋上去,报表以后再说”。结果就是埋点上线三个月,运营、策划、数据、服务端各自拉出一套数字,会议上花了半小时争论“到底哪个在线人数是真的”。
指标的本质不是数字,而是一份口径契约:谁在什么时间、按什么条件、对哪些对象去重、用什么分母计算。契约不清楚,数字越精确,分歧越大。
本文把游戏服指标分成两层:业务指标回答“玩家和收入怎么样”,技术指标回答“系统现在稳不稳”。两层共用同一套定义方法,只是关注对象不同。
| 层次 | 典型问题 | 典型指标 | 主要读者 |
|---|---|---|---|
| 业务指标 | 玩家是否留下来、是否付费 | 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 | 单位时间进入服务的请求数 | 按秒计数,再按分钟聚合 | 容量与限流基线 |
| P99 | 99% 请求的耗时不超过该值 | 固定窗口分位数 | 观察尾部延迟 |
| 错误率 | 失败请求数 ÷ 总请求数 | 按分钟或按接口 | 判断故障面 |
| 队列积压 | 队列中等待处理的消息数 | 瞬时值,按秒采样 | 发现消费能力不足 |
| 队列年龄 | 最早一条消息已等待的时长 | 瞬时值 | 区分“积压但能消化”和“已经卡死” |
| tick 耗时 | 单帧逻辑处理耗时 | 固定窗口分位数 | 判断世界服是否过载 |
QPS 要区分“入口 QPS”和“业务 QPS”。网关入口可能因为重连风暴冲到很高,但真正进入世界的请求并不多;只看入口 QPS 会误判容量,只看业务 QPS 又会漏掉接入层压力。
错误率要定义“什么算失败”。网络超时、业务校验失败、客户端主动取消、限流拒绝,是否都算错误?比较稳妥的口径是:只有服务端未能给出预期业务结果的请求才算错误,客户端取消和主动限流单独计数。
队列积压要同时看深度和年龄。深度 10 万但消费速度稳定,可能只是短暂波峰;深度 1 万但最早消息已经等了 30 秒,说明消费者已经卡住。只看深度会错过后者。
四、口径必须先定义再埋点
埋点是不可逆的:事件一旦上报,历史数据就按当时的字段和语义固化。上线后再改口径,旧数据无法自动修正,只能双口径并行一段时间。
因此顺序必须是“定义 → 评审 → 埋点 → 验证 → 发布”,而不是“先埋了再说”。定义文档至少包含以下字段。
| 字段 | 说明 | 示例 |
|---|---|---|
| 指标名 | 全局唯一,避免同名不同义 | active_users_daily |
| 业务含义 | 一句话说明回答什么问题 | 当日有有效行为的去重用户数 |
| 分子 | 事件、过滤条件、去重键 | active_event 且 result=ok,按 user_id 去重 |
| 分母 | 固定为 1 还是某个 cohort | 1 |
| 时间窗 | 自然日、滚动窗口或滑动窗口 | 自然日,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。
- 指标改名不改口径,历史曲线出现无法解释的断点。
- 给所有指标加高基数用户标签,时序数量失控、查询变慢。
- 看板直接扫描原始事件,数据量上来后查询超时。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。