矩阵不是投票器
一次选型会上,两个人为了《泡泡工坊》要不要拆房间服务争论了四十分钟。一方说“匹配制就该大厅加房间”,另一方说“团队这么小,拆了没人运维”。吵到最后才发现,双方对“实时性”的赋值不同:一个人把“有网络延迟”算成高,另一个人按“玩家不感知帧”算成低。
矩阵的第一个作用不是给答案,而是把这类分歧变成可讨论的输入。特征怎么定义、强度怎么打分、代价写在哪一列,必须先在桌上对齐;对齐之后,结论往往自己就出来了。
第 1.3 节 用六个产品事实做了第一版选型函数,第 3.3 节 比较了分区、分服与全球同服。这一节把前十二章的判断收成一张矩阵:行是品类特征,列是拓扑选项,每个格子写清推荐程度、理由和代价。
五个特征,四种强度
特征不能只填布尔值。“有实时性”和“帧级实时性”是两种完全不同的约束;“有共享世界”和“全球共享一张大地图”也不是一个量级。矩阵给每个特征四档:
| 特征 | 要回答的问题 | 0 无 | 1 弱 | 2 中 | 3 强 |
|---|---|---|---|---|---|
| 实时性 | 玩家是否感知帧或操作延迟 | 无连续操作 | 秒级反馈可接受 | 100–300ms | 帧级,50ms 以内 |
| 共享世界 | 玩家是否共享持久世界 | 每局独立 | 单局内共享 | 小型持久世界 | 全球大地图 |
| 房间制 | 玩法是否以离散对局为单位 | 无对局 | 弱对局边界 | 部分玩法分房 | 一局即一个故障域 |
| 状态规模 | 每 tick 需要裁决多少状态 | 很小 | 单进程可容纳 | 需要分片 | 全球多分片 |
| 跨区需求 | 跨地域交互是否影响公平或体验 | 无 | 弱社交 | 有跨区玩法 | 强对抗或强协作 |
四档不是精确测量,而是评审时必须给出证据的判断。实时性看操作预算,共享世界看地图是否持久,房间制看一局是否有明确开始和结束,状态规模看峰值 tick 成本而不是注册人数,跨区需求看跨地域交互是否进入同一场战斗。没有证据的档位,应该在评审记录里标成“待验证”,而不是当作事实。
1.3 节的六个产品事实在这里合并成五维:并发玩家数与状态规模合并,因为大并发最终体现为峰值 tick 成本;会话时长通过房间制体现,短会话通常对应离散对局;实时性、共享世界、跨区需求保持独立。合并不是丢信息,而是把“人数”和“状态量”这两个经常被混用的指标分开。
25 个格子
行是特征,列是拓扑。每个格子给出推荐程度、一句理由和代价。推荐表示这个拓扑对该特征有原生支持;可选表示能做,但要额外工程;不推荐表示会直接破坏该特征的核心体验。
| 特征 | 模块化单体 | 大厅 + 房间 | 分区世界 | 全球同服 | 专用服池 |
|---|---|---|---|---|---|
| 实时性 | 可选·单进程可跑 tick;代价:没有房间级隔离 | 推荐·每房独占 tick 与输入窗口;代价:分配器与暖池 | 可选·世界 tick 可以很粗;代价:操作反馈不跟手 | 不推荐·跨地域 RTT 进入帧预算;代价:公平无法承诺 | 推荐·独占 CPU 与网络;代价:池化与冷启动 |
| 共享世界 | 可选·小世界可以放单进程;代价:只能纵向扩容 | 不推荐·房间结束即销毁;代价:世界状态无处安放 | 推荐·按地图范围切持久世界;代价:跨片迁移协议 | 推荐·逻辑一张图、物理多套数据;代价:地域路由与合规 | 不推荐·实例按局回收;代价:世界不连续 |
| 房间制 | 推荐·房间可作为模块先内聚;代价:发布仍绑在一起 | 推荐·大厅无状态、房间有状态;代价:多一层分配调用 | 可选·房间可以挂在分片里;代价:生命周期受分片影响 | 可选·全球匹配、房间就近落地;代价:匹配与开房跨层 | 推荐·每局独占实例;代价:调度与空闲成本 |
| 状态规模 | 不推荐·单库单进程很快碰到上限;代价:拆分边界被代码绑死 | 可选·单房状态小但总量线性增长;代价:需要独立归档 | 推荐·状态跟着地图范围走;代价:热区与再均衡 | 推荐·按赛区与地图分摊状态;代价:全球索引与事件同步 | 不推荐·实例状态随对局回收;代价:持久化仍要另建 |
| 跨区需求 | 不推荐·单部署单元只能选一个地域;代价:跨区靠数据库复制 | 可选·房间按赛区就近创建;代价:跨区组队要另设计 | 可选·地图分片可按地域部署;代价:跨区行军需要显式协议 | 推荐·地域路由是全球同服的前置条件;代价:数据驻留与结算时间线 | 可选·各地区分别建池;代价:容量规划翻倍 |
这张表最重要的不是“推荐”两个字,而是“代价”那一半。推荐只说明方向对,不说明便宜。大厅加房间对所有实时对局都推荐,但它要求分配器、暖池、租约和排空发布;全球同服对共享世界推荐,但它要求地域路由、数据驻留和跨区结算。只看推荐列做选型,会把代价留到上线后才发现。
打分不是投票
把特征强度和格子推荐合成一个分数:
推荐 = 2 分,可选 = 1 分,不推荐 = 0 分
总分 = Σ(特征强度 × 格子分数)
强度取 0 到 3,所以单个特征最高贡献 6 分,五个特征满分 30 分。分数区间对应的只是“值不值得进入下一轮评审”:
| 总分 | 判定 | 含义 |
|---|---|---|
| 14 分及以上 | 推荐 | 值得写 ADR,进入方案设计 |
| 6 到 13 分 | 可选 | 能工作,但需要明确代价和补偿措施 |
| 5 分及以下 | 不推荐 | 除非有外部约束,否则不应作为首选 |
平局按运维成本裁决:模块化单体最低,大厅加房间次之,分区世界与专用服池再次,全球同服最高。同样的分数下,先用更简单的形态验证玩法;等触发条件出现,再付复杂的代价。
打分有三个刻意保留的粗糙之处。第一,五个特征默认等权,但真实项目里实时性和共享世界往往比跨区需求更重要;需要时可以在 Profile 里用强度表达,而不是改权重。第二,格子分数只有 0/1/2,不能表达“推荐但很贵”和“可选但很便宜”的细微差别,这部分交给代价列和 ADR。第三,总分不包含团队规模、预算和合规,这些是矩阵之外的硬约束,必须单独写。
代码:特征向量与拓扑打分
internal/matrix 把上面的表写成可测试的结构:capability 是 5×5 的能力矩阵,Profile 是产品特征向量,Evaluate 负责打分、排序和平局裁决。下面是完整实现。
// file: internal/matrix/matrix.go
// tier: L1
// verify: go test ./internal/matrix/
package matrix
import "sort"
type Feature string
const (
Realtime Feature = "realtime"
SharedWorld Feature = "shared-world"
RoomBased Feature = "room-based"
LargeState Feature = "large-state"
CrossRegion Feature = "cross-region"
)
var Features = []Feature{Realtime, SharedWorld, RoomBased, LargeState, CrossRegion}
type Topology string
const (
ModularMonolith Topology = "modular-monolith"
LobbyRoom Topology = "lobby-room"
ShardedWorld Topology = "sharded-world"
GlobalShard Topology = "global-shard"
DedicatedPool Topology = "dedicated-server-pool"
)
var Topologies = []Topology{ModularMonolith, LobbyRoom, ShardedWorld, GlobalShard, DedicatedPool}
type Fit int
const (
NotRecommended Fit = iota
Optional
Recommended
)
func (f Fit) String() string {
return [...]string{"不推荐", "可选", "推荐"}[f]
}
// Cell is one feature/topology intersection. Reason says why the topology does
// or does not support the feature; Cost is paid even when the fit is good.
type Cell struct {
Fit Fit
Reason string
Cost string
}
// capability is the reviewed matrix. It is a capability view, not a final
// recommendation: a product combines several rows, and cost breaks ties.
var capability = map[Feature]map[Topology]Cell{
Realtime: {
ModularMonolith: {Optional, "单进程也能跑 tick,但没有房间级隔离", "一次停顿影响全部对局"},
LobbyRoom: {Recommended, "每间房独占 tick 与输入窗口", "分配器、暖池与排空发布"},
ShardedWorld: {Optional, "世界 tick 可以很粗,操作反馈不跟手", "边界迁移与热区扩容"},
GlobalShard: {NotRecommended, "跨地域 RTT 直接进入帧预算", "无法承诺对局公平"},
DedicatedPool: {Recommended, "独占 CPU 与网络,延迟最稳", "池化、冷启动与空闲成本"},
},
SharedWorld: {
ModularMonolith: {Optional, "小世界可以放在单进程", "只能纵向扩容,故障域大"},
LobbyRoom: {NotRecommended, "房间结束即销毁,没有持久世界", "世界状态无处安放"},
ShardedWorld: {Recommended, "按地图范围切分持久世界", "跨片交互与迁移协议"},
GlobalShard: {Recommended, "逻辑一张图,物理多套数据", "地域路由与数据驻留"},
DedicatedPool: {NotRecommended, "实例按局回收,世界不连续", "无法承载长期世界"},
},
RoomBased: {
ModularMonolith: {Recommended, "房间可以作为清晰模块先内聚", "扩容与发布仍绑在一起"},
LobbyRoom: {Recommended, "大厅无状态、房间有状态是原生分工", "多一层分配与调用"},
ShardedWorld: {Optional, "房间可以挂在世界分片里", "房间生命周期受分片影响"},
GlobalShard: {Optional, "全球匹配,房间仍要就近落地", "匹配与开房跨层"},
DedicatedPool: {Recommended, "每局独占实例,天然隔离", "实例利用率与调度成本"},
},
LargeState: {
ModularMonolith: {NotRecommended, "单库单进程很快碰到容量上限", "拆分时边界已被代码绑死"},
LobbyRoom: {Optional, "单房间状态小,总量随房间数增长", "需要独立持久化与归档"},
ShardedWorld: {Recommended, "状态跟着地图范围走", "热区与再均衡"},
GlobalShard: {Recommended, "按赛区与地图分片分摊状态", "全球索引与事件同步"},
DedicatedPool: {NotRecommended, "实例状态随对局回收,不能当长期存储", "持久化仍要另建"},
},
CrossRegion: {
ModularMonolith: {NotRecommended, "单部署单元只能选一个地域", "跨区只能靠数据库复制"},
LobbyRoom: {Optional, "房间按赛区就近创建", "跨区匹配与组队要额外设计"},
ShardedWorld: {Optional, "地图分片可以按地域部署", "跨区行军需要显式协议"},
GlobalShard: {Recommended, "地域路由是全球同服的前置条件", "数据驻留与结算时间线"},
DedicatedPool: {Optional, "各地区分别建池", "池容量规划翻倍"},
},
}
type Level int
const (
Absent Level = iota
Low
Medium
High
)
type Profile map[Feature]Level
type Verdict struct {
Topology Topology
Score int
Fit Fit
Reason string
Cost string
}
var opsCost = map[Topology]int{
ModularMonolith: 0,
LobbyRoom: 1,
ShardedWorld: 2,
DedicatedPool: 2,
GlobalShard: 3,
}
func CellFor(f Feature, t Topology) (Cell, bool) {
row, ok := capability[f]
if !ok {
return Cell{}, false
}
cell, ok := row[t]
return cell, ok
}
func dominant(p Profile) Feature {
best := Feature("")
level := Absent
for _, f := range Features {
if p[f] > level {
best, level = f, p[f]
}
}
return best
}
func classify(score int) Fit {
switch {
case score >= 14:
return Recommended
case score >= 6:
return Optional
default:
return NotRecommended
}
}
// Evaluate scores every topology against a product profile. Score is the sum
// of level times cell fit; ties break on operations cost, then on name, so the
// result is deterministic and explainable.
func Evaluate(p Profile) []Verdict {
out := make([]Verdict, 0, len(Topologies))
for _, t := range Topologies {
score := 0
for _, f := range Features {
score += int(p[f]) * int(capability[f][t].Fit)
}
v := Verdict{Topology: t, Score: score, Fit: classify(score)}
if f := dominant(p); f != "" {
cell := capability[f][t]
v.Reason, v.Cost = cell.Reason, cell.Cost
} else {
v.Reason, v.Cost = "没有强特征,按最低运维成本起步", "功能增长后仍要复审"
}
out = append(out, v)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
if opsCost[out[i].Topology] != opsCost[out[j].Topology] {
return opsCost[out[i].Topology] < opsCost[out[j].Topology]
}
return out[i].Topology < out[j].Topology
})
return out
}
// Top returns the first verdict after scoring and tie-breaking.
func Top(p Profile) Verdict {
return Evaluate(p)[0]
}
几个实现选择值得说明:
- 能力矩阵是单一事实来源。 文章里的 25 格和代码里的
capability必须一致;测试逐格检查理由和代价非空,防止只填了推荐程度却忘了写代价。 - 打分是确定性的。 排序先比分数,再比运维成本,最后比拓扑名。没有随机、没有 map 遍历顺序,评审记录可以逐字复现。
- 平局裁决显式化。
opsCost是一张独立的表,不是藏在排序函数里的魔法数字。想调整“同等分数下先选谁”,改这一张表并补测试。 Top只返回第一名。 它不替评审做决定,只把“最值得进入下一轮”的候选挑出来;完整排序用Evaluate。
三款产品回填
用第 1.3 节的产品事实给三款产品填特征向量,再跑矩阵:
| 产品 | 实时性 | 共享世界 | 房间制 | 状态规模 | 跨区需求 | 第一名 | 第二名 | 结论 |
|---|---|---|---|---|---|---|---|---|
| 《裂隙战场》 | 3 | 0 | 3 | 2 | 2 | 大厅 + 房间 16 分 | 专用服池 14 分 | 与 3.2 的基线一致 |
| 《群岛纪元》 | 0 | 3 | 0 | 3 | 3 | 全球同服 18 分 | 分区世界 15 分 | 与 8.1 的基线一致 |
| 《泡泡工坊》 | 0 | 0 | 3 | 0 | 0 | 模块化单体 6 分 | 大厅 + 房间、专用服池同分 | 按运维成本裁决,与 1.3 一致 |
《裂隙战场》的分数差距不大:大厅加房间 16 分,专用服池 14 分。这不奇怪,两者都原生支持实时和房间;决定差异的是跨区需求,大厅加房间可以按赛区就近开房,专用服池则要为每个地区分别建池。第 3.2 节最终选大厅加房间,代价是分配器和暖池;如果团队已经有成熟的专用服池运维能力,14 分的方案也不是错误答案。
《群岛纪元》的 18 分几乎没有悬念:共享世界、状态规模、跨区需求三项都是强,只有全球同服同时拿到三个推荐。分区世界 15 分,差距在跨区需求上——它可以按地域部署,但跨区行军要显式协议,全球事件也要另建。
最值得看的是《泡泡工坊》。它的最高分只有 6 分,判定是“可选”,不是“推荐”。矩阵没有团队规模这一维,所以它无法解释“三到四个后端不该维护多个服务”;这个理由由第 9.3 节的成本分析和 ADR 补上。矩阵能做的是把三个同分方案摆出来:模块化单体、大厅加房间、专用服池都是 6 分,按运维成本选单体。当矩阵输出“可选”而团队仍然选择它时,必须把外部约束写进 ADR,而不是假装矩阵给了推荐。
测试守住了什么
internal/matrix 有 5 个测试函数、39 个运行项(含子测试),覆盖三款产品回填、特征主导、25 格完整性、平局裁决和确定性。
| 测试 | 覆盖的失败模式 |
|---|---|
TestNorthstarBackfill | 三款产品回填后第一名与卷一基线一致 |
TestFeatureDominance | 共享世界加跨区、实时加房间、小世界、休闲房间四类组合 |
TestEveryCellHasReasonAndCost | 25 个格子逐格检查推荐程度、理由和代价 |
TestTieBreaksOnOpsCost | 同分时按运维成本选择,并确认确实是平局 |
TestEvaluateIsDeterministic | 同一向量连续 20 次排序完全一致 |
本机实际运行结果:
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/matrix
$ go vet ./internal/matrix/
$ go test -count=1 ./internal/matrix/
ok gsarchlab/internal/matrix 0.256s
gofmt -l 与 go vet 均无输出,退出码为 0。耗时随机器变化,测试数量与通过状态不随耗时变化。
矩阵的适用边界与失效条件
矩阵回答的是“在给定特征下,哪种拓扑值得优先评审”,不是“哪种拓扑正确”。它有四条明确边界。
第一,不含组织约束。团队规模、运维能力、预算、招聘计划都不在矩阵里。《泡泡工坊》的 6 分是拓扑分数,不是组织建议;把一个 6 分方案做成基线,需要 ADR 解释“为什么愿意接受这个分数”。
第二,不含合规与数据驻留。全球同服在矩阵里是推荐,但如果合规要求账号数据本地化,全球单库仍然不可行。第 8.1 节已经把“逻辑一张图、物理多套数据”写成前提,矩阵不能替代这条前提。
第三,特征是判断,不是测量。同一个产品,运营和架构师可能给出不同的强度。矩阵要求每个档位附证据:并发、会话时长、操作预算、峰值 tick 成本、跨区 RTT。没有证据的档位,结论只能当假设。
第四,矩阵会随产品阶段失效。立项期的《泡泡工坊》是房间制 3、实时性 0,矩阵给单体;如果第二年加入实时对战,实时性变成 2 到 3,矩阵会立刻把大厅加房间推到前面。基线要复审,矩阵也要复审。
还有一类矩阵处理不了的情况:两个强特征指向不同拓扑。例如一个产品既要帧级实时,又要全球共享大地图,矩阵会给全球同服或分区世界一个折中分,但真实答案往往是拆成两套子系统——竞技战场按房间就近部署,世界地图按地域分片,中间用事件同步。矩阵能暴露冲突,不能替你拆系统。
常见错误与边界
第一,把特征布尔化。“有实时性”填 1,“有共享世界”填 1,五个 1 相加得到 5 分,矩阵退化成投票器。强度必须分档,否则无法区分“偶尔同步”和“每帧同步”。
第二,五个特征等权却不说明。等权是默认值,不是真理;如果实时性明显更重要,应该在评审记录里写清楚,而不是偷偷改分数。
第三,只看推荐列,不看代价列。推荐只说明方向,代价才决定能不能落地。大厅加房间的分配器、全球同服的地域路由,都是推荐背后的硬成本。
第四,用在线人数代替状态规模。状态规模看的是峰值 tick 成本和活跃实体数,不是注册玩家数;SLG 的王城战可以把四万人压进一个矩形,在线人数却可能很平稳。
第五,跨产品复用矩阵但不重填特征。《裂隙战场》的向量不能直接套到《泡泡工坊》,房间制相同不代表实时性和状态规模相同。
第六,矩阵不版本化。格子含义、权重、平局规则变化后,旧结论无法复现;矩阵应该和 ADR 一样带版本号。
小结
品类与拓扑决策矩阵把五个特征和五种拓扑放进同一张表,每个格子写清推荐程度、理由和代价;打分只用来排序,平局按运维成本裁决,组织与合规约束留给 ADR。三款产品回填后,矩阵与卷一前面的结论一致:《裂隙战场》落在大厅加房间,《群岛纪元》落在全球同服,《泡泡工坊》落在模块化单体,而且最后一个是“可选”而非“推荐”,必须由成本理由补足。
下一节收口:把基线、风险、未决决策和待压测假设装进一份可判定的检查清单,说明哪些问题可以带条件进入卷二,哪些必须在一卷内解决。
阅读导航:上一节:13.1 三款产品的架构基线 · 下一节:13.3 进入卷二:把系统装进边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。