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 B | 10 万 goroutine 停在 channel 上,宿主 10 核 | 2.2 / 2.3 |
| goroutine 创建+回收(10 万) | 约 49–65 ms | 含跑 2000 轮 LCG,TotalAlloc 仅 +5.3 MiB | 2.3 |
goroutine 退出后 StackInuse | 195.8 MiB → 3.7 MiB | 栈还给 stackpool 复用,不 free 给 OS | 2.3 |
| 虚拟线程每实例内存 | 约 1006 B | 10 万虚拟线程,Colima 6 核容器 | 2.3 |
| 虚拟线程创建+回收(10 万) | 约 84.094 ms | 单实例 0.841 µs,同一容器内 | 2.3 |
| 平台线程每实例(堆) | 约 479 B(假) | 不含 native 栈;10 万条约 98 GiB | 2.3 |
| 2000 连接建立 | 零失败,拨号 P50 约 15 ms | 错峰 64 条/批、批间隔 15 ms | 2.2 |
| echo 往返 RTT | 8 连接 1.087 ms → 2000 连接 1.34 s | 本机 10 核,测量期负载均值 57–66 | 2.2 |
| echo 吞吐 | 8 连接 4159 → 2000 连接 1008 往返/秒 | 同上,重复跑波动 982–1207 | 2.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=5 | 4.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 ns | 4.3 |
| KCP 报文头 / mss | 24 B / 488 B | MTU 固定 512 | 5.2 |
| RTO 收敛 | 200 ms → 82 ms | 链路真实 RTT 60 ms,flush 节拍 10 ms | 5.2 |
| 50% 丢包下 300 B 消息送达 | 重传 4 次 | 确定性虚拟链路,5 个用例全绿 | 5.2 |
| 队头阻塞(5% 丢包) | 单流 P90 120.1 ms vs 多流 39.5 ms | 600 条 30Hz、RTT 60 ms、种子固定 | 5.1 |
| KCP vs TCP 式尾部延迟(5% 丢包) | P99 155 ms vs 239.6 ms | 300 条 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 增量 / 1000 | 6.2 |
| 心跳 sweep 成本 | 约 65–67 µs/ping | socket write 底噪约 80 µs/次 | 6.2 |
会话表 Bind | 单锁 495.2 ns → 64 分片 102.6 ns | 10 核、1000 账号并行 Bind | 7.1 |
| 单帧去重判定 | 约 7.3 ns/op,0 allocs | 32 位滑窗位图,全整数位运算 | 8.2 |
| 广播编一次发 N 次 | 16.5–22.9 µs / 3 allocs | 1000 连接一次广播,写是假连接 | 8.3 |
| 广播编 N 次(对照) | 65.9–69.4 µs / 2001 allocs | 同上,分配次数差约 660 倍 | 8.3 |
| 时间轮挂载 | 77.29 ns/op,79 B | vs time.AfterFunc 约 120 ns | 9.1 / 9.2 |
| 共享扫描 | 10000 条约 90 µs,9 ns/条,0 B | 心跳超时表整表扫描 | 9.1 |
Actor Send | 有界 channel 39–44 ns vs 无界队列 102–117 ns | 30 万次/轮,3 轮 | 10.3 |
| 邮箱背压上界 | 尾巴卡住 50 ms 内源头仅发 4 条 | A→B→C 各 cap 1,上界 Σcap+1 | 10.2 |
| 幂等窗口单键内存 | 37.8 B/key | 500k 活键 HeapAlloc 增量 / 键数 | 11.1 |
Window.Seen | 134 ns/op,0 allocs | 单次探测,含一次 map 查找/插入 | 11.1 |
| 活键上界 | 100(= r×ttl = 1000/s × 100ms) | 往窗口塞 10 万键后实测 | 11.1 |
业务指纹 FingerprintOf | ≈ 34 ns/op,0 allocs | 2 参数 hash/fnv,全在栈上 | 11.2 |
Settle 命中 / 首次 | 37 ns/op(0 allocs)/ 321 ns/op(151 B) | 窗口命中 vs 落 store | 11.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/s | 100 万实体、工作集 15.5 MB | 12.3 |
| AOI 扇出总量 | 1187 / 4728 / 18785 MB/s | 同上,半径平方级放大 | 12.3 |
| 本地预测省下 | 约 44 ms(60 ms RTT → 下一帧 16 ms) | 60Hz、RTT 60 ms | 13.1 |
| 一次和解重放条数 | 等于在途输入数(60Hz、RTT 100ms ≈ 6 次) | 纯浮点加法 Step,纳秒量级 | 13.2 |
| fuzz 5 秒执行 | 约 150 万次(154 万 / 183 万) | 10 个 worker,零崩溃、零 OOM | 16.1 |
| slowloris 超时 | 配置 150 ms,实测 150–160 ms | 静默客户端被断开 | 16.2 |
| 封包加解密往返 | 620 ns/op,约 413 MB/s,565 B/op 5 allocs | 256 字节明文,单核 | 16.3 |
| 端到端 QPS 天花板 | 约 3000–3200(50×400 与 100×200) | world -aoi-radius 1,单机单世界 | 17.3 |
| 端到端 P50/P90/P99 | 100×200:26.1 / 29.8 / 124–171 ms | 同机,1000×10 组失败 1 | 17.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 进入玩法卷 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。