《MMO 服务器开发》18.1 卷一回顾:我们造出了什么

卷一 17 章各造了一件零件,却从没按一条主线串起来看过。本节把帧格式、会话表、路由、时间轮、Actor 邮箱、幂等窗口、同步模型与权威校验按依赖顺序排成一张「零件 → 文件 → 验收方式 → 实测结论」总账,逐章交代它解决了什么问题、留下了什么接口、下一章为什么需要它,并用五条贯穿全卷的伏笔说明这些零件如何拼成一个可裁决的世界。

18.1 卷一回顾:我们造出了什么

从第 1 章画分层图,到第 17 章把零件装成可执行文件 aegis,这一卷写了 17 章、54 节。每一章都在解决一个具体问题、留下一件具体的零件,但我们从没停下来问一句:这些东西合起来到底是什么?

这一节就做这件事:把 17 章的零件按依赖顺序摊开,逐章交代它解决了什么问题、留下了什么接口、下一章为什么需要它。它不引入任何新代码,只做一次对账——而对账本身也该是能被测试的,否则「每一章都留了东西」就只是一句自我感觉良好的话。

读完这一节,你应该能在脑子里画出这条线:一段字节流,怎么一步步长成一个能被服务端裁决、能被客户端看见、能在三千人在线时还站得住的世界。

零件总账:把 17 章排成一张表

先把 17 章的产出排成一张表。左列是章号,中列是这一章造出的零件,右三列分别是它在哪个包、用什么命令验收、以及它跑出来的最硬的那个数字。数字全部来自本机实跑,只作同机相对量级,绝对数值不可移植:

章零件包与产物验收方式实测结论
1分层与启停顺序internal/overviewgo test启停事件序列 start→shutdown 逆序回卷
2网络 I/O 与并发模型internal/netiogo test2000 连接零失败;栈 2053 B/实例
3帧格式与命令号internal/protogo testHeaderSize = 11,length = 7 + N
4二进制编解码internal/codecgo test扁平化 32 B / 16.7 ns vs JSON 95 B / 295 ns
5可靠 UDP(KCP)internal/kcpgo test报文头 24 B;RTO 收敛 98→82 ms
6WebSocket 接入internal/wsgo test1000 条空连接约 6.5 KB/条
7会话表internal/sessiongo test单锁 495 ns → 64 分片 102.6 ns
8路由与扇出internal/routergo test去重 7.3 ns/帧;广播编一次 16.5 µs
9定时器与时间轮internal/timinggo test时间轮挂载 77 ns vs AfterFunc 120 ns
10Actor 邮箱internal/actorgo test16000 条在 -race 下无竞争
11幂等窗口internal/idemgo test37.8 B/键;200 次重放只授予 1 次
12状态同步模型internal/syncgo test增量 2716 B vs 全量 691020 B(254×)
13预测与和解internal/predictgo test本地预测把「等一个 RTT」省到约 44 ms
14时间线与延迟补偿internal/clockgo test回退 = 射手 RTT/2 + 插值延迟
15权威与反外挂internal/authoritygo test超速 18 > 6 拒;瞬移 100 单位拒
16安全防护internal/guardgo testfuzz 150 万次 0 崩溃;AES-GCM 620 ns
17装配与端到端cmd/aegisgo buildadds=6 / removes=3;瞬移被拒

这张表是给人看的。下面这份是给编译器看的——把同一份总账编码成一个可断言的 Go 包。它是 L1,go test 可以直接跑:

// file: internal/review/ledger.go
// tier: L1
// verify: go test ./internal/review/

// Package review is the machine-readable version of section 18.1's recap: one
// entry per chapter recording the part volume one built, the package it lives
// in, the artifact to reach for first, and the command that proves it runs.
// Two invariants a recap is most likely to break — every chapter left
// something behind, and a thread only points at chapters that exist — become
// assertions instead of promises.
package review

