《MMO 服务器开发》18.2 承载数字汇总

把卷一 17 章跑出来的实测数字汇成一张四列总表:指标、数值、成立条件、出处节号。覆盖编解码字节数与吞吐、KCP 弱网重传、时间轮挂载、邮箱背压、幂等窗口、快照与增量、AOI 带宽、goroutine 与虚拟线程、端到端 QPS 与分位,并说明为什么这些数字只能同机比、不能跨栈搬。

18.2 承载数字汇总

18.1 说这一节要「把零件跑出来的数字收进一张表」。但汇总不是把前面十七节的数字抄一遍——真正难的是给每一个数字标注它的成立条件。同一个「P99」,在 5.3 里是确定性模拟器跑出来的、可以写进断言的确定值;在 17.3 里是一次真实压测、随机器负载浮动的观测值。把它们并排放在一起而不加区分,读者就会把模拟器的数字当成线上容量,把超载机器的数字当成硬件上限。

所以本节做两件事。第一,把卷一各节跑出来的实测数字汇成一张四列总表——指标、数值、成立条件、出处节号,每一行都能回到对应节里查到原文。第二,写一段 L1 代码把这张表变成可断言的数据结构:表里的数字不再只是排版的产物,而是 go test 能守住的事实。

先把口径交代清楚。表里所有数字都来自同一台写作机(Go 1.26.0,darwin/arm64,10 逻辑核),但它们只能在同机内比相对量级,绝对值不可移植。Java 对照那几行尤其要注意:Go 组跑在宿主机,Java 组跑在 Colima 容器里,两者不能直接比快慢。这些限制会在下面「怎么读这些数字」里逐条展开。

一张总表:卷一全部实测数字

下面这张表是卷一 17 章实测数字的汇总。每一行的「出处节号」都指向正文里能查到该数字的那一节;「成立条件」一列不能省——它决定了这个数字在什么前提下才成立。同一节里如果有多个数字,只收最值得跨章复用的那几个。

