《游戏服务端架构·品类、状态与拓扑》13.1 三款产品的架构基线

把卷一前十二章的决策固化成三张可评审的架构基线卡:每张写明拓扑、状态归属、连接模型、权威模型、部署单元与关键 SLA,并列出已接受的取舍、已知风险和复审触发条件。配套 Go 包用字段完整性与跨产品一致性校验,防止基线在复制粘贴和人员轮换中悄悄走样。

基线不是一张架构图

2026 年 9 月的一次季度评审,三份文档同时写《裂隙战场》的帧预算:产品文档写 33ms,运维看板写 38ms,一份旧 ADR 写 50ms。三个数都不算错:33ms 是帧预算,38ms 是 1.2 节 那次 500 房间压测的均值,50ms 是早期 20Hz 方案的历史值。问题在于没人知道哪个数代表“我们现在接受的答案”。

基线卡解决的就是这个问题。它不是架构图,也不是目标架构,而是一句话能说清、又能被程序检查的当前承诺。架构图回答“系统长什么样”,基线回答“我们决定了什么、放弃了什么、什么时候必须重来”。

“基线”的四个限定

第一,基线是当前接受了的取舍,不是最优解。《泡泡工坊》选择模块化单体,不是因为它比大厅加房间更先进,而是因为三到四个后端维护多个服务的成本更高。基线必须把这句话写进去,否则下一位架构师会把“暂时不拆”误读成“不需要拆”。

第二,基线必须指名状态归属。同一份玩家数据在几个地方有副本、谁是权威、跨副本怎么同步,这些问题不写清楚,拓扑图就只是方框和箭头。第 4.1 节 已经把状态分成内存态、持久态和快照,基线卡沿用这套口径。

第三,基线必须带风险和复审触发条件。第 2.3 节 的风险台账给概率和影响打分,基线卡只保留最关键的几条,并给出可观测的触发信号。没有触发信号的基线,会慢慢变成信仰。

第四,基线是有版本的。拓扑随产品阶段变化,基线也要随 ADR 变化。改基线不是改一行文档,而是走一次决策记录:写清触发事件、备选方案、代价和新的退出条件。

一张基线卡固定六个字段:

字段要回答的问题不合格的表现
拓扑状态切在哪里、按什么单位部署只写“微服务”或“全球同服”
状态归属每类状态的权威副本在哪同一状态有两个写入者
连接模型客户端用什么协议、断线怎么恢复只写 WebSocket,不写重连语义
权威模型客户端能写什么、服务端裁决什么用“服务器权威”一句带过
部署单元哪些进程独立发布、故障域多大部署单元和代码模块混为一谈
关键 SLA哪个分位数、哪个预算把均值当预算,或只写“低延迟”

《裂隙战场》基线卡

《裂隙战场》是 5v5 竞技,20 万在线,单局 25 分钟,房间服按 30Hz 推进。第 3.2 节 把大厅、房间和分配器拆成三层,这里把它固化成基线。

字段当前基线
拓扑大厅 + 房间,按赛区部署
状态归属对局状态在 room 内存;会话与房间绑定在 allocator;账号、支付、结算在平台
连接模型QUIC 流承载关键帧,数据报承载可丢状态;断线按对局 ID 重连
权威模型预测-回滚-重放;服务端唯一写入生命、伤害、命中与比赛结果
部署单元lobby(无状态)、allocator(租约存储)、room(有状态、按赛区)
关键 SLA帧预算 p99 33ms;操作反馈 p99 150ms;暖池开房 p99 3s(示例值)
已接受取舍房间崩溃只影响一局;赛区隔离优先于全球匹配速度
已知风险R-01 跨赛区匹配 p99 超 800ms;R-02 幽灵房间数高于基线
复审触发单赛区房间数超过调度器上限;跨赛区匹配成为主玩法;tick p99 连续一周超过 33ms