// Part is one reusable part a chapter left behind.
type Part struct {
	Chapter  int    // 1..17
	Slug     string // section that first builds it, as a relref target
	Pkg      string // import path under the aegis module root
	Artifact string // exported type or file to reach for first
	Verify   string // command that runs from the module root
}

// Ledger is every part volume one builds, in chapter order. Chapter 18 adds no
// part of its own; it only points back at these.
var Ledger = []Part{
	{1, "mmo-net-01-1-layered-responsibilities", "internal/overview", "Layer / Component / Server", "go test ./internal/overview/"},
	{2, "mmo-net-02-1-blocking-nonblocking-reactor", "internal/netio", "Reactor.Run", "go test ./internal/netio/"},
	{3, "mmo-net-03-2-length-prefix-and-framing", "internal/proto", "Frame / ReadFrame", "go test ./internal/proto/"},
	{4, "mmo-net-04-1-binary-codec-from-scratch", "internal/codec", "Writer / Reader", "go test ./internal/codec/"},
	{5, "mmo-net-05-2-kcp-from-scratch", "internal/kcp", "KCP / Input", "go test ./internal/kcp/"},
	{6, "mmo-net-06-1-websocket-handshake", "internal/ws", "ReadFrame / WriteFrame", "go test ./internal/ws/"},
	{7, "mmo-net-07-1-session-table", "internal/session", "Table / ShardedTable", "go test ./internal/session/"},
	{8, "mmo-net-08-1-routing-table", "internal/router", "Router / Dispatch", "go test ./internal/router/"},
	{9, "mmo-net-09-2-time-wheel-from-scratch", "internal/timing", "Wheel / After", "go test ./internal/timing/"},
	{10, "mmo-net-10-1-message-driven-entity", "internal/actor", "Entity / Registry", "go test ./internal/actor/"},
	{11, "mmo-net-11-1-idempotency-window", "internal/idem", "Window / Settler", "go test ./internal/idem/"},
	{12, "mmo-net-12-1-state-sync-vs-lockstep", "internal/sync", "World / Step", "go test ./internal/sync/"},
	{13, "mmo-net-13-1-input-buffer-and-prediction", "internal/predict", "Predictor / Reconcile", "go test ./internal/predict/"},
	{14, "mmo-net-14-1-server-timeline", "internal/clock", "Timeline / Estimator", "go test ./internal/clock/"},
	{15, "mmo-net-15-1-authority-boundary", "internal/authority", "Registry / Check", "go test ./internal/authority/"},
	{16, "mmo-net-16-2-flood-and-conn-exhaustion", "internal/guard", "Gate / Limiter / Sealer", "go test ./internal/guard/"},
	{17, "mmo-net-17-1-assemble-aegis", "cmd/aegis", "gateway.Config + main", "go build ./cmd/aegis/"},
}

// Station is one chapter on a thread.
type Station struct {
	Chapter int
	Note    string
}

// Thread is one idea carried from chapter to chapter: where it is first stated,
// and every later chapter that reuses or sharpens it.
type Thread struct {
	Name     string
	Stations []Station
}

