6.1 指标体系与口径
前面五章讲的是怎么把 aegis 压出来、调顺、看清、发出去、改得动。从第 6 章开始,问题换了方向:这套服务端跑起来之后,它的表现怎么被量化? 而量化最容易被低估的一步,不是采集,不是计算,而是先把口径定下来。
一个真实场景:运营晨会,市场同学说昨天 DAU 是 4 万,数据分析同学说昨天 DAU 是 3.6 万。两个人手里都有数据,都没算错,却吵了半小时。原因通常不是谁造假,而是两边的「活跃」定义不一样——一边数的是登录成功的账号,一边数的是真正进入游戏世界的角色。崩溃在加载界面的那批人,前者算、后者不算;跨零点在线的那批人,后者算、前者不算。同一个词,两套口径,对不上账是必然的。
这一节不写采集、不写管道(那是 6.2 的事),只做两件事:把「指标分几层、每层看什么」摆清楚,再把「口径」这件抽象的约定,落成一份能跑的 Go 断言。做完这两件事,6.2 的埋点才知道该埋什么、6.3 的漏斗才算得准。
分工:本节与站内既有文章的边界
站内 practice 专题里已经有几篇讲数据体系的文章,本节不重复它们,只承接它们的上游——先把口径定死,架构与实验设计交给它们:
| 既有文章 | 它讲什么 | 本节讲什么 |
|---|---|---|
/game-server-data-pipeline-architecture-for-analytics/ | 数据管道的整体架构:采集、缓冲、数仓分层 | 管道入口的指标口径与目录 |
/game-server-player-segmentation-architecture-design/ | 玩家分群:把玩家切成可运营的群体 | 分群依赖的基础指标口径 |
/game-server-experiment-and-ab-testing-design/ | AB 实验与假设检验设计 | 实验依赖的北极星指标口径 |
一句话:本节定「数什么、怎么数」,它们定「数出来怎么用」。 口径不统一,后面无论是分群还是实验,都会建在流沙上。
指标分三层:业务、服务、资源
线上运营看的所有数字,其实分三层,而且下层不健康时,上层数字没有意义:
| 层 | 回答的问题 | 典型指标 | 谁看 |
|---|---|---|---|
| 业务层 business | 玩家在不在、愿不愿花钱 | DAU / MAU、留存、ARPU / ARPPU、LTV、转化率 | 运营、市场、制作人 |
| 服务层 service | 服务端扛不扛得住 | QPS、P99 延迟、错误率、队列深度 | 后端、SRE |
| 资源层 resource | 机器累不累 | CPU、内存、GC 停顿、连接数 | SRE、平台 |
这三层不是并列的,而是有依赖方向的。业务层的 DAU 掉了,可能是玩家不喜欢,也可能是服务层 P99 飙到两秒把玩家卡跑了,还可能是资源层 GC 停顿把整个节点拖死了。所以一个只看业务层的看板是危险的:它能在故障当天告诉你「DAU 掉了 30%」,却不会告诉你为什么。反过来,只有资源层、没有业务层的看板则是瞎的:CPU 很健康,玩家却已经走光了。
aegis 的做法是给每个指标都挂两个字段——它在哪一层、它的数据来自哪张表。业务层的 DAU 来自 analytics.fact_daily,服务层的 QPS 来自 gateway.metrics,资源层的 GC 停顿来自 runtime.metrics。有了这两个字段,看板上任何一个数字都能被反查回它的原始来源,不会再出现「这个数是谁算的、怎么算的」。
口径统一:两个团队为什么对不上账
回到晨会那个场景。要让它不再发生,只需要把「活跃」这个词的口径写下来。一个口径至少要说清四件事:窗口(按天、周还是月)、主体(算「登录成功」还是「进入世界」)、去重键(按 PlayerID 去重,还是一登录就算一次)、分子分母(是个计数,还是两个数相除)。把「DAU」拆开,立刻能看到两种都合理的口径:
- 登录口径:当天至少登录成功一次的账号。崩溃在加载界面的玩家算。
- 在线口径:当天在游戏世界里待过(在线时长 > 0)的角色。跨零点在线的玩家算。
aegis 标准化的口径是在线口径——一个玩家只有真正进了世界才算「活跃」。这不是因为它更「正确」,而是因为它必须唯一:只要全站只认一个口径,晨会就不会吵。下面用一组构造数据把这件事钉死。
可运行产物:把口径写成断言
口径如果不能被执行,就只是一句口号。下面这段是 L1,go test 可以直接跑。先看 internal/analytics/metric.go——它把「口径」变成结构体,把「活跃」变成一次可去重的集合运算:
// file: internal/analytics/metric.go
// tier: L1
// verify: go test ./internal/analytics/ -run TestMetric
//
// Package analytics turns raw gameplay facts into the numbers an operations
// team argues about. The lesson of this file is that a metric is never just a
// number -- it is a number plus a caliber. "DAU = 3" and "DAU = 4" can both be
// correct for the same day if two teams pinned different calibers.
package analytics
import "fmt"
// Fact is one raw per-player-per-day record the engine aggregates over.
type Fact struct {
PlayerID uint64
ServerID uint16
Day string // "2026-10-01"
LoginCount int // successful logins that day
OnlineSecs int // seconds spent in the world that day
PaidCents int64 // amount paid that day, in cents
}
// Subject names the activity event a caliber counts over. The same word can
// mean two things -- "logged in" is not "was in the world" -- and that gap is
// exactly where reconciliations fail.
type Subject string
const (
// SubjectLogin counts a player who authenticated, even if the client
// crashed before entering the world.
SubjectLogin Subject = "login"
// SubjectOnline counts a player who was actually in the world.
SubjectOnline Subject = "online"
)
// Caliber is the written-down definition of a metric. Two teams that share a
// Caliber get the same number; two teams that do not, will not.
type Caliber struct {
Metric string
Unit string
Window string
Subject Subject
DedupKey string
Numerator string
Denominator string
}
// CanonicalDAU is the caliber aegis standardises on: a player is "active" only
// if they were in the world, not merely authenticated.
var CanonicalDAU = Caliber{
Metric: "dau", Unit: "player", Window: "day",
Subject: SubjectOnline, DedupKey: "player_id",
Numerator: "distinct players with online_secs > 0",
}
func (c Caliber) counts(f Fact) bool {
switch c.Subject {
case SubjectLogin:
return f.LoginCount > 0
case SubjectOnline:
return f.OnlineSecs > 0
default:
return false
}
}
// Active returns the distinct players active on day under the caliber. Dedup is
// by PlayerID: a player with several sessions that day is counted once.
func Active(facts []Fact, day string, c Caliber) map[uint64]struct{} {
set := make(map[uint64]struct{})
for _, f := range facts {
if f.Day == day && c.counts(f) {
set[f.PlayerID] = struct{}{}
}
}
return set
}
// DAU counts distinct active players on day under the caliber.
func DAU(facts []Fact, day string, c Caliber) int { return len(Active(facts, day, c)) }
// Revenue sums the day's payments in cents.
func Revenue(facts []Fact, day string) int64 {
var sum int64
for _, f := range facts {
if f.Day == day {
sum += f.PaidCents
}
}
return sum
}
// Payers returns the distinct players who paid anything on day.
func Payers(facts []Fact, day string) map[uint64]struct{} {
set := make(map[uint64]struct{})
for _, f := range facts {
if f.Day == day && f.PaidCents > 0 {
set[f.PlayerID] = struct{}{}
}
}
return set
}
// ARPU is revenue / DAU; ARPPU is revenue / payers. ARPPU >= ARPU always,
// because payers are a subset of active users.
func ARPU(facts []Fact, day string, c Caliber) float64 {
if dau := DAU(facts, day, c); dau > 0 {
return float64(Revenue(facts, day)) / float64(dau)
}
return 0
}
// ARPPU is average revenue per paying user.
func ARPPU(facts []Fact, day string) float64 {
if p := len(Payers(facts, day)); p > 0 {
return float64(Revenue(facts, day)) / float64(p)
}
return 0
}
// Reconciliation records why a team's reported number disagrees with canonical.
type Reconciliation struct {
Team string
Reported float64
Canonical float64
Matches bool
Reason string
}
// Reconcile compares a team's reported value against the canonical value.
func Reconcile(team string, reported, canonical, tolerance float64) Reconciliation {
r := Reconciliation{Team: team, Reported: reported, Canonical: canonical}
diff := reported - canonical
if diff < 0 {
diff = -diff
}
r.Matches = diff <= tolerance
if !r.Matches {
r.Reason = fmt.Sprintf(
"%s reports %.2f but the canonical caliber yields %.2f", team, reported, canonical)
}
return r
}
再配一个测试 internal/analytics/metric_test.go,用一组故意埋了口径陷阱的构造数据锁住三条不变量:两种口径的 DAU 必须不同、ARPPU 永远不低于 ARPU、口径不一致时 Reconcile 必须报出原因:
// file: internal/analytics/metric_test.go
// tier: L1
// verify: go test ./internal/analytics/ -run TestMetric
package analytics
import (
"math"
"testing"
)
// day1Facts is a hand-built day with a deliberate caliber trap: p2 and p5
// logged in but never reached the world (crash at loading), and p3 was still
// online from a session that started before midnight, so it never logged in.
func day1Facts() []Fact {
return []Fact{
{PlayerID: 1, ServerID: 1, Day: "2026-10-01", LoginCount: 1, OnlineSecs: 3600},
{PlayerID: 2, ServerID: 1, Day: "2026-10-01", LoginCount: 1, OnlineSecs: 0},
{PlayerID: 3, ServerID: 1, Day: "2026-10-01", LoginCount: 0, OnlineSecs: 1800, PaidCents: 500},
{PlayerID: 4, ServerID: 1, Day: "2026-10-01", LoginCount: 2, OnlineSecs: 7200, PaidCents: 1500},
{PlayerID: 5, ServerID: 1, Day: "2026-10-01", LoginCount: 1, OnlineSecs: 0},
}
}
func loginCaliber() Caliber {
c := CanonicalDAU
c.Subject = SubjectLogin
return c
}
func approx(a, b float64) bool { return math.Abs(a-b) <= 1e-6 }
func TestMetricDAUDependsOnCaliber(t *testing.T) {
facts := day1Facts()
loginDAU := DAU(facts, "2026-10-01", loginCaliber())
onlineDAU := DAU(facts, "2026-10-01", CanonicalDAU)
if loginDAU != 4 || onlineDAU != 3 {
t.Fatalf("DAU login=%d online=%d, want 4 and 3", loginDAU, onlineDAU)
}
if loginDAU == onlineDAU {
t.Fatal("the two calibers must disagree on this dataset, or the lesson is lost")
}
}
func TestMetricActiveSetsAreDisjoint(t *testing.T) {
facts := day1Facts()
login := Active(facts, "2026-10-01", loginCaliber())
online := Active(facts, "2026-10-01", CanonicalDAU)
// login counts the crashed-at-loading players (2, 5); online counts the
// player carried over midnight (3). Neither set contains the other.
if _, ok := login[2]; !ok {
t.Fatal("login caliber must count player 2, who crashed at loading")
}
if _, ok := login[3]; ok {
t.Fatal("login caliber must not count player 3, who never logged in")
}
if _, ok := online[3]; !ok {
t.Fatal("online caliber must count player 3, carried over midnight")
}
if _, ok := online[2]; ok {
t.Fatal("online caliber must not count player 2, who never entered the world")
}
}
func TestMetricRevenueAndRates(t *testing.T) {
facts := day1Facts()
const day = "2026-10-01"
if got := Revenue(facts, day); got != 2000 {
t.Fatalf("revenue = %d, want 2000", got)
}
if got := ARPU(facts, day, CanonicalDAU); !approx(got, 2000.0/3.0) {
t.Fatalf("ARPU = %.4f, want %.4f", got, 2000.0/3.0)
}
if got := ARPPU(facts, day); !approx(got, 1000) {
t.Fatalf("ARPPU = %.4f, want 1000", got)
}
if ARPPU(facts, day) < ARPU(facts, day, CanonicalDAU) {
t.Fatal("ARPPU must never fall below ARPU")
}
}
func TestMetricReconcileFlagsCaliberMismatch(t *testing.T) {
facts := day1Facts()
canonical := float64(DAU(facts, "2026-10-01", CanonicalDAU))
marketing := Reconcile("marketing", 4, canonical, 0.001)
if marketing.Matches || marketing.Reason == "" {
t.Fatal("marketing's login-caliber DAU must not reconcile, and must explain why")
}
if ops := Reconcile("ops", 3, canonical, 0.001); !ops.Matches {
t.Fatalf("ops reported the canonical value and must reconcile: %s", ops.Reason)
}
}
本机实跑结果(go test ./internal/analytics/ -run TestMetric -v):
=== RUN TestMetricDAUDependsOnCaliber
--- PASS: TestMetricDAUDependsOnCaliber (0.00s)
=== RUN TestMetricActiveSetsAreDisjoint
--- PASS: TestMetricActiveSetsAreDisjoint (0.00s)
=== RUN TestMetricRevenueAndRates
--- PASS: TestMetricRevenueAndRates (0.00s)
=== RUN TestMetricReconcileFlagsCaliberMismatch
--- PASS: TestMetricReconcileFlagsCaliberMismatch (0.00s)
PASS
ok aegis/internal/analytics 0.601s
TestMetricActiveSetsAreDisjoint 这条断言值得单独说一句。它守的是集合而不是数字:登录口径的集合里有 p2、没有 p3,在线口径反过来。将来若有人把 CanonicalDAU.Subject 改回登录口径,这条测试会立刻变红,因为它知道「谁该在、谁不该在」。而 TestMetricReconcileFlagsCaliberMismatch 守的是「口径不一致时必须能解释」,它让「对不上账」从一句争吵变成一条带原因的记录。口径只有变成断言,才会在下一个版本里活下来。
本节小结
这一节确立了三件事:
- 指标分三层,且下层是上层的先决条件。业务层回答「玩家在不在、花不花钱」,服务层回答「扛不扛得住」,资源层回答「机器累不累」。只看业务层的看板是危险的,只看资源层的看板是瞎的。
- 指标 = 数字 + 口径。同一个 DAU 之所以会「4 万 vs 3.6 万」,是因为「活跃」被定义成了两件事。口径至少要写清窗口、主体、去重键、分子分母。
- 口径是可执行的:
aegis把「活跃」标准化成在线口径,并用一组断言锁死「两种口径必须不同」「ARPPU 不低于 ARPU」「不一致必须解释」。
口径定完了,接下来的问题是:这些指标背后的原始事件——「谁在什么时候做了什么」——从哪里来、怎么进系统。下一节就写这件事:埋点怎么上报,数据怎么从客户端一路流进数仓。
阅读导航:上一节:目录 · 下一节:6.2 埋点与数据管道 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。