《游戏服务端架构·品类、状态与拓扑》3.1 单体、分层与模块化单体

《泡泡工坊》三个人的团队把登录、房间、经济和结算写进一个 main 包,第十八个月的一次促销结算拖垮了全部在线。本节把模块化单体拆成 auth、room、economy、settlement、ops 五个模块,用允许依赖表、依赖校验与三色环检测把边界变成 CI 门禁,并给出五条可量化的拆分触发条件。

一次促销结算把《泡泡工坊》拖下线

《泡泡工坊》是北辰工作室的休闲小游戏,三个人维护,单局 3–8 分钟。第一版后端只有一个二进制:登录、匹配、房间、金币、结算、运营接口全在 package main 里。它上线很快,也撑过了前一年。

第十八个月的一次周末促销暴露了问题。运营配置了一个“累计对局送金币”的活动,结算逻辑在 HTTP handler 里同步跑,还持有全局 rooms 锁。晚上八点,活动结算扫描到 40 万个账号,单次执行 90 秒。结果不是结算慢,而是:

  • 登录接口 p99 从 80ms 涨到 4.2s;
  • 正在进行的房间无法 tick,玩家看到角色原地不动;
  • 运营后台打不开,客服无法确认活动配置;
  • 值班同学唯一的止血手段是回滚整个二进制,连带回滚了当天下午刚修的匹配 bug。

复盘时争论的焦点是“要不要上微服务”。但真正的问题不是部署单元数量,而是这个二进制内部没有任何可检查的边界:结算可以直接改房间结构体,登录可以读金币表,运营接口可以调用战斗函数。所有代码都能到所有地方,故障自然也能到所有地方。

这一节先不讨论拆服务,而是回答一个更前置的问题:同样是“一个进程”,单体与单体之间的差别在哪里。

三种单体不是一回事

“单体”经常被当成一个贬义词。实际上至少有三类形态,边界质量完全不同:

形态边界依据典型症状适用阶段
单层单体没有边界,按文件堆放任何改动都可能影响任何功能原型、一次性工具
分层单体按技术层划分:handler/service/repo层内继续混杂,跨层调用随意早期业务,团队小于 5 人
模块化单体按业务能力划分,模块间有显式契约契约外的依赖会被 CI 拒绝小团队长期维护

分层单体比单层单体前进了一步,但它解决的是“代码放在哪”,不是“谁能依赖谁”。一个 service 包里同时放着登录、房间和结算,层是分开了,业务边界仍然没有。

模块化单体的关键不是目录,而是契约:每个模块声明自己拥有什么状态、允许依赖谁、对外暴露什么接口。契约之外的调用不是“风格问题”,而是构建失败。

对《泡泡工坊》这种三个人、实时性不强、玩法还在迭代的产品,模块化单体通常比微服务更合适:它保留单进程的调试和部署优势,同时把未来拆分的接缝提前画好。

五个模块与一张允许依赖表

把《泡泡工坊》的业务按“谁拥有状态”切开,得到五个模块:

模块职责拥有的状态允许依赖
auth登录、会话、令牌校验账号身份、会话ops
room房间生命周期、tick、成员房间内存状态auth、economy
economy钱包、流水、幂等发放余额、账本auth、ops
settlement对局结果转奖励、补偿结算任务、重试队列room、economy、auth、ops
ops审计、指标、只读后台审计日志无

依赖方向可以画成一张有向图:

auth ─────────────► ops
  ▲                   ▲
  │                   │
room ───► economy ────┘
  │           ▲
  └───────────┴──── settlement ───► auth / ops

四条禁止依赖需要单独解释,因为它们最容易被“顺手”打破:

  1. auth 不得依赖任何业务模块。 登录是入口,房间服挂了也必须能登录、能看公告、能联系客服。如果 auth 调 room,一次房间故障就会把登录一起拖死。
  2. room 不得依赖 settlement。 房间结束时只发布 MatchFinished 事件,结算模块订阅。反过来依赖会让“结算慢”直接阻塞房间释放,也会和 settlement → room 形成环。
  3. economy 不得依赖 room。 经济系统不应该理解“房间”“对局”这些玩法概念,它只接受带幂等键的加减币命令。否则每加一种玩法都要改钱包。
  4. ops 不得依赖业务模块。 观测面一旦能调用业务写接口,就会从“只读后台”变成第二个控制面,故障时没人说得清是谁改了状态。