// Threads are the ideas that appear in more than one chapter — the spine of the
// recap.
var Threads = []Thread{
	{"延迟是排队,不是协议栈", []Station{
		{2, "2000 连接把往返从 1.087ms 抬到 1.34s,Little 定律 2000 = 1008 × 1.98s 对得上"},
		{8, "广播编一次发 N 次:1000 连接 16.5µs vs 逐条编 67.5µs"},
		{9, "积压要丢帧,不能追赶成死亡螺旋(maxSteps 默认 8)"},
		{15, "令牌桶把在途请求压回队列容量内"},
	}},
	{"容量的单位是请求/秒,不是连接数", []Station{
		{2, "8 连接 4159 rt/s → 2000 连接 1008 rt/s,挂机连接几乎不花钱"},
		{7, "网关按账号限流:NewLimiter(10, 3)"},
		{15, "Throttle 时令牌桶容量与回填一起降"},
		{16, "每 IP 上限 64、全局 10000、握手令牌桶 burst 5 / refill 200ms"},
	}},
	{"11 字节帧头是全书共享的契约", []Station{
		{3, "固定头 11 字节,length = 7 + N"},
		{4, "编解码复用同一 wire 顺序,越界返回错误而非 panic"},
		{12, "增量编码复用同一头,CmdAttrSync = 0x0103"},
		{16, "fuzz 直接打 DecodeFrom:5 秒约 150 万次、0 崩溃"},
	}},
	{"客户端只提意图,服务端裁决", []Station{
		{13, "本地预测先走一步,服务端回 ack 后裁剪重放"},
		{14, "延迟补偿回退到射手当时看到的那一刻再判命中"},
		{15, "信任三分:客户端发 Derived 字段即违规;Tolerance 1.1、TeleportThreshold 40"},
	}},
	{"Actor 邮箱把锁消掉", []Station{
		{10, "8 发送者 × 2000 条在 -race 下无竞争;背压上界 Σcap + 1"},
		{17, "world 单 goroutine 消费邮箱,sessions / byName / grid 一把锁不用"},
	}},
}

// AssembleOrder is the dependency order chapter 17.1 fixed for the assembly.
var AssembleOrder = []string{"internal/proto", "internal/session", "internal/router", "internal/timing", "internal/actor"}

配套测试只做四件事:每一章恰好留下一件零件、每件零件的四个字段都填齐且命令可执行、每条伏笔只指向真实存在的章、以及装配顺序与 17.1 定下的依赖顺序一致。下面摘录其中两条:

// snippet: internal/review/ledger_test.go(节选)
func TestEveryPartIsCompleteAndRunnable(t *testing.T) {
	for _, p := range Ledger {
		if p.Slug == "" || strings.Contains(p.Slug, ".md") || strings.HasPrefix(p.Slug, "/") {
			t.Errorf("chapter %d slug %q must be a bare same-volume relref target", p.Chapter, p.Slug)
		}
		if !strings.HasPrefix(p.Pkg, "internal/") && p.Pkg != "cmd/aegis" {
			t.Errorf("chapter %d package %q is neither under internal/ nor cmd/aegis", p.Chapter, p.Pkg)
		}
		if p.Artifact == "" {
			t.Errorf("chapter %d has no artifact", p.Chapter)
		}
		if !strings.HasPrefix(p.Verify, "go test") && !strings.HasPrefix(p.Verify, "go build") {
			t.Errorf("chapter %d verify %q is not a go command", p.Chapter, p.Verify)
		}
	}
}

func TestThreadsStayOnChaptersThatExist(t *testing.T) {
	inLedger := map[int]bool{}
	for _, p := range Ledger {
		inLedger[p.Chapter] = true
	}
	for _, th := range Threads {
		for _, s := range th.Stations {
			if !inLedger[s.Chapter] {
				t.Errorf("thread %q points at chapter %d, which left no part behind", th.Name, s.Chapter)
			}
		}
	}
}

本机实跑(go test -v ./internal/review/):

=== RUN   TestLedgerCoversEveryChapterOnce
--- PASS: TestLedgerCoversEveryChapterOnce (0.00s)
=== RUN   TestEveryPartIsCompleteAndRunnable
--- PASS: TestEveryPartIsCompleteAndRunnable (0.00s)
=== RUN   TestThreadsStayOnChaptersThatExist
--- PASS: TestThreadsStayOnChaptersThatExist (0.00s)
=== RUN   TestAssembleOrderMatchesLedger
--- PASS: TestAssembleOrderMatchesLedger (0.00s)
PASS
ok  	aegis/internal/review	0.504s

一条主线:从字节流到可裁决的世界

把表摊平了看,17 章其实是一条很直的线。