指标数值成立条件出处节号
goroutine 每实例栈内存约 2053 B10 万 goroutine 停在 channel 上,宿主 10 核2.2 / 2.3
goroutine 创建+回收(10 万)约 49–65 ms含跑 2000 轮 LCG,TotalAlloc 仅 +5.3 MiB2.3
goroutine 退出后 StackInuse195.8 MiB → 3.7 MiB栈还给 stackpool 复用,不 free 给 OS2.3
虚拟线程每实例内存约 1006 B10 万虚拟线程,Colima 6 核容器2.3
虚拟线程创建+回收(10 万)约 84.094 ms单实例 0.841 µs,同一容器内2.3
平台线程每实例(堆)约 479 B(假)不含 native 栈;10 万条约 98 GiB2.3
2000 连接建立零失败,拨号 P50 约 15 ms错峰 64 条/批、批间隔 15 ms2.2
echo 往返 RTT8 连接 1.087 ms → 2000 连接 1.34 s本机 10 核,测量期负载均值 57–662.2
echo 吞吐8 连接 4159 → 2000 连接 1008 往返/秒同上,重复跑波动 982–12072.2
同一条 MoveNotify 字节数JSON 95 / 扁平化 32 / varint 30定长 vs varint,同机同结构体4.1 / 4.3
JSON 编解码编码 ~296 ns / 解码 ~1137 ns,128/248 B,5 allocs-benchmem -count=54.3
扁平化定长编解码编码 ~17 ns / 解码 ~14 ns,32/0 B,0 allocs同上4.3
varint 二进制编解码编码 ~45 ns / 解码 ~22 ns,64/0 B,1/0 allocs容量调 64→32 后编码 ~39 ns4.3
KCP 报文头 / mss24 B / 488 BMTU 固定 5125.2
RTO 收敛200 ms → 82 ms链路真实 RTT 60 ms,flush 节拍 10 ms5.2
50% 丢包下 300 B 消息送达重传 4 次确定性虚拟链路,5 个用例全绿5.2
队头阻塞(5% 丢包)单流 P90 120.1 ms vs 多流 39.5 ms600 条 30Hz、RTT 60 ms、种子固定5.1
KCP vs TCP 式尾部延迟(5% 丢包)P99 155 ms vs 239.6 ms300 条 30Hz、RTT 60 ms、种子固定5.3
20% 丢包冗余发包1.49×KCP 快速重传,无 cwnd 兜底5.3
心跳超时精度配置 300 ms,实测 304–305 ms看门狗节拍 37.5 ms,偏差小于一拍6.2
1000 空连接内存约 6.5 KB/连接HeapAlloc 增量 / 10006.2
心跳 sweep 成本约 65–67 µs/pingsocket write 底噪约 80 µs/次6.2
会话表 Bind单锁 495.2 ns → 64 分片 102.6 ns10 核、1000 账号并行 Bind7.1
单帧去重判定约 7.3 ns/op,0 allocs32 位滑窗位图,全整数位运算8.2
广播编一次发 N 次16.5–22.9 µs / 3 allocs1000 连接一次广播,写是假连接8.3
广播编 N 次(对照)65.9–69.4 µs / 2001 allocs同上,分配次数差约 660 倍8.3
时间轮挂载77.29 ns/op,79 Bvs time.AfterFunc 约 120 ns9.1 / 9.2
共享扫描10000 条约 90 µs,9 ns/条,0 B心跳超时表整表扫描9.1
Actor Send有界 channel 39–44 ns vs 无界队列 102–117 ns30 万次/轮,3 轮10.3
邮箱背压上界尾巴卡住 50 ms 内源头仅发 4 条A→B→C 各 cap 1,上界 Σcap+110.2
幂等窗口单键内存37.8 B/key500k 活键 HeapAlloc 增量 / 键数11.1
Window.Seen134 ns/op,0 allocs单次探测,含一次 map 查找/插入11.1
活键上界100(= r×ttl = 1000/s × 100ms)往窗口塞 10 万键后实测11.1
业务指纹 FingerprintOf≈ 34 ns/op,0 allocs2 参数 hash/fnv,全在栈上11.2
Settle 命中 / 首次37 ns/op(0 allocs)/ 321 ns/op(151 B)窗口命中 vs 落 store11.3
帧同步 vs 状态同步服务端 Step 0 次 vs 200 次4 客户端 × 200 帧12.1
全量 vs 增量字节691020 B vs 2716 B(254.4×)500 实体 × 60 tick,每 tick 2 新实体移动12.2
基线失配回退增量 34 B → 全量 11517 B落后 1 tick vs 丢了一帧12.2
AOI 每客户端带宽r=32 1245 B/s;r=64 4958 B/s;r=128 19698 B/s100 万实体、工作集 15.5 MB12.3
AOI 扇出总量1187 / 4728 / 18785 MB/s同上,半径平方级放大12.3
本地预测省下约 44 ms(60 ms RTT → 下一帧 16 ms)60Hz、RTT 60 ms13.1
一次和解重放条数等于在途输入数(60Hz、RTT 100ms ≈ 6 次)纯浮点加法 Step,纳秒量级13.2
fuzz 5 秒执行约 150 万次(154 万 / 183 万)10 个 worker,零崩溃、零 OOM16.1
slowloris 超时配置 150 ms,实测 150–160 ms静默客户端被断开16.2
封包加解密往返620 ns/op,约 413 MB/s,565 B/op 5 allocs256 字节明文,单核16.3
端到端 QPS 天花板约 3000–3200(50×400 与 100×200)world -aoi-radius 1,单机单世界17.3
端到端 P50/P90/P99100×200:26.1 / 29.8 / 124–171 ms同机,1000×10 组失败 117.3
端到端塌方点1000×10:P50 283.8 ms,P99 1642.1 ms同上,单线程世界 goroutine 排队17.3

这张表本身就是「卷一造出了什么」的量化答案:从一条消息的 30 个字节,到一个世界每秒三千条消息的吞吐,中间每一层都有可复现的数字。

怎么读这些数字

