《MMO 服务器开发》6.1 指标体系与口径

指标不是数字,而是数字加口径。这一节先把业务、服务、资源三层指标摆清楚,再用「登录口径」与「在线口径」两种 DAU 演示两个团队为什么会对不上账,最后把统一口径写成一份能跑的 Go 断言:两种口径必须给出不同结果、ARPPU 永远不低于 ARPU、不一致必须带原因。

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 守的是「口径不一致时必须能解释」,它让「对不上账」从一句争吵变成一条带原因的记录。口径只有变成断言,才会在下一个版本里活下来。

本节小结

这一节确立了三件事:

  1. 指标分三层,且下层是上层的先决条件。业务层回答「玩家在不在、花不花钱」,服务层回答「扛不扛得住」,资源层回答「机器累不累」。只看业务层的看板是危险的,只看资源层的看板是瞎的。
  2. 指标 = 数字 + 口径。同一个 DAU 之所以会「4 万 vs 3.6 万」,是因为「活跃」被定义成了两件事。口径至少要写清窗口、主体、去重键、分子分母。
  3. 口径是可执行的:aegis 把「活跃」标准化成在线口径,并用一组断言锁死「两种口径必须不同」「ARPPU 不低于 ARPU」「不一致必须解释」。

口径定完了,接下来的问题是:这些指标背后的原始事件——「谁在什么时候做了什么」——从哪里来、怎么进系统。下一节就写这件事:埋点怎么上报,数据怎么从客户端一路流进数仓。

阅读导航:上一节:目录 · 下一节:6.2 埋点与数据管道 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标