第 1–3 章是地基。 1.1 先立四层分层,并且把「启动顺序」写成可执行的断言——启停事件序列必须 start:gateway → … → shutdown:gateway 地来、逆序地回。1.2 诚实划了边界:站内 170 篇实战文里可运行代码是 0,本卷只补「从零实现 + 数字」这一半。1.3 数出十个零件,其中 7 个是必需件——这就是后面 14 章的施工清单。2.1 从零写了一个 Reactor,再指出 Go 的 net 包底下就是同一套 netpoller;2.2 用 2000 条连接证明「成本在调度不在内存」;2.3 把 goroutine、Java 虚拟线程、Netty EventLoop 摆在一张表里。3.1 定下全书共享的 11 字节帧头,3.2 用 length = 7 + N 把字节流切成帧,3.3 给出 [1, 3] 的版本协商区间。到这里,「一段字节流」第一次变成了「一条条有边界的消息」。

第 4–6 章把消息搬上网络。 4.1 的 Writer / Reader 越界一律返回错误、绝不 panic;4.3 量出扁平化 32 字节 / 16.7 ns,对比 JSON 95 字节 / 295 ns;5.1 用确定性模拟器把 TCP 队头阻塞的代价算出来,5.2 从零写 KCP(24 字节报文头、mss = 488、RTO 从 200ms 收敛到 82ms),5.3 在 0/1/5/20% 四档丢包下量 P50/P99;6.1 手写 WebSocket 握手与分片,6.2 用看门狗管心跳,6.3 发一张只能验一次的重连票据。传输层的代价,第一次有了数字。

第 7–9 章把连接、路由和定时接起来。 7.1 的会话表把「账号 ↔ 连接」双向索引做到并发一致,单锁 495 ns 被 64 分片降到 102.6 ns;7.2 处理顶号与迁移,7.3 用类型签名把网关边界钉死——Route func(cmd uint16) string 根本拿不到 payload,网关想越权都难。8.1 把隐式约定变成显式路由表,8.2 用 32 位序号 + 滑窗位图去重,单帧判定只要 7.3 ns,8.3 证明广播「编一次发 N 次」比逐条编码快 3–4 倍。9.1 把心跳与冷却做成惰性查询,9.2 从零写哈希时间轮,9.3 用 maxSteps = 8 把积压挡成丢帧而不是死亡螺旋。世界有了节拍。

第 10–11 章解决「怎么改状态才不出错」。 10.1 把玩家实体改成消息驱动,所有状态只被自己的邮箱推进;10.2 给邮箱加背压,在途消息数被卡在 Σcap + 1;10.3 对照 Go channel 与 Erlang/Skynet 式 Actor。11.1 的幂等窗口每键只花 37.8 字节,11.2 用服务端复算的业务指纹当键,11.3 把窗口、持久唯一约束、状态机三层叠起来——200 次重放最终只授予一次。至少一次投递下,奖励不会发两次。

第 12–14 章进入实时同步。 12.1 讲清状态同步与帧同步的服务端分工,12.2 的增量编码把 691020 字节的全量压成 2716 字节(254 倍),12.3 用均匀网格做兴趣过滤,半径 32 时每客户端只要 1245 B/s。13.1 让客户端本地预测、省下约 44 ms,13.2 用「裁剪 → 复位 → 重放」和解,13.3 用插值把离散快照画成连续运动。14.1 用单调钟搭一条只向前走的时间线,14.2 按「服务端时间 − 射手 RTT/2 − 插值」回退再判命中,14.3 给回退设上界、识别 lag switch。客户端看到的画面,第一次由服务端说了算。

第 15–17 章收口。 15.1 把信任分成 Untrusted / Advisory / Derived 三级,客户端敢发 Derived 字段就是违规;15.2 用超速、瞬移、穿墙三条规则校验移动;15.3 把令牌桶、指数衰减嫌疑分和处置梯度接成一条信号管线。16.1 用 fuzzer 在 5 秒里打了约 150 万次畸形帧、零崩溃,16.2 用配额 + 限速 + 超时守住接入层,16.3 用 AES-256-GCM 把封包加密压到 620 ns 一次。最后 17.1 把前 16 章按依赖顺序装成 aegis,Listen 与 Serve 分离保证端口冲突 fail fast;17.2 让三个客户端真的互相看见——adds=6 / removes=3,一次瞬移被服务端当场拒绝。