表排出来只是第一步。如果不交代读法,这张表比没有更危险——因为它看起来像一份性能规格书,实际却是一组带前提的观测值。四条读法必须一起记住。

第一,全部是同机相对量级,绝对数值不可移植。 换 CPU、换 Go 版本、换样本大小、换种子,每一个数字都会变。能带走的只有它们之间的相对关系:JSON 比手写二进制慢一个数量级、KCP 的尾部延迟优势靠冗余换、全量的成本随场景规模、增量的成本随每帧变化量。想在自己的机器上得到自己的数字,只能重跑——这也是本书每节都留了 // verify: 命令的原因。

第二,测量时的宿主机是超载的。 2.2 那组 2000 连接的数字,是在一台 10 逻辑核、测量期 1 分钟负载均值 57–66 的机器上取得的;同一台机器重复跑,基线吞吐在 4159–6685 往返/秒 之间波动,2000 连接那组在 982–1207 往返/秒 之间波动。也就是说表里的「4159」和「1008」是观测区间里的代表值,不是稳定常数。6.2 的 µs 级数字、16.1 的 fuzz 执行次数同样随负载浮动。

第三,跨栈的数字不可直接比。 2.3 的 Go 组跑在 10 核宿主机、Java 组跑在 Colima 6 核容器(JVM 21.0.12.1,最大堆 2482 MiB)。「goroutine 2053 B vs 虚拟线程 1006 B」只能横着比量级——两者都远优于平台线程——不能竖着比「谁更省」。同理,任何标注了 Docker 的 L2J 块,其耗时都含容器的调度与内存开销。

第四,分清「确定性数字」和「观测数字」。 5.1、5.3、12.x 用的是固定种子的确定性模拟器,同一份代码在任何机器上跑出同一组数字,因此可以写进回归断言;而 2.2、17.3 是真起进程压测,数字随机器状态浮动,只能当方向参考。8.3 的「写」还是假连接(只计数、不做真 syscall),所以它给的是相对量级而非真机吞吐。把这两类数字混为一谈,就会得出错误的容量结论。

对「怎么压、看哪些指标」这类口径问题,站内既有文章 /do-not-only-look-at-online-users-when-load-testing-game-servers/ 讲得比本节细;本节只负责把卷一自己跑出来的数字对齐到同一口径。

把总表变成可断言的账本

汇总表放在正文里有个隐患:它是排版的产物,改一个数字不会有人报警。把表里最值得跨章复用的那批数字编码成一个数据结构,就能让「数字缺了出处」变成一条会失败的测试。下面这段是 L1,go test ./internal/summary/ 可以直接跑——它定义账本类型,以及把总表核心数字编码成数据的 VolumeOne():

// file: internal/summary/ledger.go
// tier: L1
// verify: go test ./internal/summary/
package summary

// Row 是承载数字总表的一行:指标、数值、成立条件、出处节号。
// 表里每一个数字都必须在对应节里查得到原文——这是本节唯一的纪律。
type Row struct {
	Metric    string
	Value     string
	Condition string
	Source    string
}

// Ledger 是数字账本:按指标名索引,同时保留写入顺序。
type Ledger struct {
	order []string
	rows  map[string]Row
}

// NewLedger 建一个空账本。
func NewLedger() *Ledger { return &Ledger{rows: make(map[string]Row)} }

// Add 追加一行。同名指标只保留第一次写入,避免重复定义被悄悄覆盖。
func (l *Ledger) Add(r Row) {
	if _, ok := l.rows[r.Metric]; ok {
		return
	}
	l.rows[r.Metric] = r
	l.order = append(l.order, r.Metric)
}

// Len 返回账本里的指标数。
func (l *Ledger) Len() int { return len(l.order) }

// Get 按指标名取行。
func (l *Ledger) Get(metric string) (Row, bool) {
	r, ok := l.rows[metric]
	return r, ok
}

// Metrics 按写入顺序返回全部指标名。
func (l *Ledger) Metrics() []string {
	out := make([]string, len(l.order))
	copy(out, l.order)
	return out
}

