《游戏服务端编程实践》21.1 云原生游戏后端趋势

从容器化与 Agones 类编排出发,分析有状态游戏服的调度难点、按在线人数扩缩容、缩容迁移、冷启动预热与云服务选型边界,并用 Go 实现就绪/存活探针与先摘流后退出的状态机,附测试覆盖、失败场景与常见事故分析,帮助团队把发布从手工脚本变成可验证流程。

一、云原生解决的是发布问题

云原生经常被讲成“上 K8s”。对游戏服务端来说,这个理解太窄。容器和编排真正解决的是发布的一致性和可回滚性:镜像把运行时依赖固定下来,编排器把“启动、探针、摘流、退出”变成统一协议,发布不再依赖某台机器上的手工脚本。它不解决玩法状态放在哪,也不解决分片如何迁移。

判断一个游戏后端是否适合云原生,先看它的状态边界。登录、网关、社交、排行榜的副本可以随时替换;世界、战斗、房间这类持有内存状态的服务,可以容器化,但不能按无状态的方式滚动。把这两类服务混在同一个 Deployment 里,是很多迁移项目的第一步错。

服务类型能否直接水平扩容发布方式关键约束
登录、社交可以滚动更新会话粘性或令牌校验
网关可以,但连接会断分批替换重连风暴、会话恢复
世界、房间不可以迁移或分区替换内存状态、分片归属
战斗通常按局生命周期排空后替换结算不能丢
数据库视引擎而定主备切换/扩缩容延迟与一致性

二、编排器要理解“游戏服务器”

K8s 的 Deployment 假设副本是无差别的:任何一个 Pod 都能处理任何请求。游戏服务器往往不是这样。一个房间服只服务特定房间,一个世界分片只负责若干地图,调度器必须知道“这个实例对应哪段状态”。Agones 一类的方案在 Pod 之上增加了 GameServer 资源和 Allocated/Fleet 概念,先把实例准备好,再由分配器绑定玩家。

即便不用 Agones,也要在调度层补三件事:第一,实例启动后能报告自己负责的分片;第二,分配请求必须幂等,重复分配不能创建两个房间;第三,实例被回收前要先标记不可分配。很多自研调度器只做了第一件,结果发布时旧实例已经停止接受连接,新实例却还没注册,玩家匹配直接失败。

有状态服务的调度难点在于“副本数”不等于“容量”。加一个世界服副本,不会自动分担已有分片;必须先决定迁移哪些地图或玩家,再让新实例加载对应快照。缩容更危险:直接删 Pod 等于删内存状态。编排器只负责生命周期,不负责状态搬迁,这部分必须由游戏自己的控制面完成。

三、弹性:看在线人数,不只看 CPU

CPU 是滞后指标。玩家涌入时,网关先出现连接数上升,世界服先出现 tick 延迟,CPU 往往要等队列堆积后才升高。按 CPU 扩容,常常在玩家已经卡顿之后才动作。更合适的信号是在线人数、房间数、每 tick 耗时、消息队列积压这些业务指标,CPU 只作为兜底。

弹性策略要写清三件事:扩多少、多久评估一次、什么情况下不缩。扩容可以激进,缩容必须保守,因为缩容涉及迁移和断线。示例值:在线人数超过单实例容量的 70% 触发扩容,低于 40% 且持续十分钟才缩容;评估周期 30 秒,扩容冷却 2 分钟,缩容冷却 10 分钟。这些是示例口径,必须用压测校准。

冷启动是弹性的隐藏成本。游戏服启动要加载配置、地图、热数据,可能还要预热脚本和连接池。一个需要 90 秒才能就绪的实例,在突发流量里几乎帮不上忙。解决办法不是把探针改松,而是分层预热:先加载最小可用数据并标记 ready,再在后台加载非关键资源;同时保留一定比例的缓冲实例,避免每次都从零开始。

四、缩容:先迁移,再退出

缩容的顺序必须是“停止分配 → 迁移或结算 → 摘流 → 退出”。停止分配是控制面动作,迁移是业务动作,摘流是网络动作,退出是进程动作。任何一步提前,都会造成玩家掉线或状态丢失。尤其不能先发 SIGTERM 再想办法迁移,因为进程收到信号后生命周期已经进入倒计时。

迁移本身要考虑成本。整张地图搬迁可能涉及大量实体和定时器;按玩家迁移则要处理跨服引用和邮件补偿。实践里常见的是“先冻结新进入,再分批迁移,最后关闭分片”。迁移期间该分片仍可读,但不接受新玩家。迁移失败要有回滚路径,不能把玩家卡在中间状态。发布流程与拓扑顺序的关系,可以参考 /game-server-in-action-19-1/ 里的依赖校验。

五、云服务选型:边界比功能重要

托管数据库、消息队列和 Redis 能省掉大量运维工作,但它们的边界必须提前写清。托管数据库的自动故障切换通常有几十秒不可用,游戏是否允许这段停服?消息队列的投递语义是至少一次还是至多一次,重复消息由谁去重?Redis 持久化不是数据库事务,能不能接受故障时丢最近几秒的写入?