基线的关键不是 33ms 这个数字,而是它后面的口径:这是帧预算的 p99,不是压测均值。1.2 节那次 500 房间、每房间 10 人的本机压测,平均 tick 是 38ms;平均超过预算,恰恰说明“平均值不能当 SLA”。基线卡把 p99 和均值分开写,评审时才不会各说各话。

《群岛纪元》基线卡

《群岛纪元》是 SLG,8 万在线,玩家共享一张 4096×4096 的大地图。第 8.1 节 的结论是“逻辑一张图、物理多套数据”,基线卡必须把这个承诺拆成可检查的状态归属。

字段当前基线
拓扑全球同服 + 地域分片;按矩形范围切地图
状态归属地图格与舰队在 world-shard;主城资源在 economy;联盟外交在 alliance;全球事件只存事件;账号支付在赛区
连接模型WebSocket 长连接,CDN 前置静态与事件;跨区只同步脱敏事件
权威模型纯状态同步;服务端裁决移动、战斗与归属,客户端不做权威预测
部署单元gateway、world-shard(有状态)、economy、alliance、global-event
关键 SLA同分片操作 p99 20ms;同赛区跨分片 p99 60ms;跨赛区异步事件 p99 300ms(示例值)
已接受取舍跨区不同步个人状态;热区分片按矩形拆分,不搬玩家
已知风险R-01 跨地域匹配 p99 超 800ms;R-02 同一 battle_id 重复发奖;R-03 王战写入放大 3 倍
复审触发跨地域延迟导致战争结果不可接受;合规要求数据本地化;单分片峰值 tick 成本超预算

这张卡里最容易漏的是最后一行“账号支付在赛区”。地图可以按矩形重新切,联盟可以跨区,但账号和支付数据不能因为合服离开欧盟。基线把“不能动的东西”和“可以重新切的东西”分开,迁移方案才有边界。

《泡泡工坊》基线卡

《泡泡工坊》是休闲小游戏,3 万在线,单局 8 分钟,团队只有几名后端。第 9.3 节 给的最小组合是单服务、单主库、缓存和对象存储,基线卡把它固化为“模块化单体起步”。

字段当前基线
拓扑模块化单体;房间是内部模块,不是独立服务
状态归属API 无状态;账号、背包、活动进度在 Postgres;会话与排行榜在 Redis;分享图与回放在对象存储
连接模型托管 WebSocket 或长轮询;断线重连后重新拉取房间快照
权威模型服务端权威;客户端只做表现层预测,不写权威状态
部署单元api(无状态、至少 2 副本)、postgres(有状态)、redis、object-store
关键 SLA登录 p99 200ms;房间操作 p99 200ms(示例值,沿用 5.1 的 100–200ms 预算)
已接受取舍单服务换上线速度;房间不独立部署;成本上限优先于功能完整
已知风险R-01 对象存储出流量打穿日预算;R-02 配置写死导致故障时无法回滚
复审触发每周因发布冲突回滚两次以上;单接口 CPU 占用超过一半;房间模块需要独立扩容

注意“房间是内部模块”这一行。它和第 1.3 节的选择一致:匹配制只说明需要房间,不说明房间必须独立部署。基线把“现在不拆”写成明确决定,同时给出拆分触发条件,避免团队在两种误读之间摇摆:一种是以为永远不用拆,另一种是过早拆成多个服务。

三张卡放在一起

维度《裂隙战场》《群岛纪元》《泡泡工坊》
拓扑大厅 + 房间全球同服 + 地域分片模块化单体
状态权威房间内存地图分片Postgres
连接QUIC 流 + 数据报WebSocket + CDN托管 WebSocket / 长轮询
权威模型预测-回滚状态同步服务端权威
故障域单房间单矩形分片单服务
首要 SLA33ms tick20ms 同片操作200ms 操作
复审触发调度器上限合规与跨区延迟发布冲突与扩容

三张卡共用 northstar-platform 的账号、支付、匹配、社交、排行榜、邮件、配置、数据和 GM 能力,但共享平台不等于共享拓扑。竞技产品共享的是账号与结算边界,SLG 共享的是事件与索引,休闲产品共享的是登录和成本闸门。把平台服务当成拓扑模板,是三款产品第一年失败的根源。