五条伏笔:跨章复用的到底是什么

上一条主线是按章讲的。但真正让 17 章「连成一体」的,是几条反复出现的伏笔——它们在某一章第一次被说出,然后在后面几章里被反复引用、加码。把上面那份 Threads 展开,就是这五条:

  • 延迟是排队,不是协议栈。 2.2 用 Little 定律把 2000 连接下的 1.34 s 往返解释成排队,这条判断在 8.3 的扇出、9.3 的 Tick 预算、15.3 的限速里各出现一次。它决定了容量规划的单位是「在途请求数」。
  • 容量的单位是请求/秒,不是连接数。 同样起于 2.2(挂机连接几乎不花钱),落到 7.3 的网关限流、15.3 的令牌桶、16.2 的每 IP 配额。一个被外挂驱动的客户端,一条连接就能把整个网关的排队时间拉起来。
  • 11 字节帧头是全书共享的契约。 3.1 定死 length(4) | cmd(2) | seq(4) | flags(1),此后 4 章的编解码、12 章的增量编码、16 章的 fuzz 全部复用同一套定义,没有任何一章自创第二套帧格式。
  • 客户端只提意图,服务端裁决。 13.1 让客户端本地预测「先走一步」,14.2 用延迟补偿决定「打没打中」,15.1–15.3 把这条原则落成信任分级、移动校验和处置梯度。客户端能做的永远只是「提议」。
  • Actor 邮箱把锁消掉。 10.1–10.2 用邮箱串行化状态,10.3 对照两种 Actor 实现,最后 17.1 的 world 用单 goroutine 消费邮箱,于是 sessions、byName、grid 这些状态一把锁都不用。这是第 10 章对第 17 章最直接的兑现。

这五条不是事后总结的漂亮话,它们是写代码时真的被反复引用的约束——Ledger 里每一件零件、Threads 里每一个站点,都能回到某一节正文里找到出处。

与站内既有文章的分工

既有文章它负责什么本节只做什么
游戏服务器架构评审清单上线前的评审项与检查表不复述清单,只把卷一自己的零件逐条对账
游戏服务器压测机器人平台架构压测平台怎么搭、机器人怎么编排不搭平台,只引用 17.3 那组承载数字的出处
压测不要只看在线人数压测该看哪些指标、怎么看不展开指标口径,只交代每章留下的可验收产物
游戏服务器权威状态架构权威状态的方案取舍只回顾第 15 章把权威边界落成的代码

一句话:别人讲「这套服务该按什么标准评审、压测该看什么」,本节讲「卷一这 17 章自己到底造出了什么、每一件凭什么说它跑通了」。

本节小结

  • 17 章留下 17 件零件,从 internal/overview 到 cmd/aegis,每件都有明确的包、产物和验收命令——这是「卷一造出了什么」的完整清单。
  • 它们是一条依赖链,不是一堆积木。 proto 无依赖、session 依赖 proto、router 与 timing 依赖 session、aoi 依赖 session 的位置、world 依赖全部;17.1 的装配顺序就是这条链。
  • 五条伏笔把章节缝在一起:排队论、请求/秒、11 字节帧头、服务端裁决、Actor 邮箱——它们跨章复现,也是本卷最值得记住的东西。
  • 对账可以被测试。 把总账写成 internal/review 后,「每章都留了东西」「伏笔不指向幽灵章节」从口号变成了 go test 能失败的断言。

下一节不再讲零件,而是把这些零件跑出来的数字收进一张表——2.2 的往返、5.3 的丢包、8.3 的扇出、12.2 的增量、16.3 的加密,全部对齐到同一口径,看它们合起来能支撑一个什么样的承载量级。

阅读导航:上一节:17.3 机器人客户端与第一组承载数字 · 下一节:18.2 承载数字汇总 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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