组件适合承担不适合承担成本关注点
托管关系库账号、订单、角色存档高频位置同步连接数、IOPS、跨区流量
消息队列异步任务、日志、补偿实时战斗指令堆积存储、重复投递
Redis会话、排行榜、热点缓存唯一权威存档内存规格、持久化选项
对象存储配置、回放、日志归档低延迟随机读写请求次数、出网流量

成本不是单价,而是“为了可用性必须多买的那一份”。跨可用区主备、双活、灾备演练都会让账单翻倍。选型时先把不可妥协的可用性写下来,再算需要几份副本,最后才比较单价。

六、失败场景:把世界服当无状态滚动

某项目把世界服和登录服放在同一个 Deployment,滚动更新策略是默认的 25% 最大不可用。一次版本发布时,编排器先启动新世界服,再逐个终止旧实例。新实例启动后没有加载任何地图分片,却因为就绪探针只检查 HTTP 端口而立刻变成 ready;流量被切过来,玩家进入后找不到自己的场景,大量报错。

同时,旧实例收到 SIGTERM 后只有 30 秒宽限期。部分实例还在写玩家存档,进程被强制杀掉,重启后出现回档。事故根因不是 K8s,而是三个假设同时错误:把有状态服当无状态、就绪探针与业务就绪脱节、终止流程没有摘流和排空。

修复按顺序做:世界服拆成独立工作负载,用分片控制器管理归属;就绪探针接入业务状态,地图未加载完不允许接流量;终止时先标记 draining,等网关停止路由,再等待进行中的请求结束,最后才退出。下面这个 Go 实现就是第三件事的最小模型。

七、Go 实现:就绪、存活与摘流状态机

cloudnative.go 把节点生命周期分成 starting、ready、draining、stopped 四个阶段。Admit 是业务入口的闸门,只有 ready 放行;BeginDrain 让就绪探针立即失败;WaitDrained 等待进行中请求归零;Stop 在还有请求时拒绝退出。存活探针在 draining 阶段仍返回 true,避免编排器提前杀进程。

// file: ch21/cloudnative/cloudnative.go
// tier: L1
// verify: go test ./ch21/cloudnative/

package cloudnative

import (
	"context"
	"errors"
	"sync"
)

var (
	ErrNotReady    = errors.New("cloudnative: node is not ready")
	ErrNotDraining = errors.New("cloudnative: node is not draining")
	ErrDraining    = errors.New("cloudnative: node is draining")
	ErrStopped     = errors.New("cloudnative: node is stopped")
	ErrBusy        = errors.New("cloudnative: node still has inflight requests")
)

// Phase 是节点在发布生命周期中的位置。摘流(draining)不是“正在退出”,
// 而是“不再接新请求,但旧请求还要跑完”,探针必须能区分这几个阶段。
type Phase string

const (
	PhaseStarting Phase = "starting"
	PhaseReady    Phase = "ready"
	PhaseDraining Phase = "draining"
	PhaseStopped  Phase = "stopped"
)

// Node 用一个互斥锁维护阶段和进行中请求数。idle 在请求数归零时关闭,
// WaitDrained 等待它即可,不需要定时轮询。
type Node struct {
	mu         sync.Mutex
	phase      Phase
	inflight   int
	idle       chan struct{}
	idleClosed bool
}

func NewNode() *Node {
	return &Node{
		phase:      PhaseStarting,
		idle:       closedChan(),
		idleClosed: true,
	}
}

func closedChan() chan struct{} {
	ch := make(chan struct{})
	close(ch)
	return ch
}

func (n *Node) resetIdleLocked() {
	n.idle = make(chan struct{})
	n.idleClosed = false
}

// MarkReady 由启动检查完成后调用。重复调用是幂等的,方便启动脚本重试。
func (n *Node) MarkReady() error {
	n.mu.Lock()
	defer n.mu.Unlock()

	switch n.phase {
	case PhaseStarting:
		n.phase = PhaseReady
		return nil
	case PhaseReady:
		return nil
	case PhaseDraining:
		return ErrDraining
	default:
		return ErrStopped
	}
}

// Admit 是业务入口的闸门:只有 ready 阶段放行。返回的 release 必须调用,
// 内部用 sync.Once 保证重复释放不会把计数减成负数。
func (n *Node) Admit() (func(), error) {
	n.mu.Lock()
	defer n.mu.Unlock()

	switch n.phase {
	case PhaseReady:
	case PhaseDraining:
		return nil, ErrDraining
	case PhaseStopped:
		return nil, ErrStopped
	default:
		return nil, ErrNotReady
	}

	if n.inflight == 0 && n.idleClosed {
		n.resetIdleLocked()
	}
	n.inflight++

	var once sync.Once
	return func() {
		once.Do(n.release)
	}, nil
}

func (n *Node) release() {
	n.mu.Lock()
	defer n.mu.Unlock()

	if n.inflight > 0 {
		n.inflight--
	}
	if n.inflight == 0 && !n.idleClosed {
		close(n.idle)
		n.idleClosed = true
	}
}