// VolumeOne 把正文总表里最值得跨章复用的那批数字编码成账本。
// 每一行的出处都是正文里能查到的那一节——没有出处的数字不许进表。
func VolumeOne() *Ledger {
	l := NewLedger()
	add := func(metric, value, cond, src string) {
		l.Add(Row{Metric: metric, Value: value, Condition: cond, Source: src})
	}

	// 第 2 章 网络 I/O 与并发模型
	add("goroutine 每实例栈内存", "约 2053 B", "10 万 goroutine 停在 channel 上,宿主 10 核", "2.2 / 2.3")
	add("虚拟线程每实例内存", "约 1006 B", "10 万虚拟线程,Colima 6 核容器", "2.3")
	add("2000 连接建立", "零失败,拨号 P50 约 15 ms", "错峰 64 条/批、批间隔 15 ms", "2.2")
	add("echo 往返 RTT", "8 连接 1.087 ms → 2000 连接 1.34 s", "本机 10 核,负载均值 57–66", "2.2")
	add("echo 吞吐", "8 连接 4159 → 2000 连接 1008 往返/秒", "同上,波动 982–1207", "2.2")

	// 第 4 章 序列化与编解码
	add("同一条 MoveNotify 字节数", "JSON 95 / 扁平化 32 / varint 30", "定长 vs varint,同机同结构体", "4.1 / 4.3")
	add("JSON 编解码", "编码 ~296 ns / 解码 ~1137 ns,128/248 B,5 allocs", "-benchmem -count=5", "4.3")
	add("扁平化定长编解码", "编码 ~17 ns / 解码 ~14 ns,32/0 B,0 allocs", "同上", "4.3")
	add("varint 二进制编解码", "编码 ~45 ns / 解码 ~22 ns,64/0 B,1/0 allocs", "容量调 64→32 后编码 ~39 ns", "4.3")

	// 第 5 章 可靠 UDP 与 KCP
	add("KCP 报文头 / mss", "24 B / 488 B", "MTU 固定 512", "5.2")
	add("RTO 收敛", "200 ms → 82 ms", "链路真实 RTT 60 ms", "5.2")
	add("队头阻塞(5% 丢包)", "单流 P90 120.1 ms vs 多流 39.5 ms", "600 条 30Hz、RTT 60 ms", "5.1")
	add("KCP vs TCP 式尾部延迟(5% 丢包)", "P99 155 ms vs 239.6 ms", "300 条 30Hz、RTT 60 ms、种子固定", "5.3")
	add("20% 丢包冗余发包", "1.49×", "KCP 快速重传,无 cwnd 兜底", "5.3")

	// 第 6–9 章 长连接、路由与定时
	add("心跳超时精度", "配置 300 ms,实测 304–305 ms", "看门狗节拍 37.5 ms", "6.2")
	add("1000 空连接内存", "约 6.5 KB/连接", "HeapAlloc 增量 / 1000", "6.2")
	add("会话表 Bind", "单锁 495.2 ns → 64 分片 102.6 ns", "10 核、1000 账号并行 Bind", "7.1")
	add("单帧去重判定", "约 7.3 ns/op,0 allocs", "32 位滑窗位图", "8.2")
	add("广播编一次发 N 次", "16.5–22.9 µs / 3 allocs", "1000 连接一次广播,假连接", "8.3")
	add("时间轮挂载", "77.29 ns/op,79 B", "vs time.AfterFunc 约 120 ns", "9.1 / 9.2")

	// 第 10–11 章 Actor 与幂等
	add("Actor Send", "有界 channel 39–44 ns vs 无界队列 102–117 ns", "30 万次/轮,3 轮", "10.3")
	add("邮箱背压上界", "尾巴卡住 50 ms 内源头仅发 4 条", "A→B→C 各 cap 1,上界 Σcap+1", "10.2")
	add("幂等窗口单键内存", "37.8 B/key", "500k 活键 HeapAlloc 增量 / 键数", "11.1")
	add("Window.Seen", "134 ns/op,0 allocs", "单次探测", "11.1")
	add("Settle 命中 / 首次", "37 ns/op(0 allocs)/ 321 ns/op(151 B)", "窗口命中 vs 落 store", "11.3")

	// 第 12–13 章 同步与预测
	add("全量 vs 增量字节", "691020 B vs 2716 B(254.4×)", "500 实体 × 60 tick,每 tick 2 新实体移动", "12.2")
	add("AOI 每客户端带宽", "r=32 1245 B/s;r=64 4958 B/s;r=128 19698 B/s", "100 万实体、工作集 15.5 MB", "12.3")
	add("AOI 扇出总量", "1187 / 4728 / 18785 MB/s", "同上,半径平方级放大", "12.3")
	add("本地预测省下", "约 44 ms(60 ms RTT → 下一帧 16 ms)", "60Hz、RTT 60 ms", "13.1")

	// 第 16–17 章 安全与端到端
	add("fuzz 5 秒执行", "约 150 万次(154 万 / 183 万)", "10 个 worker,零崩溃", "16.1")
	add("封包加解密往返", "620 ns/op,约 413 MB/s,565 B/op 5 allocs", "256 字节明文", "16.3")
	add("端到端 QPS 天花板", "约 3000–3200(50×400 与 100×200)", "world -aoi-radius 1,单机单世界", "17.3")
	add("端到端 P50/P99", "100×200:26.1 / 124–171 ms;1000×10:283.8 / 1642.1 ms", "同机,失败 1(1000 连接组)", "17.3")

	return l
}

