一、拆分不是目的,隔离故障才是
很多团队把“微服务”当成规模增长的默认答案,先画出一排服务框,再回头找理由。结果通常是:服务数量上去了,发布次数没变,故障半径反而变大。拆分的收益只有三类:让变化快的部分独立发布,让负载特征不同的部分独立伸缩,让故障被限制在更小的进程与数据范围内。如果一项拆分不能明确落到这三类收益上,它大概率只是把一次函数调用改成了两次网络调用。
游戏服务端还有自己的约束:战斗帧同步要求稳定延迟,登录和支付要求强一致,聊天和排行榜可以最终一致。把这些负载放进同一个进程,会互相拖累;但把它们拆成十几个服务,跨服务调用又会把延迟和故障点叠加。所以第一件事不是“拆几个服务”,而是先列出业务能力、数据所有权和一致性要求,再决定哪些边界值得变成进程边界。
二、四种拆分维度
按业务域拆是最常见的方式:账号、角色、背包、交易、战斗、社交各自成域。按玩法拆适合品类差异大的项目,例如大厅、副本、竞技场、活动各有独立生命周期。按读写拆是把高频只读查询与低频写入分开,典型是排行榜、配置、好友列表。按团队拆则是组织约束的映射,谁维护谁负责,但绝不能只按团队人数切分。
| 维度 | 适合的场景 | 主要收益 | 主要风险 |
|---|---|---|---|
| 业务域 | 领域模型清晰、数据归属明确 | 边界稳定,便于长期演进 | 领域划分错误会固化 |
| 玩法 | 玩法生命周期差异大 | 独立上线、独立扩容 | 公共能力重复建设 |
| 读写 | 读多写少、查询模式差异大 | 读路径可独立伸缩 | 数据同步延迟与一致性 |
| 团队 | 组织职责已经稳定 | 责任清晰、沟通成本低 | 按人头切分导致边界破碎 |
四种维度可以组合,但一次只应该引入一种新的边界。比如先把背包从角色服务中按业务域拆出来,稳定后再把背包的只读查询按读写维度拆开。一次同时按玩法和团队切,出了问题很难判断是哪条边界错了。
三、先不拆的判据
拆分有明确成本:跨进程调用、分布式事务、可观测性、联调、版本兼容、故障排查。小团队最常见的事故不是“单体撑不住”,而是“拆完以后没人能说清一次请求经过了哪些服务”。因此需要一套“先不拆”的判据。
| 判据 | 先不拆 | 可以考虑拆 |
|---|---|---|
| 变更频率 | 两边总是一起改 | 一边每周变、一边每季度变 |
| 伸缩差异 | CPU、内存、IO 特征接近 | 一边需要 10 倍副本,另一边不需要 |
| 故障隔离 | 一边故障另一边也无法工作 | 一边故障可以降级 |
| 数据所有权 | 同一份数据需要双方原子更新 | 数据归属单一,跨服务只读 |
| 认知负担 | 团队不足 5 人、部署链路已复杂 | 边界能被新人用一句话说清 |
最后一条经常被忽略。微服务的边界首先是认知边界:如果团队成员无法在不看代码的情况下说清“这个服务拥有什么数据、接受什么请求、失败时谁兜底”,那这条边界还没有准备好。文档可以解释边界,但不能替代边界本身。
四、三种反模式:共享数据库、共享 key、跨服务 JOIN
第一种是共享数据库。两个服务直连同一张表,各自执行 INSERT/UPDATE,表面上看是“复用”,实际上是把数据库当成了隐式接口。任何一方的表结构变更、事务边界、索引调整都会影响另一方,而且没有人能说清某一行的最终状态由谁负责。替代方案是明确单一所有者:写操作只走所有者,其他服务通过 RPC 或订阅变更事件获得数据副本。
第二种是共享缓存 key。两个服务往同一个 Redis key 写不同结构的数据,或者一个服务把另一个服务的 key 当成自己的私有存储。缓存没有 schema,冲突往往在线上才暴露,表现为字段丢失、类型错误或 TTL 互相覆盖。替代方案是 key 带服务前缀与版本,且只有所有者能写;其他服务只读或订阅失效消息。
第三种是跨服务 JOIN。查询层为了省事,把两个服务的表放在同一个 SQL 里 JOIN,服务边界瞬间失效:数据库连接、表名、字段、分库分表规则全部耦合。替代方案是查询服务分别调用两个接口,在内存中按主键拼装;或者由数据所有者提供面向查询的宽表/物化视图,并明确更新延迟。
| 反模式 | 识别信号 | 直接后果 | 替代方案 |
|---|---|---|---|
| 共享数据库 | 多个服务配置里出现同一 DSN 和表名 | 表结构变更互相阻塞 | 单一所有者 + 事件同步 |
| 共享缓存 key | 同一 key 被多个服务读写 | TTL 与结构互相覆盖 | 服务前缀 + 只写归属 |
| 跨服务 JOIN | SQL 中出现其他服务的表 | 分库分表无法推进 | 接口拼装或宽表 |
五、失败场景:两个服务同时写 player_item
运营活动上线后,背包服务在活动发奖时直接更新 player_item,战斗服务在结算掉落时也直接更新同一张表。平时看不出问题,直到一次双倍掉落活动:两个服务各自开启事务,背包服务先扣格子容量,战斗服务后写入新道具,双方都认为自己成功。结果玩家道具总数超过上限,日志里两个服务都显示“更新 1 行”。
排查时更难的是责任归属:背包服务说“我写入前查过容量”,战斗服务说“我只负责加道具”。数据库没有约束能表达“容量由背包服务负责”,因为约束跨越了服务边界。修复方式不是加一把数据库锁,而是把 player_item 的写权限收归背包服务:战斗服务只提交掉落结果,由背包服务校验容量并落库。活动期间的高频写入则通过批量接口合并,避免每次掉落一次 RPC。
这个案例说明,数据所有权不是文档里的声明,而是运行时可执行的约束。只要另一个服务还持有数据库写权限,边界就只是约定;约定会在赶进度时被绕过。
六、Go 实现:依赖图、环检测与资源所有权
下面的 boundary.go 把服务声明、依赖关系和资源归属放进同一张图。注册阶段拒绝重复服务与重复资源所有权,Check 用 DFS 检测环并输出完整路径,CheckAccess 在运行时约束写操作只能由所有者执行、读操作必须显式声明依赖。
// file: ch11/boundary/boundary.go
// tier: L1
// verify: go test ./ch11/boundary/
package boundary
import (
"errors"
"fmt"
"sort"
"strings"
"sync"
)
var (
ErrEmptyService = errors.New("boundary: empty service name")
ErrDuplicateService = errors.New("boundary: duplicate service")
ErrUnknownService = errors.New("boundary: unknown service")
ErrSelfDependency = errors.New("boundary: service depends on itself")
ErrUnknownDependency = errors.New("boundary: unknown dependency")
ErrCycle = errors.New("boundary: dependency cycle")
ErrEmptyResource = errors.New("boundary: empty resource")
ErrResourceConflict = errors.New("boundary: resource already owned")
ErrUnknownResource = errors.New("boundary: unknown resource")
ErrNotOwner = errors.New("boundary: service does not own resource")
ErrNoReadGrant = errors.New("boundary: no read grant for resource")
)
// ResourceKind 区分表与主题,两者的所有权规则相同,但冲突时的排查路径不同。
type ResourceKind string
const (
KindTable ResourceKind = "table"
KindTopic ResourceKind = "topic"
)
type AccessMode int
const (
Read AccessMode = iota
Write
)
// Service 声明自己依赖谁、拥有哪些资源。拥有意味着只有它能写这份数据。
type Service struct {
Name string
DependsOn []string
Owns map[string]ResourceKind
}
type node struct {
dependsOn []string
owned []string
}
// Graph 保存服务依赖与资源归属,注册阶段就拒绝重复归属。
type Graph struct {
mu sync.RWMutex
nodes map[string]*node
owners map[string]string
resource map[string]ResourceKind
}
func NewGraph() *Graph {
return &Graph{
nodes: make(map[string]*node),
owners: make(map[string]string),
resource: make(map[string]ResourceKind),
}
}
// Register 注册服务,任何资源只能有一个所有者。
func (g *Graph) Register(s Service) error {
name := strings.TrimSpace(s.Name)
if name == "" {
return ErrEmptyService
}
owned := make([]string, 0, len(s.Owns))
for res, kind := range s.Owns {
if strings.TrimSpace(res) == "" {
return ErrEmptyResource
}
if kind != KindTable && kind != KindTopic {
return fmt.Errorf("boundary: invalid resource kind %q", kind)
}
owned = append(owned, res)
}
sort.Strings(owned)
deps := append([]string(nil), s.DependsOn...)
sort.Strings(deps)
g.mu.Lock()
defer g.mu.Unlock()
if _, ok := g.nodes[name]; ok {
return fmt.Errorf("%w: %s", ErrDuplicateService, name)
}
for _, res := range owned {
if owner, ok := g.owners[res]; ok {
return fmt.Errorf("%w: %s owned by %s", ErrResourceConflict, res, owner)
}
}
g.nodes[name] = &node{dependsOn: deps, owned: owned}
for _, res := range owned {
g.owners[res] = name
g.resource[res] = s.Owns[res]
}
return nil
}
// Check 校验依赖存在且无环。环的报错会带上完整路径,便于定位拆分方案。
func (g *Graph) Check() error {
g.mu.RLock()
defer g.mu.RUnlock()
names := make([]string, 0, len(g.nodes))
for name := range g.nodes {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
for _, dep := range g.nodes[name].dependsOn {
if dep == name {
return fmt.Errorf("%w: %s", ErrSelfDependency, name)
}
if _, ok := g.nodes[dep]; !ok {
return fmt.Errorf("%w: %s -> %s", ErrUnknownDependency, name, dep)
}
}
}
state := make(map[string]int, len(g.nodes))
var stack []string
var visit func(string) error
visit = func(name string) error {
switch state[name] {
case 1:
start := 0
for i, n := range stack {
if n == name {
start = i
break
}
}
path := append(append([]string(nil), stack[start:]...), name)
return fmt.Errorf("%w: %s", ErrCycle, strings.Join(path, " -> "))
case 2:
return nil
}
state[name] = 1
stack = append(stack, name)
for _, dep := range g.nodes[name].dependsOn {
if err := visit(dep); err != nil {
return err
}
}
stack = stack[:len(stack)-1]
state[name] = 2
return nil
}
for _, name := range names {
if state[name] == 0 {
if err := visit(name); err != nil {
return err
}
}
}
return nil
}
// Owner 返回资源的所有者,第二返回值为 false 表示资源未注册。
func (g *Graph) Owner(resource string) (string, bool) {
g.mu.RLock()
defer g.mu.RUnlock()
owner, ok := g.owners[resource]
return owner, ok
}
// OwnedBy 返回服务拥有的资源快照,调用方修改不会影响图内部状态。
func (g *Graph) OwnedBy(service string) []string {
g.mu.RLock()
defer g.mu.RUnlock()
n, ok := g.nodes[service]
if !ok {
return nil
}
return append([]string(nil), n.owned...)
}
// CheckAccess 执行运行时边界检查:写只能由所有者执行,读需要显式依赖所有者。
func (g *Graph) CheckAccess(service, resource string, mode AccessMode) error {
g.mu.RLock()
defer g.mu.RUnlock()
n, ok := g.nodes[service]
if !ok {
return fmt.Errorf("%w: %s", ErrUnknownService, service)
}
owner, ok := g.owners[resource]
if !ok {
return fmt.Errorf("%w: %s", ErrUnknownResource, resource)
}
if owner == service {
return nil
}
if mode == Write {
return fmt.Errorf("%w: %s cannot write %s owned by %s", ErrNotOwner, service, resource, owner)
}
for _, dep := range n.dependsOn {
if dep == owner {
return nil
}
}
return fmt.Errorf("%w: %s cannot read %s owned by %s", ErrNoReadGrant, service, resource, owner)
}
实现上有三个取舍。第一,依赖允许前向引用:服务可以先注册,依赖的目标后注册,最终由 Check 统一校验。这样初始化代码不必严格按拓扑序书写,但必须在启动完成前调用一次 Check。第二,环检测按服务名排序后遍历,报错路径稳定可复现,便于测试和值班排查。第三,读权限要求显式依赖所有者,而不是“注册了就能读”,否则共享数据库会以另一种形式回来。
资源归属表只记录所有权,不记录字段级权限。字段级权限看起来更精细,但会让边界图迅速膨胀,而且很难在运行时强制执行。更实际的做法是:跨服务只读通过接口暴露必要字段,写操作一律回到所有者。OwnedBy 返回快照,调用方不能通过修改返回值影响图内部状态。
七、测试覆盖了哪些边界
实验包中的 boundary_test.go 覆盖了六类行为:
- 重复注册服务返回
ErrDuplicateService,空服务名返回ErrEmptyService。 - 两个服务声明同一张表返回
ErrResourceConflict,冲突信息带上已有所有者。 - 三节点环返回
ErrCycle,错误信息包含battle -> gate -> room -> battle完整路径。 - 自依赖返回
ErrSelfDependency,依赖不存在返回ErrUnknownDependency。 - 非所有者写资源返回
ErrNotOwner,未声明依赖的读返回ErrNoReadGrant。 - 未知服务与未知资源分别返回
ErrUnknownService和ErrUnknownResource。
八、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch11/boundary && go vet ./ch11/boundary/ && go test ./ch11/boundary/ -count=1
ok gspractice/ch11/boundary 0.240s
gofmt -l 无输出表示格式一致,go vet 通过,测试覆盖注册冲突、环检测和越权访问。耗时是本机一次运行的结果,只用于说明测试通过,不作为性能承诺。
小结
服务拆分的起点不是画服务框,而是确定数据所有权与故障隔离目标。业务域、玩法、读写、团队四种维度可以组合,但一次只引入一种新边界。变更频率、伸缩差异、故障隔离、数据所有权和认知负担构成拆分判据;任何一条不成立时,先不拆往往更安全。共享数据库、共享缓存 key 和跨服务 JOIN 都会把已经划出的边界重新粘回去,识别信号是“多个服务配置里出现同一份存储”。把所有权写成运行时可执行的检查,边界才不会在赶进度时被绕过。RPC 与消息总线的具体取舍,见/game-server-in-action-11-2/。
常见错误与边界
- 按技术分层拆服务,例如所有服务共用“数据访问层”,结果每次表结构变更仍要全量发布。
- 拆完仍共享数据库或缓存,边界只存在于架构图和文档里,运行时没有任何约束。
- 只检查写权限,不检查读权限,导致任意服务都能绕过接口直接读表。
- 依赖图只在启动时打印,不阻断启动;环和未知依赖要等第一次调用才暴露。
- 资源粒度切得过细,字段级所有权让图难以维护,最终被团队放弃。
- 认为“同一个团队维护”就可以共享表,组织边界不能替代数据边界。
- 把跨服务 JOIN 当成临时查询,临时方案会变成永久耦合。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。