把基线写成可校验的结构

基线卡最怕两件事:字段缺一块,或者三张卡互相矛盾。字段缺失通常在评审时还能发现,跨产品矛盾却会藏很久,例如《裂隙战场》的 room 被误标成无状态,或者《泡泡工坊》复制模板时多出一个 room 部署单元。

internal/baseline 把这两类问题写成代码。model.go 定义产品、拓扑、状态格、部署单元、SLA 和风险;fixtures.go 放三张基线卡的数据;validate.go 只做两件事:单卡字段完整性,以及跨产品契约。下面是校验核心。

// file: internal/baseline/validate.go
// tier: L1
// verify: go test ./internal/baseline/
package baseline

import (
	"errors"
	"fmt"
	"sort"
)

var (
	ErrMissingField      = errors.New("baseline: required field is empty")
	ErrUnknownProduct    = errors.New("baseline: unknown product")
	ErrDuplicateProduct  = errors.New("baseline: product declared twice")
	ErrDuplicateUnit     = errors.New("baseline: duplicate deploy unit")
	ErrBadState          = errors.New("baseline: state cell needs name, owner and a known persistence")
	ErrBadSLA            = errors.New("baseline: sla needs a name and positive p99 budget")
	ErrBadRisk           = errors.New("baseline: risk needs id, 1..5 severity and trigger")
	ErrTopologyMismatch  = errors.New("baseline: topology differs from the reviewed baseline")
	ErrAuthorityMismatch = errors.New("baseline: authority differs from the reviewed baseline")
	ErrPlatformMismatch  = errors.New("baseline: products disagree on the platform")
	ErrUnitContract      = errors.New("baseline: deploy units violate the product contract")
)

var canonical = map[Product]struct {
	Topology  Topology
	Authority Authority
	Units     []string
}{
	RiftBattle:     {LobbyRoom, PredictionRollback, []string{"lobby", "allocator", "room"}},
	Archipelago:    {GlobalShard, ServerStateSync, []string{"gateway", "world-shard", "economy", "alliance", "global-event"}},
	BubbleWorkshop: {ModularMonolith, ServerAuthoritative, []string{"api"}},
}

func (b Baseline) unit(name string) (DeployUnit, bool) {
	for _, u := range b.Units {
		if u.Name == name {
			return u, true
		}
	}
	return DeployUnit{}, false
}

func (b Baseline) validate() []Issue {
	var out []Issue
	add := func(field, msg string) { out = append(out, Issue{b.Product, field, msg}) }
	if b.Product == "" {
		add("product", ErrMissingField.Error())
	}
	if b.Topology == "" {
		add("topology", ErrMissingField.Error())
	}
	if b.Connection == "" {
		add("connection", ErrMissingField.Error())
	}
	if b.Authority == "" {
		add("authority", ErrMissingField.Error())
	}
	if b.Platform == "" {
		add("platform", ErrMissingField.Error())
	}
	if len(b.State) == 0 {
		add("state", ErrMissingField.Error())
	}
	for i, s := range b.State {
		if s.Name == "" || s.Owner == "" ||
			(s.Persistence != "memory" && s.Persistence != "durable" && s.Persistence != "snapshot") {
			add(fmt.Sprintf("state[%d]", i), ErrBadState.Error())
		}
	}
	if len(b.Units) == 0 {
		add("units", ErrMissingField.Error())
	}
	seen := map[string]bool{}
	for i, u := range b.Units {
		if u.Name == "" || u.FailureScope == "" {
			add(fmt.Sprintf("units[%d]", i), ErrMissingField.Error())
		}
		if seen[u.Name] {
			add(fmt.Sprintf("units[%d]", i), ErrDuplicateUnit.Error())
		}
		seen[u.Name] = true
	}
	if len(b.SLAs) == 0 {
		add("slas", ErrMissingField.Error())
	}
	for i, s := range b.SLAs {
		if s.Name == "" || s.P99MS <= 0 {
			add(fmt.Sprintf("slas[%d]", i), ErrBadSLA.Error())
		}
	}
	if len(b.Risks) == 0 {
		add("risks", ErrMissingField.Error())
	}
	for i, r := range b.Risks {
		if r.ID == "" || r.Severity < 1 || r.Severity > 5 || r.Trigger == "" {
			add(fmt.Sprintf("risks[%d]", i), ErrBadRisk.Error())
		}
	}
	if len(b.Accepted) == 0 {
		add("accepted", ErrMissingField.Error())
	}
	if len(b.ReviewTriggers) == 0 {
		add("review_triggers", ErrMissingField.Error())
	}
	return out
}

