《游戏服务端架构·品类、状态与拓扑》1.3 品类如何决定拓扑:北辰三款产品

用北辰工作室三款产品对照五种服务端拓扑:模块化单体、大厅加房间、分区世界、全球同服与专用服池;把并发、会话时长、实时性、共享世界、匹配制与地域敏感六个产品事实写成可执行的选型函数,说明为什么拓扑只是第一版决策,并给出三份 ADR 的退出条件。

同一个平台团队,三种完全不同的拓扑

北辰工作室的平台组曾经想做一个“统一游戏服务”:账号、匹配、房间、战斗、结算全部复用一套代码,三款产品只改配置。

这个想法在第一年就失败了。原因不是代码写得不好,而是三款产品的拓扑需求根本不同:

  • 《裂隙战场》需要大量短生命周期房间,每个房间独立 tick,故障不能互相影响;
  • 《群岛纪元》需要一个持续存在的全球世界,玩家分布在不同地域,但必须看到同一张地图;
  • 《泡泡工坊》需要快速开房、快速结束,单局只有 3–8 分钟,后端越轻越好。

用一套拓扑硬套三种产品,最后得到的是一个同时具备三者缺点的系统:房间不够隔离、世界不够一致、登录不够轻。

品类不是市场标签,它直接决定服务端的拓扑。

拓扑谱系

在讨论“选哪个”之前,先把常见拓扑列清楚。游戏服务端大致有五种形态。

模块化单体

一个可部署单元,内部按模块划分边界。适合:

  • 实时性要求不高;
  • 团队小,发布频率低;
  • 玩法还在快速试错,边界不稳定。

优点是部署简单、调试方便、没有网络故障。缺点是有状态部分难以独立扩容,故障域大。

大厅加房间

大厅无状态,负责登录、匹配、组队;房间有状态,负责一局游戏的完整生命周期。适合:

  • 离散对局;
  • 强实时;
  • 每局玩家数量有限。

这是竞技游戏最常见的形态。关键是房间的创建、分配、回收和故障恢复。

分区世界

把持续世界按地图、服务器或玩家群体切成多个分片,每个分片有独立的状态和进程。适合:

  • 有持久世界;
  • 玩家数量超过单进程承载;
  • 可以接受分片之间的有限交互。

难点在分片边界、跨分片交互和再均衡。

全球同服

所有玩家在逻辑上共享一个世界,物理上通过地域路由、分片和多副本实现。适合:

  • SLG、开放世界;
  • 玩家社交关系跨地域;
  • 商业上需要“全球一个服”的卖点。

难点在全球延迟、数据一致性、跨地域结算和合规。

专用服池

预先准备一批专用服务器,按需分配给对局或会话,结束后回收。适合:

  • FPS、竞技、房间制;
  • 需要独占 CPU/网络资源;
  • 对延迟和公平性要求高。

难点在池化、冷启动、扩容速度和成本。

决定拓扑的六个产品事实

拓扑不是由“技术先进程度”决定的,而是由产品事实决定的。下面六个问题,基本能区分大多数情况:

事实问题影响
并发玩家数同时在线多少人?是否需要分片/多地域
会话时长一局或一次在线多久?房间生命周期与状态持久化
实时性玩家是否感知帧/操作延迟?是否需要专用 tick 与房间隔离
共享世界玩家是否共享一个持久世界?分区 vs 房间
匹配制玩法是否以离散对局为单位?大厅+房间 vs 世界服
地域敏感跨地域延迟是否影响公平?是否需要地域路由/赛区

把这六个事实写成结构体,就可以做第一版选型:

// file: internal/topology/choose.go
// tier: L1
// verify: go test ./internal/topology/
package topology

// Kind is a first-pass server topology for one product.
type Kind string

const (
	ModularMonolith Kind = "modular-monolith"
	LobbyRoom       Kind = "lobby-room"
	ShardedWorld    Kind = "sharded-world"
	GlobalShard     Kind = "global-single-shard"
	DedicatedPool   Kind = "dedicated-server-pool"
)

