一、服务划分的起点是玩法,不是人数
很多团队第一次拆服务的理由是“在线人数涨了”。人数只是结果,真正决定边界的是一次玩法交互需要哪些状态一起变更。一次战斗要读角色属性、背包、技能冷却和地图格子,这些状态如果分属四个服务,每个技能释放就要跨四次网络;而登录只涉及账号和角色列表,和战斗状态几乎没有交集。按交互拆,边界才稳定;按人数拆,过两个月就要重拆一次。
可以用一条简单的判断链:先列出核心玩法循环,再标出每次循环读写的数据,最后把“必须同时变更”的数据收进同一个服务。跨服务的数据只保留标识和只读快照,写入必须回到数据所有者。这条规则比任何架构图都更能防止后期出现“两个服务都能改同一张表”的局面。
| 玩法交互 | 主要读写的数据 | 天然归属 |
|---|---|---|
| 登录、选服、创角 | 账号、角色列表、封禁状态 | 登录服务 |
| 移动、视野、场景事件 | 地图、AOI、实体位置 | 世界服务 |
| 技能结算、伤害、掉落 | 战斗实体、冷却、随机种子 | 战斗服务 |
| 好友、公会、私聊 | 关系链、频道成员 | 社交服务 |
| 榜单刷新、名次查询 | 分数、赛季、分页索引 | 排行榜服务 |
| 补偿、封号、公告 | 运营单据、审计日志 | 后台服务 |
这张表不是最终架构,而是一次“数据所有权”的对齐会议。凡是同一行里的数据被拆到两个服务,后面一定会出现同步延迟和补偿逻辑。
二、七类服务的职责与不变量
游戏服务端常见的七类服务并不是标准答案,但它们覆盖了大多数项目从上线到运营的完整路径。划分时不要只写“负责什么”,还要写“不允许做什么”,后者才是边界。
| 服务 | 核心职责 | 明确不负责 | 状态形态 |
|---|---|---|---|
| 登录 | 认证、选服、创角、封禁校验 | 战斗与场景逻辑 | 无状态,读账号库 |
| 网关 | 连接、加密、限流、消息路由 | 业务规则与持久化 | 无状态,持有连接 |
| 世界 | 地图、AOI、实体、场景事件 | 跨服匹配与结算 | 有状态,按地图分片 |
| 战斗 | 技能、伤害、掉落、战斗回放 | 长周期角色数据 | 有状态,短生命周期 |
| 社交 | 好友、公会、聊天、邮件 | 角色数值成长 | 半状态,关系落库 |
| 排行榜 | 分数写入、名次查询、赛季 | 角色属性权威值 | 有状态,可重建 |
| 后台 | 配置、补偿、封禁、审计 | 实时战斗路径 | 无状态,操作落库 |
“明确不负责”这一列比“核心职责”更重要。登录服务一旦开始判断角色能不能进某个副本,它就和世界服务共享了规则;排行榜一旦接受客户端直接上报分数,它就成了第二个数值权威。边界不是靠文档维持的,而是靠“这个服务没有权限做这件事”维持的。
三、部署拓扑:进程、容器与可用区
同样的服务划分,可以跑在完全不同的部署单元里。部署单元越大,调用越便宜,故障域也越大;部署单元越小,隔离越好,网络和运维成本越高。选择时要看服务的状态形态和故障影响,而不是看哪个方案更“云原生”。
| 部署方式 | 隔离级别 | 适合的服务 | 主要代价 |
|---|---|---|---|
| 同进程 goroutine | 无进程隔离 | 早期单体、工具模块 | 一个 panic 拖垮全进程 |
| 同机多进程 | 进程级 | 世界与战斗分片 | 端口、内存、升级协调 |
| 容器同可用区 | 容器级 | 网关、登录、社交 | 网络一跳、镜像体积 |
| 跨可用区 | 机房级 | 账号库、排行榜主库 | 延迟上升、跨区流量 |
| 跨地域 | 地域级 | 灾备、全球同服分区 | 一致性复杂、成本高 |
无状态服务可以随时横向扩容,滚动升级也简单:新实例先就绪,再摘掉旧实例。有状态服务不能这么干。世界服务的每个分片持有内存中的实体状态,扩容不是加一个副本,而是把一部分地图或玩家迁走;升级要先把玩家引导到其他分片,再保存快照,最后替换进程。把有状态服务当无状态服务滚动重启,是上线事故的常见来源。
网关比较特殊:它本身不保存业务状态,但持有大量长连接。滚动升级时连接会断,客户端要能重连并恢复会话。因此网关的“无状态”只对业务数据成立,对连接生命周期不成立。发布策略要按连接数分批,而不是按实例数一次性替换。
四、容量规划:单服承载与跨服玩法
“单服能扛多少人”没有通用答案,它取决于消息频率、单条消息大小、状态更新范围和 CPU 预算。容量规划要从可观测指标反推,而不是拍一个数字。下面这些指标是示例口径,实际阈值必须用压测和线上数据校准。
| 指标 | 观测方式 | 规划用途 |
|---|---|---|
| 单连接消息率 | 网关注入计数 | 估算网关 CPU 与带宽 |
| 单条消息字节数 | 协议统计直方图 | 估算出口带宽 |
| AOI 内实体数 | 世界服务采样 | 估算广播放大倍数 |
| 单次战斗 CPU 时间 | 压测火焰图 | 估算单机战斗并发 |
| 数据库 QPS 与 p99 | 连接池与慢查询 | 判断是否要加缓存或分库 |
一个常用的估算方式是:单服在线人数乘以人均消息率,得到总消息率;再乘以平均广播人数,得到实际下发量。示例值:一万在线、人均每秒 2 条、平均每条广播给 20 人,那么每秒下发约 40 万条消息。这个量级下,网关的序列化次数和出口带宽通常比业务逻辑更早成为瓶颈。
跨服玩法要单独规划,不能直接复用单服容量。跨服匹配、跨服战场和全服排行榜的流量峰值往往集中在一个时间窗,而且调用链更长。要为跨服链路设定明确的延迟预算,例如匹配请求 200 毫秒内返回、战场进场 3 秒内完成,超过预算就降级到本服玩法,而不是让请求无限排队。
五、故障域与数据库位置
故障域的第一个问题是数据库放在哪。数据库和业务服务同可用区,延迟最低,但机房故障会一起不可用;数据库跨可用区做主备,业务服务仍可能因为跨区延迟而超时;数据库跨地域,灾备能力最强,但写入延迟和一致性处理会显著复杂。
| 调用类型 | 建议位置 | 延迟预算示例 | 超时后的行为 |
|---|---|---|---|
| 角色数据读写 | 同可用区主库 | 20 毫秒 | 快速失败,提示重试 |
| 排行榜写入 | 同可用区,异步刷盘 | 50 毫秒 | 进本地队列,批量重试 |
| 跨服匹配 | 同地域跨可用区 | 200 毫秒 | 降级为本服匹配 |
| 全局公告拉取 | 跨地域只读副本 | 1 秒 | 使用本地缓存 |
延迟预算必须写进调用代码,而不是只写在设计文档里。每一次跨区调用都要有超时、重试上限和降级路径。没有预算的跨区调用,平时看起来只是“稍微慢一点”,高峰期会变成连接池耗尽和级联超时。
故障域还要考虑“共享中间件”。两个服务共用一套 Redis,看起来各自独立,实际上 Redis 故障会同时打穿它们。关键路径上的缓存要么按服务隔离实例,要么在缓存不可用时能退化为直接读库,并且限流保护数据库。
六、失败场景:一次跨区调用拖垮全服
某款游戏把战斗服务部署在 B 可用区,世界服务的主库在 A 可用区。战斗每次结算伤害都要同步调用世界服务校验角色属性,设计时认为“一次查询很快”。上线后单个战斗房间没有问题,但跨服战场开启时,几万个战斗实体同时发起校验,跨区链路的 p99 从 30 毫秒涨到 800 毫秒。
更糟的是战斗服务用的是无上限的同步调用。请求堆积占满 goroutine 和连接池,战斗结算开始超时,玩家看到技能放不出去;世界服务为了处理校验请求又占用了场景 tick 的 CPU,导致正常移动也卡顿。最后不是某一个服务挂了,而是两个可用区一起进入雪崩。
修复分三步。第一,把角色属性在进入战斗时做成只读快照,战斗期间不再跨区查询;第二,所有跨区调用加 50 毫秒超时和熔断,失败时使用快照并记录审计;第三,把战斗服务和世界服务调整到同一可用区,跨区只保留异步结算和日志上报。这个案例的教训不是“不要跨区”,而是“跨区调用必须有预算、有快照、有降级”。
七、Go 实现:把拓扑写成可校验的数据
拓扑如果只存在于架构图里,就无法在发布前检查。下面的 topology.go 把服务、依赖和部署区域写成结构体,Validate 负责三件事:拒绝重复服务和缺失依赖,用 Kahn 算法检测循环依赖并给出依赖优先的启动顺序,对既没有依赖也没有被依赖的服务发出孤立告警。
孤立告警不是错误。一个只被定时任务调用的对账服务可能确实没有入边,但它应该被显式标注,而不是悄悄留在拓扑里。把“需要人工确认”的节点变成告警,比把它当成合法节点更安全。
// file: ch19/topology/topology.go
// tier: L1
// verify: go test ./ch19/topology/
package topology
import (
"errors"
"fmt"
"sort"
"strings"
)
var (
ErrEmptyTopology = errors.New("topology: empty topology")
ErrEmptyName = errors.New("topology: empty service name")
ErrDuplicateService = errors.New("topology: duplicate service")
ErrUnknownDependency = errors.New("topology: unknown dependency")
ErrCycle = errors.New("topology: dependency cycle")
)
// Kind 区分服务的部署形态:无状态可以随便扩,有状态要绑定分片和存储。
type Kind string
const (
KindGateway Kind = "gateway"
KindStateless Kind = "stateless"
KindStateful Kind = "stateful"
KindDatabase Kind = "database"
)
// Service 是拓扑里的一个节点。DependsOn 表示“启动和运行都需要对方”,
// 而不是“偶尔调用一次”,否则拓扑会被无关的边连成一团。
type Service struct {
Name string
Kind Kind
Zone string
DependsOn []string
}
type Topology struct {
Services []Service
}
// Report 是校验通过后的产物。Order 是依赖优先的启动顺序,
// Warnings 不阻止部署,但需要在评审时逐条确认。
type Report struct {
Order []string
Warnings []string
}
// Validate 检查重复服务、缺失依赖和循环依赖,并给出启动顺序与孤立服务告警。
func Validate(t Topology) (*Report, error) {
if len(t.Services) == 0 {
return nil, ErrEmptyTopology
}
byName := make(map[string]Service, len(t.Services))
for _, s := range t.Services {
name := strings.TrimSpace(s.Name)
if name == "" {
return nil, ErrEmptyName
}
if _, ok := byName[name]; ok {
return nil, fmt.Errorf("%w: %s", ErrDuplicateService, name)
}
byName[name] = s
}
indegree := make(map[string]int, len(byName))
dependents := make(map[string][]string, len(byName))
for name, s := range byName {
for _, dep := range s.DependsOn {
if _, ok := byName[dep]; !ok {
return nil, fmt.Errorf("%w: %s -> %s", ErrUnknownDependency, name, dep)
}
indegree[name]++
dependents[dep] = append(dependents[dep], name)
}
}
order, err := topoSort(byName, indegree, dependents)
if err != nil {
return nil, err
}
report := &Report{Order: order}
for _, name := range order {
if len(byName[name].DependsOn) == 0 && len(dependents[name]) == 0 {
report.Warnings = append(report.Warnings,
fmt.Sprintf("isolated service: %s", name))
}
}
return report, nil
}
// topoSort 用 Kahn 算法,并始终对候选队列排序,保证同一份拓扑每次得到相同顺序。
func topoSort(byName map[string]Service, indegree map[string]int, dependents map[string][]string) ([]string, error) {
ready := make([]string, 0, len(byName))
for name := range byName {
if indegree[name] == 0 {
ready = append(ready, name)
}
}
sort.Strings(ready)
order := make([]string, 0, len(byName))
for len(ready) > 0 {
name := ready[0]
ready = ready[1:]
order = append(order, name)
next := append([]string(nil), dependents[name]...)
sort.Strings(next)
for _, dep := range next {
indegree[dep]--
if indegree[dep] == 0 {
ready = append(ready, dep)
}
}
sort.Strings(ready)
}
if len(order) != len(byName) {
stuck := make([]string, 0)
for name := range byName {
if indegree[name] > 0 {
stuck = append(stuck, name)
}
}
sort.Strings(stuck)
return nil, fmt.Errorf("%w: %s", ErrCycle, strings.Join(stuck, ", "))
}
return order, nil
}
// Index 把启动顺序转成“名字 -> 下标”,便于部署脚本判断先后。
func (r *Report) Index() map[string]int {
idx := make(map[string]int, len(r.Order))
for i, name := range r.Order {
idx[name] = i
}
return idx
}
// HasWarning 用于部署前检查某条告警是否已经被确认。
func (r *Report) HasWarning(substr string) bool {
for _, w := range r.Warnings {
if strings.Contains(w, substr) {
return true
}
}
return false
}
topoSort 每次都排序候选队列,所以同一份拓扑在多次构建、多个环境中得到相同顺序。这一点对部署脚本很重要:如果顺序随机,滚动发布时每次先启动的服务都不同,排查问题会非常困难。循环依赖的错误信息会列出仍然有入边的节点,方便直接定位环上的服务。
Index 把顺序转换成下标,部署脚本可以据此判断“数据库是否已经先于世界服务启动”。HasWarning 则让发布流水线可以按服务名查询告警,决定是阻断还是要求人工确认。
八、测试覆盖哪些边界
测试文件覆盖了六类行为,都是拓扑评审时真实会遇到的输入:
- 合法拓扑没有告警,且数据库、缓存、登录、世界、战斗、网关、排行榜的顺序满足依赖。
- 同一份拓扑连续校验二十次,启动顺序完全一致。
- 三节点环返回
ErrCycle,错误信息包含环上的服务名。 - 依赖一个不存在的服务返回
ErrUnknownDependency,并指出调用方和被调用方。 - 没有任何边的服务产生孤立告警,正常参与依赖的服务不产生告警。
- 重复服务名、空拓扑和空白服务名分别返回对应的哨兵错误。
这些测试不依赖真实网络和数据库,可以在 CI 里快速运行。拓扑校验本身也应该作为发布前的一步,而不是等到部署失败才发现依赖写错。
九、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch19/topology && go vet ./ch19/topology/ && go test ./ch19/topology/ -count=1
ok gspractice/ch19/topology 0.297s
gofmt -l 没有输出说明格式一致;go vet 通过;测试覆盖合法拓扑、顺序稳定性、环检测、缺失依赖、孤立告警和重复服务。需要强调的是,这个包只校验拓扑的静态结构,不校验端口冲突、资源配额和实际网络连通性,那些属于部署阶段的检查。
小结
服务划分从玩法交互和数据所有权出发,而不是从在线人数出发。登录、网关、世界、战斗、社交、排行榜和后台各自要有明确的“不负责”清单,边界才能稳定。部署时要区分无状态与有状态服务的扩容、升级和故障行为,数据库位置和跨区调用都要有延迟预算与降级路径。把拓扑写成可校验的数据,在发布前检查重复、缺失和循环依赖,并对孤立服务发出告警,可以让架构评审从画图变成可执行的检查。
常见错误与边界
- 按在线人数拆服,而不是按玩法交互和数据所有权拆,导致每次版本更新都要重新划分边界。
- 拓扑里只画业务服务,不画数据库、缓存和消息队列,上线后才发现关键依赖没有容量规划。
- 跨区调用没有超时、重试上限和降级路径,高峰期把连接池和上游服务一起拖垮。
- 把有状态服务当成无状态服务滚动重启,玩家状态丢失或分片被重复接管。
- 网关按实例数一次性替换,忽视长连接重连风暴和会话恢复能力。
- 孤立服务不告警,废弃模块长期留在拓扑里,占用端口和运维注意力。
- 认为拓扑校验通过就等于可以部署,忽略端口、配额、网络策略和真实压测。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。