1.3 本书范围与站内服务治理文章的分工
读完前两节,读者大概率会有一个疑问:站内已经有一大批游戏服务器架构文章了,卷一又写完了网络内核、卷二写完了单服玩法,这一卷再写一遍,是不是重复? 这个疑问值得正面回答,因为本站的游戏服务器与分布式内容确实不算少——一个实战专题、若干服务治理架构文,再加上刚刚上线的卷一《网络与并发内核》和卷二《单服架构与玩法系统》。如果不把边界说清楚,读者会在「架构方案」和「从零实现」之间反复横跳,作者也会不自觉地把别人写透的内容再抄一遍。
这一节的立场很朴素:别人讲过的「是什么、为什么、怎么选」,我们全部外链出去;本卷只补别人没补的那一块——「把集群与跨服从零写出来,并且跑出数字」。 这不是自夸。恰恰相反,站内既有内容在很多方面比本卷更完整,它们把服务治理的取舍讲得更透。本卷的价值不在于「更多」,而在于「另一种」:它提供的不是又一份架构综述,而是一份能编译、能跑、能被断言检查的代码。
需要先厘清的是,这一节不是分工表。本卷从第 2 章起,每一章的第 1 节都会有一张表,说明「本章对应的站内既有文章是哪几篇、各自负责什么」。而这一节是总纲:它先讲清三份内容各自的位置,再给出一条可以被测试检查的边界,并明确列出本卷不写的内容。
三份内容,三种分工
把与本卷相关的三份内容摊开,它们的分工是这样的:
| 内容 | 位置 | 负责回答 | 形态 |
|---|---|---|---|
| 站内架构文(game/server 与 practice) | /posts/game/server/
与 /posts/game/server/practice/ | 应该怎么设计、为什么这么选 | 纯架构设计文,零可运行代码 |
| 卷一 / 卷二 | /posts/books/ | 网络底座与单服玩法从零怎么写 | 从零实现 + 真跑 + 压测数字 |
| 本卷《分布式与跨服》 | /posts/books/ | 集群与跨服从零怎么写 | 从零实现 + 真跑 + 端到端验收 |
这张表有三个关键点。
第一,站内架构文和本卷的形态完全不同。它们回答「应该怎么设计」——服务怎么发现、健康检查怎么设计、网关怎么排空连接、跨服怎么结算——但不会给你一份能 go test 通过的实现。这不是缺陷,是定位:它们的读者是来「看方案、做评审」的。本卷的读者是来「自己写出来、跑通、量出来」的。同一件事(比如服务发现),架构文给你一张取舍清单,本卷给你一份连真中间件、能注册能心跳的代码。
第二,卷一、卷二与本卷的边界是「底座 / 单服」与「集群」。卷一已经把 wire 格式、编解码、KCP、时间轮、Actor、会话路由写完了;卷二把背包、任务、公会、交易、匹配、战斗管线装上了。本卷不重写底座,也不重写单服玩法,而是直接站在它们之上,回答「当玩家和状态分布在多台机器上时,怎么发现彼此、怎么调用、怎么路由、怎么保证数据不丢不乱」。卷一回答「包怎么流动」,卷二回答「单机玩法怎么落地」,本卷回答「状态怎么跨机器分布」。
第三,三份内容共用同一套约定。本卷的 wire 格式、命令号(含新增的跨服域 0x0D–0x10)、EntityID / PlayerID / NodeID 等类型,全部沿用卷一、卷二定稿的口径——所以本卷每一章都能接到前两卷造出的网络层与玩法层上,而不是另起炉灶。
一句话总结这条分工:「为什么、怎么选」看站内架构文,「底座与单服怎么写」看卷一与卷二,「集群与跨服怎么写、跑出什么数字」看本卷。
两个具体例子:服务发现与集群拓扑
抽象的分工不如具体例子清楚。本卷要讲的两块——服务发现与集群拓扑——站内恰好各有一篇对应的架构文章,正好用来说明边界。
服务发现。站内有《游戏服务器服务发现与健康检查架构设计》 与《游戏服务器服务治理架构设计》 ,它们讲的是「服务怎么注册、健康检查该探哪些维度、发现与治理该怎么分层」,重点在架构取舍。本卷第 2 章不重复这些取舍,它只做一件事:把注册与健康检查连到本机真跑的 etcd / NATS / Consul 上,并对比三者在本机的行为——节点怎么注册、心跳超时怎么判死、路由表怎么同步、Leader 怎么选出来。架构文告诉你「应该这么设计」,本卷告诉你「这么设计的代码长什么样,三种中间件在本机跑出来的差别是什么」。
集群拓扑。站内有《游戏服务器集群拓扑设计》 ,它讲的是「一个集群该切成几类节点、拓扑怎么随规模演进、每种形态的取舍是什么」,重点在方案设计。本卷 1.1 同样不重复这些讨论,它把「五类节点各持有什么状态、三种形态要不要路由层」编码成两张常量表和一组件是断言——网关可复制、场景单写者、只有全球同服才需要运行时路由层,这些判断谁也不能口头改。方案文告诉你「应该怎么切」,本卷告诉你「怎么切才不会在下一次重构里被改坏」。
这两组例子的模式是一样的:架构文负责「应该怎么设计」,本卷负责「怎么写出来、怎么验证」。 两者不冲突,也不能互相替代——读得懂一篇方案文,和亲手把它实现出来并跑通,中间隔着一整个调试周期,本卷补的就是这一段。
明确不写的内容
边界讲清楚,还要把「不写什么」也钉死,否则读者会一直等一个不会来的章节。下面这张表是本卷的排除清单:
| 内容 | 为什么不在本卷 | 去哪里看 |
|---|---|---|
| 引擎与渲染、客户端表现 | 与服务器无关 | 站内引擎与客户端书稿 |
| 美术与内容管线 | 属于内容生产流程 | 内容管线相关文章 |
| 商业化运营 | 属于运营阶段,非技术架构 | 运营相关文章 |
| 真实云厂商控制台操作 | 需真实云账号,本机不可复现 | 仅作原理提及 |
| 真实公网 DDoS | 需真实公网环境,本机不可复现 | 仅作原理提及 |
| 机器学习反外挂 | 属于风控与算法专题 | 风控相关文章 |
这张表也是给读者的预期管理:本卷只覆盖「集群与跨服」这一格,其余各格各有归属,不在本卷重复。特别要强调的是,像「真实公网 DDoS」与「云厂商控制台操作」这类需要真实环境的内容,本卷最多讲原理,绝不会假装能在本机复现——凡标注为 L4 的代码块,正文都会显式写明「本节需真实环境,命令行不可复现」。
可运行产物:把分工编码成一张查询表
上面这张分工表是给人看的。但「不越界」这件事如果只靠自觉,迟早会漂——所以本卷把这条边界编码成一个可查询的函数:给它一个话题,它告诉你这个话题归谁(站内架构文、卷一/卷二,还是本卷,还是压根不在本系列)。这样「越界」就变成一条可以被测试断言的失败用例。
下面这段是 L1,go test 可以直接跑。先看 internal/scope/scope.go:
// file: internal/scope/scope.go
// tier: L1
// verify: go test ./internal/scope/
// Package scope encodes the division of labour behind volume three. Section 1.3
// explains it in prose: the site's existing architecture articles answer "what
// should the design be", volumes one and two already shipped the network kernel
// and the single-server gameplay systems, and this volume writes the cluster
// layer from scratch. This file makes that split queryable, so "who owns this
// topic" is an assertion a test can fail on rather than a rule nobody re-reads.
//
// It also pins the exclusion list. A topic like rendering or anti-cheat models
// is not "someone else's chapter": it is out of this series entirely, and the
// table says so rather than leaving the reader waiting for a chapter that will
// never come.
package scope
import "strings"
// Owner names which body of work owns a topic.
type Owner string
const (
// OwnerPractice is the site's existing game-server architecture articles:
// they answer "what should the design be, and why", with no runnable code.
OwnerPractice Owner = "practice"
// OwnerVol12 is volumes one and two already shipped: the network kernel and
// the single-server gameplay systems.
OwnerVol12 Owner = "vol1-vol2"
// OwnerDist is this volume: the cluster and cross-server layer, built from
// scratch with numbers.
OwnerDist Owner = "vol3-dist"
// OwnerExcluded is out of the series entirely: no volume here writes it.
OwnerExcluded Owner = "excluded"
)
// Rule maps a topic keyword to its owner. First match wins, so order is
// priority: the excluded and already-shipped rules come before this volume's,
// so a broad keyword cannot silently steal a topic that belongs elsewhere.
type Rule struct {
Keyword string
Owner Owner
}
// rules is the 1.3 split, in code.
var rules = []Rule{
// Out of the series: no volume in this series writes these.
{"引擎", OwnerExcluded},
{"渲染", OwnerExcluded},
{"客户端表现", OwnerExcluded},
{"美术", OwnerExcluded},
{"商业化", OwnerExcluded},
{"云厂商控制台", OwnerExcluded},
{"公网 DDoS", OwnerExcluded},
{"机器学习反外挂", OwnerExcluded},
// Architecture-only topics stay with the existing articles.
{"分层方案", OwnerPractice},
{"状态归属方案", OwnerPractice},
{"选型对比", OwnerPractice},
{"架构取舍", OwnerPractice},
{"部署拓扑", OwnerPractice},
// Wire and single-server gameplay belong to the shipped volumes.
{"编解码", OwnerVol12},
{"KCP", OwnerVol12},
{"帧切分", OwnerVol12},
{"时间轮", OwnerVol12},
{"背包", OwnerVol12},
{"公会仓库", OwnerVol12},
// The cluster layer is this volume's job.
{"服务发现", OwnerDist},
{"健康检查", OwnerDist},
{"RPC", OwnerDist},
{"超时预算", OwnerDist},
{"熔断", OwnerDist},
{"网关集群", OwnerDist},
{"会话粘性", OwnerDist},
{"跨服", OwnerDist},
{"分库分表", OwnerDist},
{"雪花算法", OwnerDist},
{"缓存一致", OwnerDist},
{"分布式事务", OwnerDist},
{"对账", OwnerDist},
{"事件溯源", OwnerDist},
}
// OwnerOf returns the owner of a topic. A topic that matches no rule falls back
// to this volume: its job is exactly "the cluster layer nobody else wrote from
// scratch", so the default must point here, never away.
func OwnerOf(topic string) Owner {
for _, r := range rules {
if strings.Contains(topic, r.Keyword) {
return r.Owner
}
}
return OwnerDist
}
// Excluded lists, in reader-facing words, what this series does not cover. It
// is the exclusion table of section 1.3, kept next to the router so the two can
// never drift apart.
func Excluded() []string {
return []string{
"引擎与渲染",
"客户端表现",
"美术与内容管线",
"商业化运营",
"真实云厂商控制台操作",
"真实公网 DDoS",
"机器学习反外挂",
}
}
// DeferredPaths returns the site paths this volume defers to for a given owner.
// Only section paths under /posts/ are ever returned: a bare single-segment
// path is a dead link once articles are flattened to /<slug>/.
func DeferredPaths(o Owner) []string {
switch o {
case OwnerPractice:
return []string{"/posts/game/server/", "/posts/game/server/practice/"}
case OwnerVol12:
return []string{"/posts/books/网络与并发内核/", "/posts/books/单服架构与玩法系统/"}
default:
// This volume and the excluded list defer to nothing.
return nil
}
}
配套测试 internal/scope/scope_test.go 断言三件事:话题按表路由、被排除的话题不会漏进本卷(这是最容易漂的一条)、以及所有外链目标都是 /posts/ 下的合法栏目路径:
// file: internal/scope/scope_test.go
// tier: L1
// verify: go test ./internal/scope/
package scope
import (
"strings"
"testing"
)
func TestOwnerOfRoutesToPractice(t *testing.T) {
for _, topic := range []string{"分层方案怎么写", "状态归属方案", "服务选型对比", "部署拓扑取舍"} {
if got := OwnerOf(topic); got != OwnerPractice {
t.Errorf("OwnerOf(%q) = %s, want practice", topic, got)
}
}
}
func TestOwnerOfRoutesToShippedVolumes(t *testing.T) {
for _, topic := range []string{"KCP 重传", "帧切分", "时间轮级联", "公会仓库并发"} {
if got := OwnerOf(topic); got != OwnerVol12 {
t.Errorf("OwnerOf(%q) = %s, want vol1-vol2", topic, got)
}
}
}
func TestOwnerOfRoutesToThisVolume(t *testing.T) {
for _, topic := range []string{"服务发现与健康检查", "RPC 超时预算", "网关集群会话粘性", "跨服匹配", "雪花算法时钟回拨"} {
if got := OwnerOf(topic); got != OwnerDist {
t.Errorf("OwnerOf(%q) = %s, want vol3-dist", topic, got)
}
}
}
func TestExcludedTopicsDoNotFallThroughToThisVolume(t *testing.T) {
// The trap this guards: a topic like "渲染" must NOT be silently claimed by
// this volume just because it matched no cluster keyword. It is out of the
// series, and the router must say so.
for _, topic := range []string{"引擎与渲染管线", "客户端表现优化", "美术资源管线", "商业化付费设计", "真实公网 DDoS 演练", "机器学习反外挂模型"} {
if got := OwnerOf(topic); got != OwnerExcluded {
t.Errorf("OwnerOf(%q) = %s, want excluded", topic, got)
}
}
}
func TestFallbackIsThisVolume(t *testing.T) {
// A topic nobody else claimed is exactly what this volume is for.
if got := OwnerOf("一个没人写过的话题 xyz"); got != OwnerDist {
t.Fatalf("fallback = %s, want vol3-dist", got)
}
}
func TestExcludedListCoversTheSevenTopics(t *testing.T) {
want := []string{
"引擎与渲染", "客户端表现", "美术与内容管线", "商业化运营",
"真实云厂商控制台操作", "真实公网 DDoS", "机器学习反外挂",
}
got := Excluded()
if len(got) != len(want) {
t.Fatalf("Excluded() has %d entries, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("Excluded()[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestDeferredPathsAreSectionPaths(t *testing.T) {
for _, o := range []Owner{OwnerPractice, OwnerVol12} {
paths := DeferredPaths(o)
if len(paths) == 0 {
t.Fatalf("DeferredPaths(%s) is empty", o)
}
for _, p := range paths {
if !strings.HasPrefix(p, "/posts/") || !strings.HasSuffix(p, "/") {
t.Errorf("path %q must be a section path under /posts/ ending in /", p)
}
}
}
// This volume and the exclusion list defer to nothing.
for _, o := range []Owner{OwnerDist, OwnerExcluded} {
if got := DeferredPaths(o); got != nil {
t.Fatalf("DeferredPaths(%s) = %v, want nil", o, got)
}
}
}
本机实跑结果(go test ./internal/scope/ -v):
=== RUN TestOwnerOfRoutesToPractice
--- PASS: TestOwnerOfRoutesToPractice (0.00s)
=== RUN TestOwnerOfRoutesToShippedVolumes
--- PASS: TestOwnerOfRoutesToShippedVolumes (0.00s)
=== RUN TestOwnerOfRoutesToThisVolume
--- PASS: TestOwnerOfRoutesToThisVolume (0.00s)
=== RUN TestExcludedTopicsDoNotFallThroughToThisVolume
--- PASS: TestExcludedTopicsDoNotFallThroughToThisVolume (0.00s)
=== RUN TestFallbackIsThisVolume
--- PASS: TestFallbackIsThisVolume (0.00s)
=== RUN TestExcludedListCoversTheSevenTopics
--- PASS: TestExcludedListCoversTheSevenTopics (0.00s)
=== RUN TestDeferredPathsAreSectionPaths
--- PASS: TestDeferredPathsAreSectionPaths (0.00s)
PASS
ok aegis/internal/scope 0.857s
两条断言值得单独说。TestExcludedTopicsDoNotFallThroughToThisVolume 守的是最容易漂的一条:OwnerOf 的默认分支把「没人认领的话题」判给本卷,这本来是对的——本卷的职责就是「别人没写、要从零实现的集群层」。但正因为它兜底,一个像「渲染」这样的话题如果没被显式排除,就会被悄悄判给本卷,读者便一直等一个不会来的章节。所以排除规则必须排在兜底之前,测试就是守这个次序的。TestDeferredPathsAreSectionPaths 守的则是链接口径:外链一律指向 /posts/ 下的栏目页,因为普通文章被 permalink 压到了根级 /<slug>/,写单段 /<topic>/ 会变成死链。
本节小结
这一节把三份内容的分工讲清楚了:
- **站内架构文(game/server 与 practice)**回答「应该怎么设计、为什么这么选」,形态是纯方案文,没有可运行代码。
- **卷一《网络与并发内核》与卷二《单服架构与玩法系统》**回答「底座与单服玩法从零怎么写」,已经造出了
aegis的 wire 格式、传输会话模型与单机玩法系统。 - 本卷《分布式与跨服》回答「集群与跨服从零怎么写、跑出什么数字」,直接站在前两卷之上写服务发现、RPC、网关集群、实例调度、跨服结算、分布式 ID、缓存、分库分表与分布式事务,并把边界编码成一张可查询、可测试的表——包括明确不写的引擎渲染、客户端表现、美术管线、商业化运营、真实云厂商控制台、真实公网 DDoS 与机器学习反外挂。
第 1 章到此结束。从下一章开始,我们进入服务发现与治理——先回答「节点怎么注册、心跳超时怎么判死」,再回答「谁该拥有哪个服务、Leader 怎么选出来」。
阅读导航:上一节:1.2 什么时候该拆、什么时候不该 · 下一节:2.1 注册与健康检查 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。