// Profile is the handful of product facts that actually change topology.
type Profile struct {
	Name              string
	ConcurrentPlayers int
	SessionMinutes    int
	RealTime          bool // frame or operation budget is player-visible
	SharedWorld       bool // players share one persistent world
	MatchBased        bool // play happens in discrete matches
	RegionSensitive   bool // cross-region latency changes fairness
}

type Decision struct {
	Kind    Kind
	Reasons []string
}

// Choose is a decision aid, not an architecture review. It makes the first
// fork explicit; the ADR still has to justify the cost.
func Choose(p Profile) Decision {
	switch {
	case p.MatchBased && p.RealTime:
		return Decision{
			Kind: LobbyRoom,
			Reasons: []string{
				"matches need isolated failure domains",
				"the lobby can scale statelessly",
			},
		}
	case p.SharedWorld && p.ConcurrentPlayers > 10000:
		return Decision{
			Kind: GlobalShard,
			Reasons: []string{
				"one persistent world with a large population",
				"region routing must be decided before sharding",
			},
		}
	case p.SharedWorld:
		return Decision{
			Kind: ShardedWorld,
			Reasons: []string{
				"persistent world, but population fits a small number of shards",
				"shard by map region or server group first",
			},
		}
	case p.RealTime:
		return Decision{
			Kind: DedicatedPool,
			Reasons: []string{
				"real-time sessions need dedicated capacity",
				"no shared world means instances can be recycled",
			},
		}
	default:
		return Decision{
			Kind: ModularMonolith,
			Reasons: []string{
				"no player-visible real-time pressure",
				"start with one deployable and split only when the boundary hurts",
			},
		}
	}
}

这个函数故意很粗。它不是架构评审的替代品,而是把“第一刀切在哪”显式化。真正的决策还要写 ADR,说明代价和退出条件。

对应的测试用三款产品作为固定样例:

// file: internal/topology/choose_test.go
// tier: L1
// verify: go test ./internal/topology/
package topology

import "testing"

func TestNorthstarProducts(t *testing.T) {
	cases := []struct {
		name string
		p    Profile
		want Kind
	}{
		{
			name: "裂隙战场",
			p: Profile{
				Name: "裂隙战场", ConcurrentPlayers: 200000,
				SessionMinutes: 25, RealTime: true, MatchBased: true, RegionSensitive: true,
			},
			want: LobbyRoom,
		},
		{
			name: "群岛纪元",
			p: Profile{
				Name: "群岛纪元", ConcurrentPlayers: 80000,
				SessionMinutes: 60, SharedWorld: true, RegionSensitive: true,
			},
			want: GlobalShard,
		},
		{
			name: "泡泡工坊",
			p: Profile{
				Name: "泡泡工坊", ConcurrentPlayers: 30000,
				SessionMinutes: 8, MatchBased: true,
			},
			want: ModularMonolith,
		},
	}
	for _, c := range cases {
		got := Choose(c.p)
		if got.Kind != c.want {
			t.Fatalf("%s: got %s, want %s", c.name, got.Kind, c.want)
		}
		if len(got.Reasons) == 0 {
			t.Fatalf("%s: decision has no reasons", c.name)
		}
	}
}

func TestSharedWorldBelowThreshold(t *testing.T) {
	got := Choose(Profile{SharedWorld: true, ConcurrentPlayers: 5000})
	if got.Kind != ShardedWorld {
		t.Fatalf("got %s, want %s", got.Kind, ShardedWorld)
	}
}

本机验证:

$ cd .local/checks/gsarch-lab
$ go test ./internal/topology/ -v
=== RUN   TestNorthstarProducts
--- PASS: TestNorthstarProducts (0.00s)
=== RUN   TestSharedWorldBelowThreshold
--- PASS: TestSharedWorldBelowThreshold (0.00s)
PASS
ok  	gsarchlab/internal/topology	0.4s

三款产品的选择与理由

《裂隙战场》:大厅加房间

输入:20 万在线、单局 25 分钟、强实时、匹配制、地域敏感。

选择:大厅无状态,房间有状态,按赛区部署。

理由:

  • 一局比赛是一个天然故障域,房间进程崩溃只影响 10 个人,不影响其他房间;
  • 大厅只做登录、匹配、组队,可以水平扩容;
  • 赛区隔离让跨地域延迟不进入对局公平性。

