一次促销结算把《泡泡工坊》拖下线
《泡泡工坊》是北辰工作室的休闲小游戏,三个人维护,单局 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
四条禁止依赖需要单独解释,因为它们最容易被“顺手”打破:
auth不得依赖任何业务模块。 登录是入口,房间服挂了也必须能登录、能看公告、能联系客服。如果auth调room,一次房间故障就会把登录一起拖死。room不得依赖settlement。 房间结束时只发布MatchFinished事件,结算模块订阅。反过来依赖会让“结算慢”直接阻塞房间释放,也会和settlement → room形成环。economy不得依赖room。 经济系统不应该理解“房间”“对局”这些玩法概念,它只接受带幂等键的加减币命令。否则每加一种玩法都要改钱包。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 秒 | 模块独立构建 |
这张表有三个使用规则:
- 至少连续两周命中同一行,才进入拆分评审。一次促销事故不构成拆分理由,它只说明容量或隔离没做好。
- 先算收益,再看技术。 拆分收益 = 独立扩容节省的机器成本 − 网络调用、部署、排障和一致性带来的新增成本。收益必须连续两个月为正。
- 一次只拆一个模块。 同时拆两个模块,出问题时无法判断是哪个边界画错了。
反过来,下面这些不是拆分理由:代码行数超过十万、包里文件太多、招聘 JD 上写了微服务、同行都在拆。它们描述的是现状或潮流,不是可观测的代价。
拆分顺序:先异步,后有状态,最后是地基
如果触发条件成立,顺序比速度重要。《泡泡工坊》的做法是:
settlement(异步、无玩家直连)
→ room(有状态、需排空与租约)
→ economy(强一致、需幂等与对账)
→ auth(地基,最后动)
每一步都走同一条路径:先在进程内把调用改成接口,再加一个进程外适配器,然后双跑对账,最后切流量并删除旧入口。没有“双跑对账”这一步,拆分就只是一次大爆炸发布。
settlement 先拆,因为它天然异步,失败可以重试,玩家不直连;room 第二,因为它有状态,需要房间排空和分配器配合;economy 第三,因为它涉及钱,必须先把幂等键和账本对账做扎实;auth 最后,因为所有模块都依赖它,动它等于动地基。
常见错误与边界
第一,把目录当模块。internal/room 和 internal/economy 如果共享同一个数据库连接和同一张表,它们仍然是一个模块。
第二,契约只写在文档里。没有 CI 检查的边界,会在第一次赶需求时被绕过。
第三,只检查 import,不检查运行时回调。模块 A 把函数指针注册给模块 B,编译期看不到依赖,运行期却形成了反向调用。
第四,拆完不删旧入口。旧路径留在那里,半年后新人会继续用,边界等于没画。
第五,把“模块化单体”当成“永远不拆”的借口。它只是当前阶段的最优解,触发条件命中时仍然要演进。
第六,用拆分解决容量问题。如果瓶颈是单机 CPU,先做纵向优化或无状态水平扩展,拆服务只会把一次本地调用变成两次网络调用。
小结
单体不是一个二元选择。单层单体、分层单体、模块化单体的差别在于边界是否可检查。模块化单体用业务模块和允许依赖表把边界变成数据,再用依赖校验与环检测把它变成 CI 门禁。
对《泡泡工坊》来说,五个模块的契约先落下来,拆分才有依据;五条量化触发条件则回答“什么时候真的该动”。下一节进入竞技品类的标准形态:大厅无状态、房间有状态,以及一个必须防住“幽灵房间”的分配器。
阅读导航:上一节:2.3 一次真实的架构评审与风险台账 · 下一节:3.2 大厅、房间与战斗:竞技品类的标准形态 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。