一、云原生解决的是发布问题
云原生经常被讲成“上 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 和出网流量仍然会打满。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。