一次热更回滚,为什么连登录也停了
《群岛纪元》是北辰工作室的 SLG,全球同服,大地图按区块分片。上线第四个月,地图组要修一个资源点刷新错误,按惯例发布了整个 island-server。回滚时发现新版本把登录会话表的索引名改错了,登录 p99 从 90ms 涨到 6s。为了修地图,登录一起停;为了回滚登录,地图修复一起退。
值班同学在复盘会上问:“为什么地图和登录会在同一个二进制里?”这不是“要不要上微服务”的问题。同一个进程也可以有清晰的部署单元:地图分片、登录网关、异步结算各有自己的状态、伸缩曲线和发布节奏。问题在于团队从来没有把这些差异写下来,于是“能一起编译”被当成了“应该一起发布”。
这一节讨论三件事:部署单元按什么维度切、三款产品实际怎么切,以及切完之后如何用代码检查边界没有被悄悄破坏。
五个切分维度
切分不是按代码行数,也不是按目录深度。北辰工作室用五个问题判断一个候选单元是否成立:
| 维度 | 要回答的问题 | 不切开的症状 |
|---|---|---|
| 变更频率 | 两块代码是否总是一起改? | 低频繁模块被高频繁发布拖着走 |
| 伸缩特性 | 峰值时是否按同一比例扩容? | 为地图扩容顺带买登录机器 |
| 故障隔离 | 一个坏了,另一个是否必须陪着? | 地图 OOM 把登录一起拖死 |
| 团队所有权 | 是否由同一个小组值班和评审? | 出事时两个组互相等对方 |
| 数据边界 | 是否写同一批状态键? | 拆开后仍共享一张表,等于没拆 |
这五条不是打分表,而是否决条件。任何一条长期不成立,都会在某个事故里变成发布耦合或故障扩散。反过来,五条都成立也不代表必须拆:拆分收益还要覆盖网络调用、部署和排障的新增成本。模块化单体的依赖契约见3.1 单体、分层与模块化单体 ,这里不再重复。
三款产品的部署单元表
同一个工作室,三个品类的切法完全不同。下表是 2026 年 10 月的评审快照,单元名、状态和发布节奏都是示例值,不是线上实时配置:
| 产品 | 部署单元 | 职责 | 状态 | 伸缩方式 | 发布节奏 |
|---|---|---|---|---|---|
| 《裂隙战场》 | gateway | 连接、鉴权、会话路由 | 会话索引 | 按连接数 | 每周 |
| lobby | 匹配、组队、房间查询 | 匹配队列 | 按 QPS | 每周 | |
| allocator | 房间租约、暖池 | 租约记录 | 按开房速率 | 独立 | |
| room | tick、战斗、结算触发 | 房间内存 | 按房间数分区 | 每周,需排空 | |
| settlement | 奖励与补偿 | 任务队列 | 按积压 | 每日 | |
| 《群岛纪元》 | gateway | 登录、路由 | 会话 | 按连接数 | 每周 |
| worldmap | 大地图区块、行军 | 区块内存 | 按区块分片 | 每月,需迁移 | |
| settlement | 异步战斗结算 | 任务队列 | 按积压 | 每日 | |
| economy | 钱包、流水 | 账本 | 按账号分片 | 独立,强审计 | |
| 《泡泡工坊》 | app | 登录、活动、背包、排行 | 无权威状态 | 副本数 | 每日 |
| worker | 结算、补偿、报表 | 任务队列 | 按队列 | 每日 |
三张表的差异来自品类约束,而不是技术偏好。《裂隙战场》的房间有 tick,重启必须排空,所以 room 和 allocator 的发布节奏被拆开;《群岛纪元》的地图有迁移成本,所以 worldmap 按月发布,settlement 可以每天发;《泡泡工坊》没有权威对局状态,一个无状态 app 加一个异步 worker 就够了,轻量后端的细节见9.3 小游戏后端的轻量化与快速上线
。
反模式一:按技术分层切
最常见的错误切法是“所有 controller 一个单元,所有 service 一个单元,所有 repository 一个单元”。它在目录上很整齐,在部署上却制造了最坏的耦合:
| 症状 | 说明 |
|---|---|
| 发布必须同步 | 改一个登录接口,房间 API 也要跟着发版 |
| 连接池无法隔离 | 登录、地图、结算共用一个数据库连接池 |
| 故障无法收敛 | 一个 service 泄漏连接,所有入口一起变慢 |
| 所有权无法落地 | service 包由三个小组共同修改,评审找不到负责人 |
分层是代码组织手段,不是部署边界。部署单元的第一属性是“谁能独立发布、独立扩容、独立失败”,而不是“代码在哪一层”。
反模式二:切得过细的运维税
另一个极端是把每个模块都拆成独立服务。单元数量不是免费的,每增加一个单元,就增加一份部署流水线、一份监控看板、一次联调等待和一条值班路径:
| 单元数 | 典型新增成本(示例值) | 适用条件 |
|---|---|---|
| 2–5 | 一次发布约 15 分钟,看板 3 块 | 小团队、功能仍在快速迭代 |
| 6–12 | 发布编排、契约测试、跨单元追链路 | 有专职平台或 SRE |
| 13–30 | 服务目录、容量模型、依赖治理 | 多小组独立 ownership |
| 30 以上 | 组织问题开始大于技术问题 | 平台团队成熟且预算充足 |
判断标准不是“能不能拆”,而是“拆完之后,发布次数和故障恢复时间是否真的改善”。如果拆分只带来更多 YAML 和更多跨服务超时,它只是把单体的问题换成了分布式的问题。
把切分写成可校验的模型
边界写在文档里,三个月后一定失效。下面把部署单元模型写成 internal/deploy,让循环依赖、状态归属冲突和有状态共享库变成构建期能报出来的问题:
// file: internal/deploy/deploy.go
// tier: L1
// verify: go test ./internal/deploy/
package deploy
import (
"fmt"
"sort"
"strings"
)
type ScaleMode string
const (
Stateless ScaleMode = "stateless"
Partitioned ScaleMode = "partitioned"
Singleton ScaleMode = "singleton"
)
const (
RuleBlankName = "blank_name"
RuleDuplicateUnit = "duplicate_unit"
RuleUnknownUnit = "unknown_unit"
RuleSelfDependency = "self_dependency"
RuleCycle = "cycle"
RuleBadScale = "bad_scale"
RuleDuplicateState = "duplicate_state_owner"
RuleUnreachableState = "unreachable_state"
RuleSharedLib = "shared_stateful_library"
RuleUnknownLibUser = "unknown_library_user"
)
// Unit is one deployable artifact. Owns and Reads are state keys, not table
// names: a key may be a cache namespace, a partition range or a lease record.
type Unit struct {
Name string
Owner string
Scale ScaleMode
Owns []string
Reads []string
DependsOn []string
Release string
}
// Library is shared code. Holds lists state keys kept inside the library; a
// library that holds state is a deployable unit in disguise.
type Library struct {
Name string
Holds []string
UsedBy []string
}
type Catalog struct {
Units []Unit
Libraries []Library
}
type Issue struct {
Unit string
Rule string
Detail string
}
func (i Issue) String() string {
return fmt.Sprintf("%s: %s: %s", i.Unit, i.Rule, i.Detail)
}
// Validate checks the split as a whole. Every rule answers "can this unit be
// released, scaled or failed on its own?" rather than "is the code tidy?".
func Validate(c Catalog) []Issue {
var issues []Issue
byName := make(map[string]*Unit, len(c.Units))
order := make([]string, 0, len(c.Units))
for i := range c.Units {
u := &c.Units[i]
name := strings.TrimSpace(u.Name)
if name == "" {
issues = append(issues, Issue{"<unnamed>", RuleBlankName,
fmt.Sprintf("unit index %d", i)})
continue
}
if byName[name] != nil {
issues = append(issues, Issue{name, RuleDuplicateUnit, "duplicate name"})
continue
}
if u.Scale != Stateless && u.Scale != Partitioned && u.Scale != Singleton {
issues = append(issues, Issue{name, RuleBadScale, string(u.Scale)})
}
byName[name] = u
order = append(order, name)
}
sort.Strings(order)
deps := make(map[string][]string, len(order))
for _, name := range order {
seen := make(map[string]bool, len(byName[name].DependsOn))
for _, raw := range byName[name].DependsOn {
dep := strings.TrimSpace(raw)
switch {
case dep == name:
issues = append(issues, Issue{name, RuleSelfDependency, dep})
case byName[dep] == nil:
issues = append(issues, Issue{name, RuleUnknownUnit, dep})
case !seen[dep]:
seen[dep] = true
deps[name] = append(deps[name], dep)
}
}
sort.Strings(deps[name])
}
if cycle := findCycle(order, deps); cycle != nil {
issues = append(issues, Issue{cycle[0], RuleCycle, strings.Join(cycle, "->")})
}
owners := make(map[string][]string)
for _, name := range order {
seen := make(map[string]bool, len(byName[name].Owns))
for _, raw := range byName[name].Owns {
key := strings.TrimSpace(raw)
if key == "" || seen[key] {
continue
}
seen[key] = true
owners[key] = append(owners[key], name)
}
}
keys := make([]string, 0, len(owners))
for key := range owners {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if len(owners[key]) > 1 {
sort.Strings(owners[key])
issues = append(issues, Issue{strings.Join(owners[key], ","), RuleDuplicateState, key})
}
}
for _, name := range order {
reach := reachable(name, deps)
seen := make(map[string]bool, len(byName[name].Reads))
for _, raw := range byName[name].Reads {
key := strings.TrimSpace(raw)
if key == "" || seen[key] {
continue
}
seen[key] = true
ok := false
for _, owner := range owners[key] {
if owner == name || reach[owner] {
ok = true
break
}
}
if !ok {
issues = append(issues, Issue{name, RuleUnreachableState, key})
}
}
}
for _, lib := range c.Libraries {
users := make(map[string]bool, len(lib.UsedBy))
for _, raw := range lib.UsedBy {
user := strings.TrimSpace(raw)
if user == "" {
continue
}
if byName[user] == nil {
issues = append(issues, Issue{"library:" + lib.Name, RuleUnknownLibUser, user})
continue
}
users[user] = true
}
if len(lib.Holds) == 0 || len(users) < 2 {
continue
}
names := make([]string, 0, len(users))
for user := range users {
names = append(names, user)
}
sort.Strings(names)
issues = append(issues, Issue{strings.Join(names, ","), RuleSharedLib, lib.Name})
}
sort.Slice(issues, func(i, j int) bool {
if issues[i].Unit != issues[j].Unit {
return issues[i].Unit < issues[j].Unit
}
if issues[i].Rule != issues[j].Rule {
return issues[i].Rule < issues[j].Rule
}
return issues[i].Detail < issues[j].Detail
})
return issues
}
func reachable(start string, deps map[string][]string) map[string]bool {
seen := make(map[string]bool)
var walk func(string)
walk = func(name string) {
for _, dep := range deps[name] {
if !seen[dep] {
seen[dep] = true
walk(dep)
}
}
}
walk(start)
return seen
}
// findCycle returns a closed path such as room->settlement->room. The order
// argument is sorted so the reported cycle does not depend on map iteration.
func findCycle(order []string, deps map[string][]string) []string {
const white, gray, black = 0, 1, 2
color := make(map[string]int, len(order))
var stack, cycle []string
var dfs func(string) bool
dfs = func(name string) bool {
color[name] = gray
stack = append(stack, name)
for _, next := range deps[name] {
switch color[next] {
case gray:
for i, node := range stack {
if node == next {
cycle = append(cycle, stack[i:]...)
break
}
}
cycle = append(cycle, next)
return true
case white:
if dfs(next) {
return true
}
}
}
stack = stack[:len(stack)-1]
color[name] = black
return false
}
for _, name := range order {
if color[name] == white && dfs(name) {
return cycle
}
}
return nil
}
这段代码把“部署单元”压缩成四个可检查的事实:
Owns表示单元独占的状态键。两个单元声明同一个键,duplicate_state_owner直接报错;Reads表示只读依赖。读一个状态键之前,它的 owner 必须在该单元的依赖闭包里;DependsOn构成发布与故障的传播方向,闭合成环时cycle报错;Libraries里有状态的库被两个以上单元引用时,shared_stateful_library报错,因为它已经是一个隐形的共享数据库。
规则表如下:
| 规则 | 检查什么 | 对应事故 |
|---|---|---|
cycle | 单元依赖闭合成环 | 拆开后互相等待发布 |
duplicate_state_owner | 同一状态键有两个 owner | 双写、重复结算 |
unreachable_state | 读的状态不在依赖闭包里 | 绕过接口直连别人的库 |
shared_stateful_library | 有状态库被多个单元引用 | 共享库偷偷变成第二个数据库 |
unknown_unit / self_dependency | 模型自身完整性 | 拼错名字静默通过 |
本机验证
internal/deploy 有 5 个测试函数、25 个测试运行,覆盖合法目录、名称与依赖错误、两节点与三节点环、状态归属、只读可达性和共享库。实际运行结果:
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/deploy
$ go vet ./internal/deploy/
$ go test -count=1 ./internal/deploy/
ok gsarchlab/internal/deploy 0.455s
gofmt -l 与 go vet 均无输出,退出码为 0,耗时随机器变化。
常见错误与边界
第一,把目录当单元。internal/room 和 internal/economy 如果共享同一个数据库连接和同一张表,它们仍然是一个单元。
第二,只检查 import,不检查运行时回调。单元 A 把函数指针注册给单元 B,编译期看不到依赖,运行期却形成了反向调用。
第三,拆完不删旧入口。旧路径留在那里,半年后新人会继续用,边界等于没画。
第四,把“独立部署”理解成“独立仓库”。多仓库不会自动产生边界,反而让一次跨单元修改需要多个 PR,评审更难看到全貌。
第五,用部署单元解决容量问题。如果瓶颈是单机 CPU,先做纵向优化或无状态水平扩展;拆服务只会把一次本地调用变成两次网络调用。第六,忽略数据迁移成本:worldmap 按月发布不是保守,而是因为区块迁移需要停机窗口和回滚预案;把一个有状态单元当成无状态单元发布,事故一定发生在第一次滚动升级。
小结
部署单元的切分依据是变更频率、伸缩特性、故障隔离、团队所有权和数据边界,不是代码行数或目录层次。三款产品给出了三种答案:竞技按房间切,SLG 按地图与功能切,休闲按整体切。切完之后,把单元、状态键、依赖和共享库写成模型,让循环依赖和状态归属冲突在 CI 里失败,边界才不会在第一次赶需求时被绕过。
下一节进入有状态服务的内部:状态到底放在哪、租约如何防止双主,以及重启、迁移和缩容时怎样保证数据安全。
阅读导航:上一节:10.3 玩家创造内容的服务端信任模型 · 下一节:11.2 有状态服务的边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。