代价:

  • 房间是有状态服务,发布需要排空;
  • 大厅与房间之间多了一次网络调用;
  • 需要房间分配器和暖池,冷启动要优化。

《群岛纪元》:全球同服

输入:8 万在线、长时间在线、非实时、共享世界、地域敏感。

选择:逻辑上全球一个世界,物理上按地域路由、按地图分片。

理由:

  • SLG 的核心体验是“所有人看到同一张地图”,分服会破坏社交和战争叙事;
  • 8 万在线可以放在少量分片里,不需要按玩家数无限切分;
  • 地域路由解决延迟,分片解决单进程容量。

代价:

  • 跨分片交互复杂;
  • 全球结算需要统一时间线;
  • 数据合规和跨境传输必须提前设计。

《泡泡工坊》:模块化单体起步

输入:3 万在线、单局 8 分钟、有房间但实时性弱、团队只有 3 人。

选择:先做一个模块化单体,把房间作为内部模块;达到规模阈值再拆。

理由:

  • 团队只有 3 个人,拆成多个服务会先把运维成本压垮;
  • 休闲玩法对帧不敏感,单进程可以承载;
  • 房间模块边界清晰,未来拆分成本可控。

代价:

  • 单体故障域大;
  • 有状态房间和登录在同一进程,发布需要排空;
  • 规模上来后必须拆分,不能一直将就。

注意:Choose 函数对《泡泡工坊》返回 ModularMonolith,即使它是匹配制。这是有意的:匹配制只说明需要房间,不说明房间必须独立部署。第一版决策函数看的是“实时性 + 匹配制”的组合,而不是单个布尔值。

三份 ADR 的核心结论

把上面的选择写成 ADR,每份只需要回答四个问题:

ADR决定主要备选退出条件
ADR-002《裂隙战场》采用大厅+房间单体;全球同服单赛区房间数超过调度器上限,或跨赛区匹配成为主玩法
ADR-003《群岛纪元》采用全球同服+地域分片分区分服;按联盟分服跨地域延迟导致战争结果不可接受,或合规要求数据本地化
ADR-004《泡泡工坊》先做模块化单体直接上大厅+房间;Serverless单进程房间数接近承载上限,或团队扩到能独立运维

“退出条件”是 ADR 里最容易被忽略、却最重要的一栏。没有退出条件,架构就会变成信仰;有了退出条件,团队知道什么时候该重新评估。

拓扑不是永久决定

拓扑会随着产品阶段变化:

立项期:模块化单体,先验证玩法
    │
    ├── 玩法成立、在线增长 → 拆大厅与房间
    │
    ├── 全球化发行 → 加地域路由与分片
    │
    └── 多产品复用 → 平台服务化

北辰三款产品也走过这条路:《泡泡工坊》在第二年拆出了房间服务,《裂隙战场》在第三年加了跨赛区匹配,《群岛纪元》在出海时重画了地域路由。

架构文档如果不记录“当前阶段”和“下一阶段触发条件”,就会被当成永久真理。

常见错误与边界

第一,按技术潮流选拓扑。微服务、Serverless、Service Mesh 都不是品类需求的答案。

第二,把“在线人数”当成唯一指标。会话时长、实时性、共享世界同样决定拓扑。

第三,忽略团队规模。三个人的团队运维不了十个服务。

第四,把第一版决策函数当最终结论。它只负责切第一刀,ADR 负责解释代价。

第五,不写退出条件。没有退出条件,架构就无法演进。

第六,三款产品强行共用一套拓扑。共用平台服务可以,共用拓扑通常不行。

小结

品类通过六个产品事实影响拓扑:并发、会话时长、实时性、共享世界、匹配制、地域敏感。北辰三款产品分别落到大厅+房间、全球同服、模块化单体三种形态,各自的代价和退出条件都不同。

这一节把“品类如何决定拓扑”写成了一个可测试的决策函数,但它只是第一版。下一章进入架构决策方法:如何用质量属性、ADR 和评审,把第一版决策变成团队可执行的共识。

阅读导航:上一节:1.2 三类延迟预算:帧、操作、结算 · 下一节:2.1 质量属性不是形容词,而是可测量的场景 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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