// ValidateSet checks each baseline for completeness and then checks the set
// across products: one card per product, same platform, canonical topology and
// authority, and the deploy units each product promised in volume one.
func ValidateSet(bs []Baseline) Report {
	var issues []Issue
	seen := make(map[Product]bool, len(canonical))
	platform := ""
	for _, b := range bs {
		want, known := canonical[b.Product]
		if !known {
			issues = append(issues, Issue{b.Product, "product", ErrUnknownProduct.Error()})
			continue
		}
		if seen[b.Product] {
			issues = append(issues, Issue{b.Product, "product", ErrDuplicateProduct.Error()})
		}
		seen[b.Product] = true
		issues = append(issues, b.validate()...)
		if b.Topology != want.Topology {
			issues = append(issues, Issue{b.Product, "topology",
				fmt.Sprintf("%v: want %s got %s", ErrTopologyMismatch, want.Topology, b.Topology)})
		}
		if b.Authority != want.Authority {
			issues = append(issues, Issue{b.Product, "authority",
				fmt.Sprintf("%v: want %s got %s", ErrAuthorityMismatch, want.Authority, b.Authority)})
		}
		if platform == "" {
			platform = b.Platform
		} else if b.Platform != platform {
			issues = append(issues, Issue{b.Product, "platform", ErrPlatformMismatch.Error()})
		}
		for _, name := range want.Units {
			if _, ok := b.unit(name); !ok {
				issues = append(issues, Issue{b.Product, "units",
					fmt.Sprintf("%v: missing %s", ErrUnitContract, name)})
			}
		}
		if b.Product == RiftBattle {
			if u, ok := b.unit("room"); ok && !u.Stateful {
				issues = append(issues, Issue{b.Product, "units", "room unit must be stateful"})
			}
		}
		if b.Product == BubbleWorkshop {
			if _, ok := b.unit("room"); ok {
				issues = append(issues, Issue{b.Product, "units", "rooms stay inside the monolith at this baseline"})
			}
		}
	}
	for p := range canonical {
		if !seen[p] {
			issues = append(issues, Issue{p, "product", "baseline missing from the set"})
		}
	}
	sort.SliceStable(issues, func(i, j int) bool {
		if issues[i].Product != issues[j].Product {
			return issues[i].Product < issues[j].Product
		}
		if issues[i].Field != issues[j].Field {
			return issues[i].Field < issues[j].Field
		}
		return issues[i].Message < issues[j].Message
	})
	return Report{OK: len(issues) == 0, Issues: issues}
}

几个设计点值得说明:

  • 契约表 canonical 是单一事实来源。 拓扑、权威模型和必需部署单元都从这张表读,测试和线上检查共用一套定义。
  • 单卡校验和集合校验分开。 validate 只回答“这张卡自身是否完整”,ValidateSet 才回答“三张卡放在一起是否自洽”。分开之后,新增产品不会意外放宽已有产品的约束。
  • 契约检查是精确的,不是模糊的。 《裂隙战场》必须有 room 且 Stateful 为真;《泡泡工坊》不允许出现 room 部署单元。前者防止把有状态服务写成无状态,后者防止单体基线在复制中长出一个不该有的服务。
  • 错误带产品和字段。 Issue{Product, Field, Message} 让 CI 输出可以直接定位到哪张卡的哪个字段,而不是只给一句“校验失败”。
  • 排序保证输出稳定。 同一组输入每次得到相同顺序的报告,评审记录和基线对比才不会因为 map 遍历顺序而变化。

25 个运行项守住什么

internal/baseline 有 5 个测试函数、25 个运行项(含子测试),覆盖正常集合、字段缺失、跨产品违约和未知产品。

测试覆盖的失败模式
TestNorthstarBaselinesPass三张基线卡作为一组通过校验
TestFieldCompleteness连接缺失、持久化非法、部署单元重名、SLA 为零、风险越界、取舍为空、触发条件为空
TestCrossProductContract拓扑错、权威错、平台分裂、部署单元缺失、泡泡多出 room、裂隙 room 无状态、产品重复、产品缺失
TestUnknownProductRejected空产品名与未立项产品
TestCanonicalCoversEveryProduct契约表覆盖三款产品且每项完整

本机实际运行结果:

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/baseline
$ go vet ./internal/baseline/
$ go test -count=1 ./internal/baseline/
ok  	gsarchlab/internal/baseline	0.239s

gofmt -l 与 go vet 均无输出,退出码为 0。耗时随机器变化,测试数量与通过状态不随耗时变化。

基线什么时候失效

基线不是永久真理,它有三个失效入口。

第一,复审触发条件命中。《泡泡工坊》的“房间模块需要独立扩容”一旦出现,模块化单体就不再是当前最优解,团队要写新的 ADR,而不是偷偷在单体里加一个“临时”房间服务。

第二,产品事实变化。并发、会话时长、实时性、共享世界、匹配制、地域敏感这六个事实中任何一个跨过阈值,基线的输入就变了。例如《裂隙战场》如果跨赛区匹配成为主玩法,赛区隔离前提就不再成立,房间分配和匹配延迟预算都要重算。

第三,平台能力变化。northstar-platform 如果提供了托管房间或全球事件总线,原来“自建分配器”的代价可能下降,基线要重新比较。平台能力升级不是自动采纳,仍然要走一次取舍。

基线更新时保留旧版本,不要覆盖。下一次评审要能回答“上一个版本为什么被替换”,而不是只看到一张新表。

常见错误与边界

第一,把基线写成目标架构。目标架构描述“想去哪”,基线描述“现在接受什么”,两者混在一起,团队会误以为目标已经达成。

第二,只写拓扑,不写状态归属。拓扑图上的箭头不说明谁是权威,迁移和故障恢复时仍然要重新吵架。

第三,三张卡的数字口径不同。预算、均值、峰值必须分开写;把均值当 SLA,会在压测时得出“已经达标”的错误结论。

第四,没有复审触发条件。没有触发条件的基线会从“当前决定”退化成“历史习惯”。

第五,跨产品复制字段但不校验契约。《泡泡工坊》的基线卡如果直接复制《裂隙战场》的部署单元,就会多出一个不该存在的 room 服务;这类错误靠人眼很难发现,靠 ValidateSet 很便宜。

第六,基线不版本化。架构演进后找不到旧基线,就无法解释“为什么当初这样选”,也无法判断新方案是否真的更好。

小结

三张基线卡把卷一前十二章的结论压缩成可评审、可校验的当前承诺:《裂隙战场》用大厅加房间换故障隔离和帧稳定,《群岛纪元》用全球同服加地域分片换一张地图的体验,《泡泡工坊》用模块化单体换上线速度。每张卡都写明状态归属、连接与权威模型、部署单元、关键 SLA、已接受取舍、已知风险和复审触发条件。

基线不是终点。下一节把这些卡片背后的判断抽成一张品类与拓扑决策矩阵,回答“换一个品类、换一组约束时,第一刀应该切在哪里”。

阅读导航:上一节:12.3 架构决策的记录、评审与复盘 · 下一节:13.2 品类与拓扑决策矩阵 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查