基线不是一张架构图
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 / 长轮询 |
| 权威模型 | 预测-回滚 | 状态同步 | 服务端权威 |
| 故障域 | 单房间 | 单矩形分片 | 单服务 |
| 首要 SLA | 33ms tick | 20ms 同片操作 | 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 品类与拓扑决策矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。