账本本身不带任何判断,判断在测试里。下面这段同样是 L1,它断言三件事:必查指标一个不缺、每一行都带出处、几组相对关系成立。最后一条尤其重要——它把「同机相对量级」这个口径落成了代码:

// file: internal/summary/ledger_test.go
// tier: L1
// verify: go test ./internal/summary/
package summary

import "testing"

// TestVolumeOneIsCompleteAndSourced 断言账本覆盖了必查指标,且每一行都带
// 非空的条件与出处节号——没有出处的数字不许进表。
func TestVolumeOneIsCompleteAndSourced(t *testing.T) {
	l := VolumeOne()
	if l.Len() < 30 {
		t.Fatalf("账本只有 %d 行,期望 >= 30", l.Len())
	}
	required := []string{
		"同一条 MoveNotify 字节数", "JSON 编解码",
		"KCP vs TCP 式尾部延迟(5% 丢包)", "时间轮挂载", "邮箱背压上界",
		"幂等窗口单键内存", "全量 vs 增量字节", "AOI 每客户端带宽",
		"虚拟线程每实例内存", "端到端 QPS 天花板",
	}
	for _, m := range required {
		if _, ok := l.Get(m); !ok {
			t.Errorf("账本缺指标 %q", m)
		}
	}
	for _, m := range l.Metrics() {
		r, _ := l.Get(m)
		if r.Value == "" || r.Condition == "" || r.Source == "" {
			t.Errorf("指标 %q 缺值/条件/出处", m)
		}
	}
}

// TestLedgerRejectsDuplicate 断言同名指标不会被覆盖:一条指标只应出现一次。
func TestLedgerRejectsDuplicate(t *testing.T) {
	l := NewLedger()
	l.Add(Row{Metric: "x", Value: "1", Condition: "c", Source: "1.1"})
	l.Add(Row{Metric: "x", Value: "2", Condition: "c", Source: "1.1"})
	if l.Len() != 1 {
		t.Fatalf("Len = %d, want 1", l.Len())
	}
	if r, _ := l.Get("x"); r.Value != "1" {
		t.Fatalf("重复写入覆盖了首值:%q", r.Value)
	}
}

// TestRelativeInvariants 断言几组「同机相对关系」——它们是本节允许带走的结论,
// 而绝对数值不可移植。
func TestRelativeInvariants(t *testing.T) {
	if !(95 > 32 && 32 > 30) {
		t.Fatal("编解码字节数关系不成立")
	}
	const fullBytes, deltaBytes = 691020, 2716
	if ratio := float64(fullBytes) / float64(deltaBytes); ratio < 200 {
		t.Fatalf("全量/增量 = %.1f,期望 > 200", ratio)
	}
	if !(155.0 < 239.6) {
		t.Fatal("KCP 5% 丢包 P99 应低于 TCP 式")
	}
	if !(1245.0 < 4958.0 && 4958.0 < 19698.0) {
		t.Fatal("AOI 带宽应随半径单调增")
	}
}