// BeginDrain 先让就绪探针失败,K8s 才会把该实例从 Service 端点摘掉。
// 必须等端点摘除生效后再等待存量请求,否则仍可能有新连接到达。
func (n *Node) BeginDrain() error {
	n.mu.Lock()
	defer n.mu.Unlock()

	switch n.phase {
	case PhaseReady:
		n.phase = PhaseDraining
		return nil
	case PhaseDraining:
		return nil
	case PhaseStopped:
		return ErrStopped
	default:
		return ErrNotReady
	}
}

// WaitDrained 等待进行中的请求归零;ctx 用于给优雅退出设上限。
func (n *Node) WaitDrained(ctx context.Context) error {
	n.mu.Lock()
	if n.phase != PhaseDraining && n.phase != PhaseStopped {
		n.mu.Unlock()
		return ErrNotDraining
	}
	if n.inflight == 0 {
		n.mu.Unlock()
		return nil
	}
	idle := n.idle
	n.mu.Unlock()

	select {
	case <-idle:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

// Stop 只允许在没有存量请求时执行,避免进程退出把正在写的状态截断。
func (n *Node) Stop() error {
	n.mu.Lock()
	defer n.mu.Unlock()

	if n.phase == PhaseStopped {
		return nil
	}
	if n.phase != PhaseDraining {
		return ErrNotDraining
	}
	if n.inflight != 0 {
		return ErrBusy
	}
	n.phase = PhaseStopped
	return nil
}

// Shutdown 是终止流程的推荐顺序:先摘流,再等存量请求,最后退出。
func (n *Node) Shutdown(ctx context.Context) error {
	if err := n.BeginDrain(); err != nil {
		return err
	}
	if err := n.WaitDrained(ctx); err != nil {
		return err
	}
	return n.Stop()
}

// Liveness 在摘流期间仍返回 true:进程还活着,只是不接新流量。
// 如果存活探针在摘流时失败,编排器可能直接杀掉进程,存量请求就白等了。
func (n *Node) Liveness() bool {
	return n.Phase() != PhaseStopped
}

// Readiness 只反映“能否接新请求”,业务依赖未就绪时必须返回 false。
func (n *Node) Readiness() bool {
	return n.Phase() == PhaseReady
}

func (n *Node) Phase() Phase {
	n.mu.Lock()
	defer n.mu.Unlock()
	return n.phase
}

func (n *Node) Inflight() int {
	n.mu.Lock()
	defer n.mu.Unlock()
	return n.inflight
}

状态机的关键在于“摘流”和“退出”分开。只把 readiness 设为 false 还不够,因为网关和负载均衡更新端点需要时间;实现里把“停止接新请求”和“等待存量请求”拆成两个方法,调用方可以在两者之间插入等待端点生效的延迟。测试不依赖真实 sleep,用 channel 关闭来通知归零。

八、测试覆盖哪些边界

测试覆盖六类行为:未就绪时拒绝流量;进入 draining 后拒绝新请求但存活探针仍为 true;有存量请求时 Stop 返回 ErrBusy;Shutdown 会等请求释放后才返回;没有存量请求时可以直接完成退出;重复调用 release 不会把计数减成负数。取消 context 时 WaitDrained 返回 context.Canceled,终止流程可以据此记录超时而不是假装成功。

cd .local/checks/gs-practice-lab && gofmt -l ch21/cloudnative && go vet ./ch21/cloudnative/ && go test ./ch21/cloudnative/ -count=1
ok  	gspractice/ch21/cloudnative	0.232s

gofmt -l 没有输出说明格式一致;go vet 通过;测试全部通过。这里的实现只覆盖单进程内的状态机,跨实例的端点摘除、连接保活和迁移编排仍属于控制面职责,不能用一个探针接口代替。

小结

云原生对游戏后端最大的价值,是把发布流程变成可验证的状态机,而不是消灭有状态服务。容器化和 Agones 类编排解决实例生命周期与分配;有状态服的扩容、缩容和迁移仍要由游戏控制面负责。弹性要看业务指标,缩容要先迁移再退出,冷启动要靠预热和缓冲实例。选型时先写清组件不能承担什么,再比较成本。

常见错误与边界

  • 把世界服、房间服和登录服放进同一个无状态 Deployment,滚动更新时状态直接丢失。
  • 就绪探针只检查端口,进程活着但地图、配置和缓存都没加载完就开始接流量。
  • 终止时先杀进程再考虑迁移,SIGTERM 宽限期一过,存档写入被截断。
  • 存活探针在 draining 阶段返回失败,编排器提前重启,存量请求无法排空。
  • 只按 CPU 扩容,忽略在线人数、tick 延迟和消息积压,扩容总是慢半拍。
  • 缩容直接删实例,没有停止分配和迁移步骤,玩家被迫掉线。
  • 把 Redis 当唯一存档,故障切换时接受不了最近几秒的数据丢失。
  • 认为用了托管服务就不需要容量规划,连接数、IOPS 和出网流量仍然会打满。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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