这张表的价值在于它是否定式的:它主要回答“不能做什么”。架构评审时,允许什么通常很快达成一致,真正需要写下来的是那些看起来方便、实则制造耦合的捷径。

把依赖契约写成代码

只写在文档里的边界,三个月后一定会失效。下面把契约变成 gsarch-lab 里的一个包:

// file: internal/arch/graph.go
// tier: L1
// verify: go test ./internal/arch/
package arch

import (
	"fmt"
	"sort"
)

type Module string

const (
	Auth       Module = "auth"
	Room       Module = "room"
	Economy    Module = "economy"
	Settlement Module = "settlement"
	Ops        Module = "ops"
)

// modules is fixed so cycle reports are reproducible.
var modules = []Module{Auth, Room, Economy, Settlement, Ops}

type Edge struct {
	From Module
	To   Module
}

func (e Edge) String() string { return string(e.From) + "->" + string(e.To) }

// contract lists what a module MAY import. Anything absent is forbidden.
var contract = map[Module][]Module{
	Auth:       {Ops},
	Room:       {Auth, Economy},
	Economy:    {Auth, Ops},
	Settlement: {Room, Economy, Auth, Ops},
	Ops:        {},
}

func known(m Module) bool {
	_, ok := contract[m]
	return ok
}

func Allowed(e Edge) bool {
	for _, to := range contract[e.From] {
		if to == e.To {
			return true
		}
	}
	return false
}

type Violation struct {
	Edge   Edge
	Reason string
}

func (v Violation) String() string { return fmt.Sprintf("%s: %s", v.Edge, v.Reason) }

// Validate reports every dependency the contract does not permit.
func Validate(edges []Edge) []Violation {
	var out []Violation
	seen := make(map[Edge]bool, len(edges))
	for _, e := range edges {
		if !known(e.From) || !known(e.To) {
			out = append(out, Violation{e, "unknown module"})
			continue
		}
		if seen[e] {
			out = append(out, Violation{e, "duplicate dependency edge"})
			continue
		}
		seen[e] = true
		if !Allowed(e) {
			out = append(out, Violation{e,
				fmt.Sprintf("%s may not depend on %s", e.From, e.To)})
		}
	}
	return out
}

// DetectCycle returns the first cycle, with its start module repeated at the
// end (room->economy->room). The boolean is false when the graph is acyclic.
func DetectCycle(edges []Edge) ([]Module, bool) {
	adj := make(map[Module][]Module, len(modules))
	for _, m := range modules {
		adj[m] = nil
	}
	for _, e := range edges {
		if known(e.From) && known(e.To) {
			adj[e.From] = append(adj[e.From], e.To)
		}
	}
	for m := range adj {
		sort.Slice(adj[m], func(i, j int) bool { return adj[m][i] < adj[m][j] })
	}

	const white, gray, black = 0, 1, 2
	color := make(map[Module]int, len(modules))
	var stack, cycle []Module

	var dfs func(Module) bool
	dfs = func(m Module) bool {
		color[m] = gray
		stack = append(stack, m)
		for _, next := range adj[m] {
			if color[next] == gray {
				for i, s := range stack {
					if s == next {
						cycle = append(cycle, stack[i:]...)
						break
					}
				}
				cycle = append(cycle, next)
				return true
			}
			if color[next] == white && dfs(next) {
				return true
			}
		}
		stack = stack[:len(stack)-1]
		color[m] = black
		return false
	}

	for _, m := range modules {
		if color[m] == white && dfs(m) {
			return cycle, true
		}
	}
	return nil, false
}