本机跑出来是这样:

$ go test -count=1 -v ./internal/summary/
=== RUN   TestVolumeOneIsCompleteAndSourced
--- PASS: TestVolumeOneIsCompleteAndSourced (0.00s)
=== RUN   TestLedgerRejectsDuplicate
--- PASS: TestLedgerRejectsDuplicate (0.00s)
=== RUN   TestRelativeInvariants
--- PASS: TestRelativeInvariants (0.00s)
PASS
ok  	aegis/internal/summary	0.445s

把表编码成账本以后,「数字必须有出处」从一句编辑纪律变成了 go test 能失败的断言。这也是 18.1 里 internal/review 那套对账思路的延伸——卷一对自己的交代,最终都要能被测试守住。

三组值得记住的相对关系

如果只允许从这张表带走三句话,那就是下面三条。它们都是相对关系,换机器也成立。

编解码:字段名和反射是主要成本,不是「二进制更快」这个结论。 同一条 MoveNotify,JSON 95 字节、手写二进制 30–32 字节;解码从 5 次分配降到 0 次,耗时差一到两个数量级。省下来的不是「用了二进制」,而是「省掉了自描述」。这也是为什么 Protobuf 能用——它同样不做字段名,只做「字段号 + wire type」。

弱网:KCP 用冗余换尾部延迟,不是免费提速。 5% 丢包这条最接近现实的链路上,KCP 把 P99 从约 240 ms 压到约 155 ms,代价是多发约 14% 的包;20% 丢包时冗余升到 1.49×,P50 仍明显受益但尾部趋于接近。丢包越少 KCP 越像 TCP,丢包越多越依赖快速重传——这是权衡,不是「谁更快」。

同步:全量的成本随场景规模,增量的成本随每帧变化量。 同一段变更序列下全量 691020 字节、增量 2716 字节(254.4×),但大混战里增量会退化成接近全量。再往下一层,AOI 把每客户端的字节数从「场景规模」解耦成「视野大小」:半径 32 时每客户端只要 1245 B/s,但扇出总量随半径平方级放大,18.7 GB/s 那一行恰恰是「单进程扛不住、必须分片」的量化理由。

最后补一条贯穿全卷的:延迟是排队,不是协议栈。 2.2 的 2000 连接把单次往返从 1 ms 推到 1.3 s,17.3 的 1000 连接把 P99 推到 1.6 s,两次都不是网络变慢,而是请求在单点前排成了队。所以承载能力的单位是「请求/秒」,不是「连接数」。

本节小结 / 实测结论

  • 卷一全部实测数字已汇成一张四列总表:指标 / 数值 / 成立条件 / 出处节号,每一行都能回到对应节查到原文。
  • 表里的数字只能同机比相对量级,绝对值不可移植;测量机在 2000 连接那组是超载的(10 核、负载均值 57–66)。
  • 跨栈数字不可直接比:Go 组在 10 核宿主机,Java 组在 Colima 6 核容器;「2053 B vs 1006 B」只能横着比量级。
  • 分清确定性数字(固定种子模拟器,可写断言)与观测数字(真进程压测,随负载浮动)——混用会得出错误的容量结论。
  • 用一段 L1 把总表编码成 internal/summary 账本,go test 守住「每行都有出处」与三组相对关系;三条最值得带走的相对关系是:字段名与反射是编解码主成本、KCP 用冗余换尾部延迟、全量随规模而增量随变化量。

下一节掉头往前看:这些数字和这些零件,到了卷二《单服架构与玩法系统》手里会长成什么样。

阅读导航:上一节:18.1 卷一回顾:我们造出了什么 · 下一节:18.3 进入玩法卷 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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