《游戏服务端架构·品类、状态与拓扑》11.1 部署单元怎么切

《群岛纪元》把登录、地图、结算和运营写进同一个二进制,一次地图热更回滚连带停掉了登录与充值。本节给出变更频率、伸缩特性、故障隔离、团队所有权和数据边界五个切分维度,比较三款产品的部署单元表,并用 Go 实现的目录模型检查循环依赖、状态归属冲突与有状态共享库。

一次热更回滚,为什么连登录也停了

《群岛纪元》是北辰工作室的 SLG,全球同服,大地图按区块分片。上线第四个月,地图组要修一个资源点刷新错误,按惯例发布了整个 island-server。回滚时发现新版本把登录会话表的索引名改错了,登录 p99 从 90ms 涨到 6s。为了修地图,登录一起停;为了回滚登录,地图修复一起退。

值班同学在复盘会上问:“为什么地图和登录会在同一个二进制里?”这不是“要不要上微服务”的问题。同一个进程也可以有清晰的部署单元:地图分片、登录网关、异步结算各有自己的状态、伸缩曲线和发布节奏。问题在于团队从来没有把这些差异写下来,于是“能一起编译”被当成了“应该一起发布”。

这一节讨论三件事:部署单元按什么维度切、三款产品实际怎么切,以及切完之后如何用代码检查边界没有被悄悄破坏。

五个切分维度

切分不是按代码行数,也不是按目录深度。北辰工作室用五个问题判断一个候选单元是否成立:

维度要回答的问题不切开的症状
变更频率两块代码是否总是一起改?低频繁模块被高频繁发布拖着走
伸缩特性峰值时是否按同一比例扩容?为地图扩容顺带买登录机器
故障隔离一个坏了,另一个是否必须陪着?地图 OOM 把登录一起拖死
团队所有权是否由同一个小组值班和评审?出事时两个组互相等对方
数据边界是否写同一批状态键?拆开后仍共享一张表,等于没拆

这五条不是打分表,而是否决条件。任何一条长期不成立,都会在某个事故里变成发布耦合或故障扩散。反过来,五条都成立也不代表必须拆:拆分收益还要覆盖网络调用、部署和排障的新增成本。模块化单体的依赖契约见3.1 单体、分层与模块化单体 ,这里不再重复。

三款产品的部署单元表

同一个工作室,三个品类的切法完全不同。下表是 2026 年 10 月的评审快照,单元名、状态和发布节奏都是示例值,不是线上实时配置:

产品部署单元职责状态伸缩方式发布节奏
《裂隙战场》gateway连接、鉴权、会话路由会话索引按连接数每周
lobby匹配、组队、房间查询匹配队列按 QPS每周
allocator房间租约、暖池租约记录按开房速率独立
roomtick、战斗、结算触发房间内存按房间数分区每周,需排空
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 有状态服务的边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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