《游戏服务端编程实践》19.1 服务划分与部署拓扑

从玩法交互而不是在线人数出发,拆解登录、网关、世界、战斗、社交、排行榜与后台的职责,比较进程、容器与可用区的部署差异,讨论容量规划和故障域,并给出带环检测、缺失依赖与孤立告警的 Go 拓扑校验实现。文章还分析跨区调用拖垮全服的失败场景,说明如何把拓扑校验接入发布前检查。

一、服务划分的起点是玩法,不是人数

很多团队第一次拆服务的理由是“在线人数涨了”。人数只是结果,真正决定边界的是一次玩法交互需要哪些状态一起变更。一次战斗要读角色属性、背包、技能冷却和地图格子,这些状态如果分属四个服务,每个技能释放就要跨四次网络;而登录只涉及账号和角色列表,和战斗状态几乎没有交集。按交互拆,边界才稳定;按人数拆,过两个月就要重拆一次。

可以用一条简单的判断链:先列出核心玩法循环,再标出每次循环读写的数据,最后把“必须同时变更”的数据收进同一个服务。跨服务的数据只保留标识和只读快照,写入必须回到数据所有者。这条规则比任何架构图都更能防止后期出现“两个服务都能改同一张表”的局面。

玩法交互主要读写的数据天然归属
登录、选服、创角账号、角色列表、封禁状态登录服务
移动、视野、场景事件地图、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 通过;测试覆盖合法拓扑、顺序稳定性、环检测、缺失依赖、孤立告警和重复服务。需要强调的是,这个包只校验拓扑的静态结构,不校验端口冲突、资源配额和实际网络连通性,那些属于部署阶段的检查。

小结

服务划分从玩法交互和数据所有权出发,而不是从在线人数出发。登录、网关、世界、战斗、社交、排行榜和后台各自要有明确的“不负责”清单,边界才能稳定。部署时要区分无状态与有状态服务的扩容、升级和故障行为,数据库位置和跨区调用都要有延迟预算与降级路径。把拓扑写成可校验的数据,在发布前检查重复、缺失和循环依赖,并对孤立服务发出告警,可以让架构评审从画图变成可执行的检查。

常见错误与边界

  • 按在线人数拆服,而不是按玩法交互和数据所有权拆,导致每次版本更新都要重新划分边界。
  • 拓扑里只画业务服务,不画数据库、缓存和消息队列,上线后才发现关键依赖没有容量规划。
  • 跨区调用没有超时、重试上限和降级路径,高峰期把连接池和上游服务一起拖垮。
  • 把有状态服务当成无状态服务滚动重启,玩家状态丢失或分片被重复接管。
  • 网关按实例数一次性替换,忽视长连接重连风暴和会话恢复能力。
  • 孤立服务不告警,废弃模块长期留在拓扑里,占用端口和运维注意力。
  • 认为拓扑校验通过就等于可以部署,忽略端口、配额、网络策略和真实压测。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist