10.2 与 skynet / pitaya / leaf 对照与全书收官
10.1 把 Go 与 Java 两条栈放在同一张矩阵上。但选型还有另一个方向:要不要用现成的游戏服务端框架,而不是像本书这样从零造 aegis。 站内已经有一个 Skynet 专题和一个 Pitaya 专题,读者多半会问:既然有成熟的框架,为什么还要自己写一遍?
这一节回答两件事。前半部分对照三个最常被拿来讨论的框架——skynet(C + Lua 的 Actor 框架)、pitaya(Go 分布式游戏框架)、leaf(Go 轻量框架)——讲清各自的定位、适用规模,以及它们与 aegis 的差异。后半部分做全书收官:把四卷的贯穿线、可运行产物与实测数字汇总到一张表,给读者一份「下一步怎么继续」的建议。
先给一句结论,免得读者读完前半部分还在纠结「是不是白写了」:成熟框架解决「开箱即用」,本书解决「每一层为什么这么造、以及怎么按自己的需求改造」。 两者不冲突——很多团队恰恰是先用了框架,遇到天花板,才回头读这一类的实现。
三个框架的定位与适用规模
三个框架的分歧不在「谁更快」,而在它们各自假设了什么规模、把哪些东西做成了开箱即用。对照如下:
| 框架 | 语言 | 定位 | 适用规模 | 与 aegis 的差异 |
|---|---|---|---|---|
| skynet | C + Lua | Actor 模型框架:一个节点是一组 service,service 之间只靠消息通信,C 写调度、Lua 写业务 | 中小到中大型;单节点数万连接,靠多节点横向扩展 | skynet 的 actor 更「重」(每个 service 独立调度、独立栈);aegis 的 actor 建在 goroutine + 有界邮箱上,用背压而不是无限队列 |
| pitaya | Go | 面向游戏与实时应用的分布式框架:前端 connector 与后端 handler 分离,内建 RPC 与服务发现 | 中大型分布式;天然多进程 | pitaya 把连接、路由、RPC、服务发现做成开箱即用;aegis 把这些当作「要自己写」的教学目标,一层一层实现出来 |
| leaf | Go | 轻量游戏服务器框架:单进程内按需组合 leaf 模块,自带时序调度 | 小型到中型;单进程或少量进程 | leaf 强调极简与单进程内模块化;aegis 覆盖从网络内核到跨服集群的完整四卷,把集群层也写了出来 |
三者的定位差异,一句话概括:skynet 赌「actor 是万能抽象」,pitaya 赌「分布式骨架要开箱即用」,leaf 赌「单进程足够、别过度设计」。 三者都对——对的是各自假设的规模。
什么时候直接用它们:如果你想尽快上线、团队不想维护网络层与集群层,pitaya(Go)或 skynet(Lua)能省下几个月。什么时候读本书:如果你遇到框架的天花板(比如 skynet 单节点 actor 数量上限、pitaya 的某段路由不够快)、或者团队本来就要自研、又或者你只是想真正理解「一个 MMO 服务端的每一层长什么样」,本书提供的就是那份逐层实现。
需要强调:本节不评价框架优劣。skynet 在 Lua 生态里的成熟度、pitaya 在 Go 分布式上的完整度,都不是本书要挑战的。本书的立场是——读懂它们,然后知道 aegis 与它们的每一处差异是从哪个约束里长出来的。站内更细的框架对照与架构取舍,见游戏服务端实战专题导航
。
aegis 与三者的差异
把差异归到三条线上,每一条都对应本书的一个设计决定:
- 语言边界。 skynet 用 Lua 写业务、C 写调度,边界处有一次跨语言调用的成本;aegis 全程 Go 单语言,actor、网络、编解码都在一个运行时里,代价是没有 Lua 那样的热更能力(本书第 5 章讲热重载边界时正面回答过这个问题)。
- 分布式的边界。 pitaya 把前端与后端进程分开、服务发现与 RPC 开箱即用;aegis 把这条线留给你自己画——卷三用真实 etcd / NATS / Consul 从零写了注册、心跳、路由表与跨服战场。代价是更多代码,收益是每一处都能按需改。
- 抽象的克制。 leaf 的模块组合与 skynet 的 actor 都是很强的抽象;aegis 刻意克制——有界邮箱、显式背压、手写帧格式,为的是让每一层的成本可见(卷一 10.2 实测过「有界 channel 39–44 ns vs 无界队列 102–117 ns」的差距)。抽象不是越多越好,而是在你能解释它的成本时才好。
一句话:skynet 给你 actor,pitaya 给你集群,leaf 给你模块,aegis 给你一条从字节到集群的完整、可解释的路径。
全书收官:四卷的贯穿线
四卷不是四本独立的书,而是一条从下往上的线。每一卷回答上一卷解决不了的问题:
| 卷 | 回答的问题 | 关键产物 | 收口之处 |
|---|---|---|---|
| 卷一《网络与并发内核》 | 一条消息怎么从网线走到业务 | 帧编解码、KCP 传输、时间轮、会话路由、Actor 邮箱 | 端到端 QPS 天花板与分位数字 |
| 卷二《单服架构与玩法系统》 | 玩法怎么装进这个内核 | 实体运行时、AOI、战斗管线、背包、任务、社交、匹配 | 单服同屏承载与帧预算 |
| 卷三《分布式与跨服》 | 规模怎么做上去 | 服务发现、网关集群、实例调度、跨服战场、分库分表 | 集群承载与跨服往返延迟 |
| 卷四《工程化与运营》 | 怎么交付并长期运营 | 压测平台、指标、配置中心、埋点、GM、容灾、本矩阵 | 上线清单与跨栈选型标准 |
这条线最值得记住的一点是:每一卷的「结论」都是下一卷的「前提」。 卷一造出的帧格式是卷二玩法消息的载体,卷二的单服承载数字是卷三「该不该拆」的判据,卷三的集群拓扑是卷四部署与容灾的对象。断掉任何一环,后面的数字都失去意义。
全书可运行产物与实测数字汇总
四卷一共造出 29 项可运行产物,覆盖从帧编解码到跨栈矩阵的每一层。代表实测数字汇总如下(全部同机相对量级,绝对数值不可移植):
| 卷 | 代表实测数字 | 出处 |
|---|---|---|
| 卷一 | goroutine 每实例约 2053 B;同一条 MoveNotify JSON 95 B vs 扁平化 32 B;端到端 QPS 天花板约 3000–3200 | 卷一 2.3 / 4.1 / 17.3 |
| 卷二 | 1000 同屏玩家 4.76 ms/tick,占 30Hz 帧预算 14.3%;玩法不是瓶颈,网络路径才是 | 卷二 18.3 |
| 卷三 | 4 节点、单网关 1000 连接、匹配吞吐约 570 万/s、跨服往返 P50 1.513 ms / P99 5.025 ms | 卷三 11.3 |
| 卷四 | 全书四卷产物合计 29 项;跨栈选型打分器可复现判定 | 本节校验器 / 10.1 |
这张表有一个共同的读法:数字只回答「这一层本身有多贵」,不回答「线上能带多少」。 端到端 QPS 天花板不是硬件上限,匹配吞吐不是线上撮合吞吐,单网关 1000 连接是测试上限不是硬顶。四卷的数字能迁移的只有相对关系——延迟尾巴比中位数长、纯逻辑吞吐远高于带网络的吞吐、玩法结算的占比远低于网络路径。
下一步怎么继续
读到这里,你已经有了一个从字节到集群、并且跑过数字的 MMO 服务端。接下来有四个方向,按投入从小到大:
- 补观测与容灾(如果你已经在线)。回到第 3 章(日志、指标、链路)与第 8 章(备份、多活、演练),这三处是「出事时能不能收住」的直接答案。
- 换工业级组件。把
aegis的某几层替换成生产级实现:用 pitaya 的 connector 换掉卷一的网关、用 skynet 的 service 模型对照卷二的 actor、把本机 Redis 换成集群版。每换一层,用本书的基准脚本量一次差异。 - 读源码。站内 Skynet 与 Pitaya 专题有逐模块的解析;对照
aegis的实现读一遍,能看清「同一个问题,工业级框架为什么这么解」。 - 上真实环境。多地域多活、真实公网 DDoS、云厂商控制台操作——这些本书标注为 L4、本机不可复现。要做,就带着本书的清单去真实环境里做,并把每一步的结果记下来。
最后一句:本书的目标不是给你一个能上线的服务端,而是让你有能力判断「哪一层可以买、哪一层必须自己写」。 这个判断力,才是从「会用框架」到「能带项目」的分界线。
可运行产物:全书产物清单校验器
上面那张「29 项产物」不是口号,是可以被校验的。本节把四卷的产物清单写成一份 JSON,再用一个校验器断言每一卷都在、前缀都对、都有产物——这样「全书齐了」就成了一条可失败的测试,而不是一句自评。
下面这段是 L1,go test 可以直接跑。先是校验器 internal/matrix/manifest.go:
// file: internal/matrix/manifest.go
// tier: L1
// verify: go test ./internal/matrix/
package matrix
import (
"encoding/json"
"fmt"
"os"
)
// Volume is one of the four books, with the runnable artifacts it shipped.
type Volume struct {
ID string `json:"id"`
Title string `json:"title"`
Prefix string `json:"prefix"`
Artifacts []string `json:"artifacts"`
}
// Manifest is the whole-book artifact index, kept as JSON so the last section of
// the last volume can assert that nothing was left half-shipped.
type Manifest struct {
Volumes []Volume `json:"volumes"`
}
// expectedVolumes is the four-volume through-line, in reading order. The order
// is fixed so Validate's output is deterministic rather than map-ordered.
var expectedVolumes = []struct {
ID string
Prefix string
}{
{"vol1", "mmo-net-"},
{"vol2", "mmo-srv-"},
{"vol3", "mmo-dist-"},
{"vol4", "mmo-eng-"},
}
// LoadManifest reads the artifact manifest from disk.
func LoadManifest(path string) (*Manifest, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("matrix: parse manifest: %w", err)
}
return &m, nil
}
// Validate checks that all four volumes are present, carry the expected slug
// prefix, and shipped at least one artifact. It returns one problem per missing
// or malformed entry, so the closing section can print a complete picture
// instead of stopping at the first error.
func (m *Manifest) Validate() []string {
var problems []string
byID := make(map[string]Volume, len(m.Volumes))
for _, v := range m.Volumes {
byID[v.ID] = v
}
for _, want := range expectedVolumes {
v, ok := byID[want.ID]
if !ok {
problems = append(problems, fmt.Sprintf("volume %s missing from manifest", want.ID))
continue
}
if v.Prefix != want.Prefix {
problems = append(problems, fmt.Sprintf("volume %s prefix = %q, want %q", want.ID, v.Prefix, want.Prefix))
}
if len(v.Artifacts) == 0 {
problems = append(problems, fmt.Sprintf("volume %s (%s) shipped no artifacts", want.ID, v.Title))
}
}
return problems
}
清单本身是一份 JSON,四卷各带前缀与产物:
{
"volumes": [
{
"id": "vol1",
"title": "网络与并发内核",
"prefix": "mmo-net-",
"artifacts": ["帧编解码", "KCP 传输", "时间轮", "会话路由", "Actor 邮箱", "快照与增量", "AOI 与广播"]
},
{
"id": "vol2",
"title": "单服架构与玩法系统",
"prefix": "mmo-srv-",
"artifacts": ["实体运行时", "AOI 九宫格", "战斗管线", "背包与交易", "任务 DAG", "组队与公会", "匹配与实例分配"]
},
{
"id": "vol3",
"title": "分布式与跨服",
"prefix": "mmo-dist-",
"artifacts": ["服务发现", "gRPC RPC", "网关集群", "实例调度", "跨服战场", "雪花 ID", "分库分表与事务"]
},
{
"id": "vol4",
"title": "工程化与运营",
"prefix": "mmo-eng-",
"artifacts": ["机器人压测", "性能剖析", "指标打点", "配置中心", "埋点管道", "GM 后台", "备份恢复与演练", "跨栈对照矩阵"]
}
]
}
测试 internal/matrix/manifest_test.go 断言两类失败都能被抓到——缺卷、前缀不对、空产物——并加载真实清单确认全书齐备:
// file: internal/matrix/manifest_test.go
// tier: L1
// verify: go test ./internal/matrix/
package matrix
import (
"strings"
"testing"
)
func TestValidateCompleteManifest(t *testing.T) {
m := &Manifest{Volumes: []Volume{
{ID: "vol1", Title: "网络与并发内核", Prefix: "mmo-net-", Artifacts: []string{"帧编解码", "KCP 传输", "时间轮"}},
{ID: "vol2", Title: "单服架构与玩法系统", Prefix: "mmo-srv-", Artifacts: []string{"实体运行时", "AOI", "战斗管线"}},
{ID: "vol3", Title: "分布式与跨服", Prefix: "mmo-dist-", Artifacts: []string{"服务发现", "网关集群", "跨服战场"}},
{ID: "vol4", Title: "工程化与运营", Prefix: "mmo-eng-", Artifacts: []string{"机器人压测", "指标打点", "GM 后台"}},
}}
if p := m.Validate(); len(p) != 0 {
t.Fatalf("Validate() = %v, want none", p)
}
}
func TestValidateCatchesMissingVolumes(t *testing.T) {
m := &Manifest{Volumes: []Volume{
{ID: "vol1", Title: "网络与并发内核", Prefix: "mmo-net-", Artifacts: []string{"帧编解码"}},
}}
p := m.Validate()
// vol2, vol3 and vol4 are absent: exactly three problems.
if len(p) != 3 {
t.Fatalf("Validate() = %v, want 3 missing-volume problems", p)
}
}
func TestValidateCatchesWrongPrefix(t *testing.T) {
m := &Manifest{Volumes: []Volume{
{ID: "vol4", Title: "工程化与运营", Prefix: "mmo-ops-", Artifacts: []string{"机器人压测"}},
}}
p := m.Validate()
found := false
for _, s := range p {
if strings.Contains(s, "prefix") {
found = true
}
}
if !found {
t.Fatalf("Validate() = %v, want a prefix problem", p)
}
}
func TestValidateCatchesEmptyArtifacts(t *testing.T) {
m := &Manifest{Volumes: []Volume{
{ID: "vol1", Title: "网络与并发内核", Prefix: "mmo-net-", Artifacts: []string{"帧编解码"}},
{ID: "vol2", Title: "单服架构与玩法系统", Prefix: "mmo-srv-"},
{ID: "vol3", Title: "分布式与跨服", Prefix: "mmo-dist-", Artifacts: []string{"服务发现"}},
{ID: "vol4", Title: "工程化与运营", Prefix: "mmo-eng-", Artifacts: []string{"机器人压测"}},
}}
p := m.Validate()
found := false
for _, s := range p {
if strings.Contains(s, "no artifacts") {
found = true
}
}
if !found {
t.Fatalf("Validate() = %v, want an empty-artifacts problem", p)
}
}
// TestBookManifestIsComplete loads the real manifest that ships next to this
// package and asserts the whole four-volume book is accounted for.
func TestBookManifestIsComplete(t *testing.T) {
m, err := LoadManifest("manifest.json")
if err != nil {
t.Fatal(err)
}
if p := m.Validate(); len(p) != 0 {
t.Fatalf("book manifest has problems: %v", p)
}
if len(m.Volumes) != 4 {
t.Fatalf("manifest has %d volumes, want 4", len(m.Volumes))
}
total := 0
for _, v := range m.Volumes {
total += len(v.Artifacts)
}
t.Logf("四卷产物合计 %d 项", total)
}
本机实跑(go test ./internal/matrix/ -run TestBookManifestIsComplete -v):
=== RUN TestBookManifestIsComplete
manifest_test.go:86: 四卷产物合计 29 项
--- PASS: TestBookManifestIsComplete (0.00s)
PASS
ok aegis/internal/matrix 0.265s
校验器抓的是最容易漏的一件事:「某卷少交了几篇」不会被构建拦住,只会被清单校验拦住。 就像前面章节里「漏写 _index.md 不会报错、section 页静默不生成」一样,全书收官最怕的不是写错,而是以为写完了。
本节小结 / 全书收官
- 三个框架各有假设的规模:skynet 赌 actor 是万能抽象、pitaya 赌分布式骨架开箱即用、leaf 赌单进程够用。
aegis与它们的差异在三条线——语言边界(Go 单语言 vs Lua/C)、分布式边界(自己画 vs 开箱即用)、抽象克制(成本可见 vs 强抽象)。 - 四卷是一条线:卷一网络内核 → 卷二单服玩法 → 卷三分布式跨服 → 卷四工程化运营,每一卷的结论都是下一卷的前提。
- 全书 29 项可运行产物,代表数字跨四卷可对照(goroutine 约 2053 B、1000 同屏 4.76 ms/tick、跨服往返 P50 1.513 ms、端到端 QPS 天花板约 3000–3200),全部同机相对量级,绝对数值不可移植。
- 产物清单校验器是 L1,
go test ./internal/matrix/全绿,实测「四卷产物合计 29 项」。 - 下一步四条路:补观测与容灾、换工业级组件、读框架源码、上真实环境。本书的目标不是给你一个能上线的服务端,而是让你有能力判断「哪一层可以买、哪一层必须自己写」。
《MMO 服务器开发》四卷到此收官。从一条 11 字节的帧头,到一个能压测、能观测、能发布、能容灾、能运营的完整服务端——这条路走完了。
阅读导航:上一节:10.1 跨栈对照矩阵:Go vs Java 最佳实践与选型标准 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。