// Check is the CI gate: allow-list violations plus cycle detection in one call.
func Check(edges []Edge) ([]Violation, []Module) {
	cycle, _ := DetectCycle(edges)
	return Validate(edges), cycle
}

这段代码有三个刻意的设计:

  • 契约是数据,不是 if。 新增一条合法依赖只改 contract,评审 diff 一眼可见;
  • 未知模块直接报错。 拼错模块名不会被当成“没有依赖”,而是构建失败;
  • 环检测独立于允许表。 即使某天为了性能临时放开一条边,环仍然会被拦下。

最后一点很重要:允许表本身也可能被改错。所以测试里还要反向验证“契约自己无环”,否则一条新加的合法边就可能把整个图闭合成环。

三类测试:合法、非法、循环

依赖治理至少要覆盖三种情况,缺一种都会留下盲区:

// file: internal/arch/graph_test.go
// tier: L1
// verify: go test ./internal/arch/
package arch

import "testing"

func TestLegalDependenciesPass(t *testing.T) {
	edges := []Edge{
		{Auth, Ops}, {Room, Auth}, {Room, Economy},
		{Economy, Auth}, {Economy, Ops},
		{Settlement, Room}, {Settlement, Economy},
		{Settlement, Auth}, {Settlement, Ops},
	}
	if v, c := Check(edges); len(v) != 0 || len(c) != 0 {
		t.Fatalf("legal layout rejected: %v cycle=%v", v, c)
	}
}

func TestIllegalDependenciesRejected(t *testing.T) {
	edges := []Edge{
		{Auth, Room}, {Room, Settlement},
		{Economy, Room}, {Ops, Auth},
	}
	got := Validate(edges)
	if len(got) != len(edges) {
		t.Fatalf("got %d violations, want %d: %v", len(got), len(edges), got)
	}
	for i, v := range got {
		if v.Edge != edges[i] || v.Reason == "" {
			t.Fatalf("violation %d = %+v", i, v)
		}
	}
}

func TestCycleDetected(t *testing.T) {
	edges := []Edge{{Room, Economy}, {Economy, Settlement}, {Settlement, Room}}
	cycle, ok := DetectCycle(edges)
	if !ok {
		t.Fatal("cycle not detected")
	}
	if len(cycle) != 4 || cycle[0] != cycle[3] {
		t.Fatalf("path must repeat its start: %v", cycle)
	}
	if _, c := Check(edges); len(c) == 0 {
		t.Fatal("Check must surface the cycle")
	}
}

func TestContractItselfIsAcyclic(t *testing.T) {
	var edges []Edge
	for from, targets := range contract {
		for _, to := range targets {
			edges = append(edges, Edge{from, to})
		}
	}
	if cycle, ok := DetectCycle(edges); ok {
		t.Fatalf("allow-list contains a cycle: %v", cycle)
	}
}

func TestUnknownAndDuplicateEdges(t *testing.T) {
	got := Validate([]Edge{
		{Module("billing"), Economy},
		{Room, Auth}, {Room, Auth},
	})
	if len(got) != 2 || got[0].Reason != "unknown module" ||
		got[1].Reason != "duplicate dependency edge" {
		t.Fatalf("got %+v", got)
	}
}

本机实际运行:

$ cd .local/checks/gsarch-lab
$ gofmt -l .
$ go vet ./...
$ go test -count=1 -v ./internal/arch/
=== RUN   TestLegalDependenciesPass
--- PASS: TestLegalDependenciesPass (0.00s)
=== RUN   TestIllegalDependenciesRejected
--- PASS: TestIllegalDependenciesRejected (0.00s)
=== RUN   TestCycleDetected
--- PASS: TestCycleDetected (0.00s)
=== RUN   TestContractItselfIsAcyclic
--- PASS: TestContractItselfIsAcyclic (0.00s)
=== RUN   TestUnknownAndDuplicateEdges
--- PASS: TestUnknownAndDuplicateEdges (0.00s)
PASS
ok  	gsarchlab/internal/arch	0.234s

gofmt -l 无输出,go vet 无告警。这里测的不是“代码能不能跑”,而是“边界被破坏时,CI 会不会响”。

什么时候该拆:五条量化触发条件

模块化单体的下一步是拆服务。但“什么时候拆”必须有观察口径,否则会退化成两种错误:要么永远不拆,要么被一次事故推着拆过头。

北辰工作室在《泡泡工坊》上用的是一张触发条件表:

触发条件观察口径阈值动作
发布耦合每周发布次数与全量回归时长≥3 次/周且回归 >45 分钟抽出变更最频繁的模块
故障扩散单模块故障影响的 DAU 比例与时长≥25% 或持续 >10 分钟先隔离该模块的部署与线程池
扩容错配各模块峰值 CPU 占比,持续 30 分钟单模块 ≥60%,其余 <20%该模块独立部署
团队并行同一模块每周合并冲突次数≥5 次且涉及 2 个小组按模块重划所有权
构建启动全量构建与冷启动时间构建 >8 分钟或冷启动 >60 秒模块独立构建

这张表有三个使用规则:

  1. 至少连续两周命中同一行,才进入拆分评审。一次促销事故不构成拆分理由,它只说明容量或隔离没做好。
  2. 先算收益,再看技术。 拆分收益 = 独立扩容节省的机器成本 − 网络调用、部署、排障和一致性带来的新增成本。收益必须连续两个月为正。
  3. 一次只拆一个模块。 同时拆两个模块,出问题时无法判断是哪个边界画错了。

反过来,下面这些不是拆分理由:代码行数超过十万、包里文件太多、招聘 JD 上写了微服务、同行都在拆。它们描述的是现状或潮流,不是可观测的代价。

拆分顺序:先异步,后有状态,最后是地基

如果触发条件成立,顺序比速度重要。《泡泡工坊》的做法是:

settlement(异步、无玩家直连)
    → room(有状态、需排空与租约)
    → economy(强一致、需幂等与对账)
    → auth(地基,最后动)

每一步都走同一条路径:先在进程内把调用改成接口,再加一个进程外适配器,然后双跑对账,最后切流量并删除旧入口。没有“双跑对账”这一步,拆分就只是一次大爆炸发布。

settlement 先拆,因为它天然异步,失败可以重试,玩家不直连;room 第二,因为它有状态,需要房间排空和分配器配合;economy 第三,因为它涉及钱,必须先把幂等键和账本对账做扎实;auth 最后,因为所有模块都依赖它,动它等于动地基。

常见错误与边界

第一,把目录当模块。internal/room 和 internal/economy 如果共享同一个数据库连接和同一张表,它们仍然是一个模块。

第二,契约只写在文档里。没有 CI 检查的边界,会在第一次赶需求时被绕过。

第三,只检查 import,不检查运行时回调。模块 A 把函数指针注册给模块 B,编译期看不到依赖,运行期却形成了反向调用。

第四,拆完不删旧入口。旧路径留在那里,半年后新人会继续用,边界等于没画。

第五,把“模块化单体”当成“永远不拆”的借口。它只是当前阶段的最优解,触发条件命中时仍然要演进。

第六,用拆分解决容量问题。如果瓶颈是单机 CPU,先做纵向优化或无状态水平扩展,拆服务只会把一次本地调用变成两次网络调用。

小结

单体不是一个二元选择。单层单体、分层单体、模块化单体的差别在于边界是否可检查。模块化单体用业务模块和允许依赖表把边界变成数据,再用依赖校验与环检测把它变成 CI 门禁。

对《泡泡工坊》来说,五个模块的契约先落下来,拆分才有依据;五条量化触发条件则回答“什么时候真的该动”。下一节进入竞技品类的标准形态:大厅无状态、房间有状态,以及一个必须防住“幽灵房间”的分配器。

阅读导航:上一节:2.3 一次真实的架构评审与风险台账 · 下一节:3.2 大厅、房间与战斗:竞技品类的标准形态 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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