18.1 卷一回顾:我们造出了什么
从第 1 章画分层图,到第 17 章把零件装成可执行文件 aegis,这一卷写了 17 章、54 节。每一章都在解决一个具体问题、留下一件具体的零件,但我们从没停下来问一句:这些东西合起来到底是什么?
这一节就做这件事:把 17 章的零件按依赖顺序摊开,逐章交代它解决了什么问题、留下了什么接口、下一章为什么需要它。它不引入任何新代码,只做一次对账——而对账本身也该是能被测试的,否则「每一章都留了东西」就只是一句自我感觉良好的话。
读完这一节,你应该能在脑子里画出这条线:一段字节流,怎么一步步长成一个能被服务端裁决、能被客户端看见、能在三千人在线时还站得住的世界。
零件总账:把 17 章排成一张表
先把 17 章的产出排成一张表。左列是章号,中列是这一章造出的零件,右三列分别是它在哪个包、用什么命令验收、以及它跑出来的最硬的那个数字。数字全部来自本机实跑,只作同机相对量级,绝对数值不可移植:
| 章 | 零件 | 包与产物 | 验收方式 | 实测结论 |
|---|---|---|---|---|
| 1 | 分层与启停顺序 | internal/overview | go test | 启停事件序列 start→shutdown 逆序回卷 |
| 2 | 网络 I/O 与并发模型 | internal/netio | go test | 2000 连接零失败;栈 2053 B/实例 |
| 3 | 帧格式与命令号 | internal/proto | go test | HeaderSize = 11,length = 7 + N |
| 4 | 二进制编解码 | internal/codec | go test | 扁平化 32 B / 16.7 ns vs JSON 95 B / 295 ns |
| 5 | 可靠 UDP(KCP) | internal/kcp | go test | 报文头 24 B;RTO 收敛 98→82 ms |
| 6 | WebSocket 接入 | internal/ws | go test | 1000 条空连接约 6.5 KB/条 |
| 7 | 会话表 | internal/session | go test | 单锁 495 ns → 64 分片 102.6 ns |
| 8 | 路由与扇出 | internal/router | go test | 去重 7.3 ns/帧;广播编一次 16.5 µs |
| 9 | 定时器与时间轮 | internal/timing | go test | 时间轮挂载 77 ns vs AfterFunc 120 ns |
| 10 | Actor 邮箱 | internal/actor | go test | 16000 条在 -race 下无竞争 |
| 11 | 幂等窗口 | internal/idem | go test | 37.8 B/键;200 次重放只授予 1 次 |
| 12 | 状态同步模型 | internal/sync | go test | 增量 2716 B vs 全量 691020 B(254×) |
| 13 | 预测与和解 | internal/predict | go test | 本地预测把「等一个 RTT」省到约 44 ms |
| 14 | 时间线与延迟补偿 | internal/clock | go test | 回退 = 射手 RTT/2 + 插值延迟 |
| 15 | 权威与反外挂 | internal/authority | go test | 超速 18 > 6 拒;瞬移 100 单位拒 |
| 16 | 安全防护 | internal/guard | go test | fuzz 150 万次 0 崩溃;AES-GCM 620 ns |
| 17 | 装配与端到端 | cmd/aegis | go build | adds=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 承载数字汇总 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。