《游戏服务端编程实践》1.3.3 停服与数据回收

区分计划维护、滚动更新、故障摘除与紧急终止四类停服目标及其玩家感知差异,给出优雅停机的阶段顺序、在途请求排水、超时预算、幂等保存与重启恢复策略,并讨论停机演练与观测,最后附一个按阶段执行、支持 context 超时且保证关键保存的 Go 停机协调器。

一、停服不是一种动作,而是四类目标

“停服”经常被当成一个按钮:收到信号、关进程、重启。但在生产环境里,停服至少有四种完全不同的目标,流程混用就会出事故。

类型触发场景首要目标允许的玩家感知
计划维护版本更新、合服数据完整、公告明确提前通知,短暂不可用
滚动更新集群升级、扩缩容单实例无损、整体不中断无感或秒级重连
故障摘除实例不健康、节点故障尽快止损、避免流量继续进入部分玩家重连
紧急终止OOM、死锁、安全事件保留关键数据、尽快释放资源可接受回档

计划维护可以等玩家下线,滚动更新必须让流量先走,故障摘除优先摘流量而不是保存全量数据,紧急终止则要在极短时间内做最少但最关键的动作。把四类场景套进同一段“先 sleep 30 秒再 exit”的脚本,等于在故障时主动扩大影响面。

二、优雅停机的阶段顺序

优雅停机的核心不是“等一会儿”,而是按依赖顺序停止产生新状态,并给在途状态一个收尾窗口。一个可复用的顺序是:

  1. 停止接受新连接,把监听 socket 关闭或从负载均衡摘除;
  2. 等待网关或注册中心把本实例标记为 draining,让新请求不再路由进来;
  3. 通知在线玩家,给出重连目标或维护提示;
  4. 停止房间 tick、AI、定时器等状态推进源;
  5. 保存玩家、房间、排行榜等关键数据,必要时生成快照;
  6. 等待落盘和确认,再关闭数据库、消息队列等下游连接;
  7. 退出进程。

顺序里有两个容易忽略的点。第一,摘流量要早于停 tick:如果先停 tick 再摘流量,玩家在这段时间里仍能发请求,却得不到状态推进。第二,通知玩家要早于保存:保存可能耗时数秒,玩家需要提前知道发生了什么,否则会反复重连,形成重连风暴。

2.1 什么算“在途”请求

“等待在途请求”不能靠一个固定的 sleep 蒙混过去。至少要区分三类在途工作:已经读完请求但还没写回响应的连接、已经进入逻辑队列但还没被消费的消息、已经发起但还没确认的跨服调用。

在途类型如何判断排水方式
连接读写连接上有未完成的帧设置读截止时间,写完响应后关闭
逻辑队列队列长度大于 0停止入队,等待消费者清空或超时
跨服调用有未返回的 request ID等待响应或按预算取消
异步落盘有未确认的写任务等待 ack,超时后记录待恢复

排水的目标是“不再产生新工作”,而不是“把所有工作做完”。当预算耗尽时,未完成的工作必须被记录成可恢复状态,而不是被静默丢弃。

2.2 通知玩家的三种方式

通知不是只有“弹窗”一种。按成本从低到高,可以分为广播公告、定向推送、逐连接确认。计划维护适合广播公告;滚动更新适合定向推送重连目标;故障摘除则往往只能依赖客户端心跳超时后的自动重连。

选择哪种方式,取决于停机预算和玩家体验的容忍度。逐连接确认最可靠,但在线人数多时会拖长停机时间;广播最便宜,但无法保证每个客户端都收到。工程上通常组合使用:先广播,再对关键玩家定向推送,最后接受一部分玩家通过重连发现新实例。

三、超时预算与强制终止

停机必须有一个总预算,例如 30 秒或 60 秒,并且预算要按阶段拆分。没有预算的“优雅”会变成“永远停不下来”:某个下游不响应,进程就卡在保存阶段,编排系统最终只能 SIGKILL。

阶段建议预算超时后的动作
摘流量2–5 秒继续,记录未确认的实例
通知玩家1–2 秒放弃逐个通知,依赖客户端重连
停止状态推进1 秒强制停止 tick,记录未完成帧
保存关键数据10–30 秒进入强制终止前的最后尝试
关闭下游2–5 秒直接退出,依赖连接超时回收

强制终止不是失败,而是预算的一部分。真正危险的是“既没有在预算内完成,又没有留下可恢复的数据”。因此保存阶段要尽量短、可重试、可幂等;通知和日志则允许降级。

3.1 信号语义与编排系统的协作

Linux 上常见的停机信号有 SIGTERM、SIGINT、SIGHUP 和 SIGKILL。它们不是“不同强度的同一个动作”,语义完全不同。

信号语义进程能否捕获典型用途
SIGTERM请求优雅退出能编排系统缩容、滚动更新
SIGINT终端中断能本地调试、前台进程
SIGHUP终端挂断或重载配置能配置热更新、日志轮转
SIGKILL立即杀死不能预算耗尽后的强制终止

编排系统通常先发 SIGTERM,等待 terminationGracePeriodSeconds,再发 SIGKILL。因此进程内的停机预算必须短于这个宽限期,并预留摘流量和落盘的时间。把宽限期设成 30 秒、进程却要保存 40 秒,最后一定会被 SIGKILL。

另外,SIGHUP 不应被当成停服信号。把它和 SIGTERM 绑到同一个处理函数,会在日志轮转时意外触发停机。

同一个进程也可能收到多次 SIGTERM。常见处理是第一次进入优雅停机,第二次跳过剩余的非关键阶段、直接执行关键保存并退出。这样既保留了自动化的耐心,也给运维留了一个“立即停”的手动开关。关键保存仍然要有自己的超时,避免第二次信号把进程直接变成不可控的阻塞状态。

四、幂等保存与重启恢复

停服期间的重试、超时和重复信号都会导致同一份数据被保存多次。保存接口必须幂等:以玩家 ID + 数据版本号、或房间 ID + 快照序号作为唯一键,重复写入产生相同结果,而不是叠加奖励或重复扣费。

重启恢复要回答“最后一次成功保存是什么时候”。常见做法是给每次保存打一个单调递增的序号,启动时读取最新序号,并检查是否存在“已写入一半”的临时文件。先写临时文件、再原子 rename,可以避免读到半个快照;如果使用数据库,则用事务或版本号做条件更新。

UPDATE player_snapshot
SET payload = ?, version = ?
WHERE player_id = ? AND version < ?;

这条语句的影响行数为 0 时,说明已有更新的版本,调用方应视为成功而不是报错。幂等保存的目标不是“保证只写一次”,而是“写多次和写一次结果相同”。

4.1 重启后的恢复检查

重启恢复不只是“读最新一行”。至少要检查四件事:快照版本是否连续、临时文件是否需要清理、数据库连接是否可用、消息队列里是否有未确认的停机消息。

检查项不通过的后果处理方式
快照版本回档或读到旧数据回退到上一个完整版本并告警
临时文件磁盘被半成品占满启动时清理过期临时文件
下游连接保存一直重试失败快速失败,进入只读或维护模式
未确认消息重复发奖或漏发按幂等键去重后再消费

恢复流程本身也要幂等。一次重启失败后再次重启,不能因为上次留下了半成品就拒绝启动;正确的做法是识别并修复,而不是要求人工清库。

五、数据回收:哪些必须落盘,哪些可以丢

停服时的数据可以分成三类,处理策略不同。

数据是否必须落盘原因回收方式
玩家角色、背包、货币必须直接影响资产与公平同步保存 + 版本校验
房间战斗结果、排行榜必须影响结算与申诉快照或事务写入
临时房间、匹配队列可不落盘重启后可重建直接丢弃并通知重匹配
本地缓存、对象池可不落盘可由源数据重建清空或随进程退出
访问日志、调试日志可降级允许少量丢失有界队列 + 超时丢弃

判断标准是“丢了能不能从其他数据重建”。能重建的就不应该在停机预算里抢时间;不能重建的才值得为它保留独立的关键阶段。把所有内存状态都当成必须落盘,往往导致停机超时,最后反而丢得更多。

5.1 回收顺序与引用计数

数据回收和资源关闭一样,要先断引用再释放。典型顺序是:停止 tick 与定时器、从房间索引摘除、等待在途保存完成、清空对象池、关闭连接与文件句柄。每一步都应该是幂等的,因为停机流程可能被重复触发。

引用计数适合管理“多个房间共享同一张地图实例”这类场景。计数归零时才释放地图,而不是某个房间退出就销毁。引用计数本身也要有兜底:停机时如果计数迟迟不归零,应记录泄漏的持有者并强制回收,而不是无限等待。

六、失败场景:只关监听不排空

某实例收到 SIGTERM 后只关闭了监听端口,没有摘流量,也没有等待在途请求。负载均衡仍然把新连接路由过来,这些连接被拒绝后客户端立即重连;同时旧连接上的房间 tick 还在跑,保存逻辑与新实例的加载逻辑并发修改同一份数据。结果是玩家看到“能登录但数据回档”,监控上则是重连 QPS 飙升。

更严重的版本是 SIGKILL 前完全没有落盘:进程被编排系统直接杀掉,内存中的房间奖励和排行榜积分全部丢失,只能靠全服补偿。这类问题无法靠“下次注意”解决,必须在停机流程里把“关键保存”做成独立阶段,并给它一个不受父级取消影响的执行窗口。

时间动作观察到的现象
T+0s收到 SIGTERM,关闭监听负载均衡仍认为实例可用
T+1s客户端重连到本实例连接被拒绝,重连 QPS 上升
T+3s旧连接上的 tick 仍在跑房间状态继续变化
T+10s编排系统发送 SIGKILL未保存的奖励全部丢失
T+30s新实例加载旧快照玩家看到回档

这张时间表说明了两个事实:关闭监听不等于摘流量,进程退出也不等于数据已经落盘。停机流程必须同时和负载均衡、注册中心、存储层对齐。

七、停机演练与观测

停机流程最怕“只在生产第一次执行”。至少要在预发环境演练三类场景:正常 SIGTERM、下游超时、预算耗尽后的 SIGKILL。每次演练记录各阶段耗时、失败原因和最终保存版本号。

观测指标用途
各阶段耗时判断预算分配是否合理
保存成功/失败数发现幂等键或下游问题
被跳过的阶段判断父 context 是否过早取消
重连 QPS判断通知和摘流量是否生效
最后保存版本重启恢复的起点

没有观测的停机流程只能算“跑通过一次”,不能算“可运营”。把阶段结果结构化输出,才能在事故复盘时回答“到底哪一步没做完”。

八、可运行实现:按阶段执行的停机协调器

下面的 Coordinator 按声明顺序执行阶段。非关键阶段在父 context 已取消时直接跳过;关键阶段使用 context.WithoutCancel 剥离父级取消与截止时间,再套上自己的超时,因此即使总预算已经耗尽,保存仍然有一次有界的机会。

// file: ch01/shutdown/shutdown.go
// tier: L1
// verify: go test ./ch01/shutdown/
package shutdown

import (
	"context"
	"errors"
	"time"
)

// Phase 描述停机流程中的一个阶段。
//
// Critical 为 true 的阶段(通常是保存和快照)不继承调用方的
// 取消信号,只受自己的 Timeout 约束。这样即使前面的排空阶段
// 已经把总预算耗尽,关键数据仍然有机会落盘。
type Phase struct {
	Name     string
	Timeout  time.Duration
	Critical bool
	Run      func(context.Context) error
}

// Result 是单个阶段的执行结果。
type Result struct {
	Name     string
	Err      error
	TimedOut bool
	Skipped  bool
}

// OK 表示阶段成功执行并返回 nil。
func (r Result) OK() bool {
	return r.Err == nil && !r.Skipped
}

// Coordinator 按声明顺序执行停机阶段。
type Coordinator struct {
	phases []Phase
}

// New 创建一个停机协调器。
func New(phases ...Phase) *Coordinator {
	return &Coordinator{phases: phases}
}

// Run 依次执行所有阶段并返回逐阶段结果。
//
// 非关键阶段在父 context 已取消时直接标记为 Skipped,避免在
// 已经超时的预算里继续等待。关键阶段使用 WithoutCancel 剥离
// 父级取消与截止时间,再套上自己的 Timeout,保证保存动作有
// 一个独立、有界的执行窗口。
func (c *Coordinator) Run(ctx context.Context) []Result {
	results := make([]Result, 0, len(c.phases))
	for _, phase := range c.phases {
		result := Result{Name: phase.Name}

		if phase.Run == nil {
			result.Err = errors.New("shutdown: nil phase runner")
			results = append(results, result)
			continue
		}
		if !phase.Critical && ctx.Err() != nil {
			result.Err = ctx.Err()
			result.Skipped = true
			results = append(results, result)
			continue
		}

		base := ctx
		if phase.Critical {
			base = context.WithoutCancel(ctx)
		}
		var runCtx context.Context
		var cancel context.CancelFunc
		if phase.Timeout > 0 {
			runCtx, cancel = context.WithTimeout(base, phase.Timeout)
		} else {
			runCtx, cancel = context.WithCancel(base)
		}
		err := phase.Run(runCtx)
		result.TimedOut = errors.Is(runCtx.Err(), context.DeadlineExceeded)
		cancel()
		result.Err = err
		results = append(results, result)
	}
	return results
}

// Failed 返回第一个未成功(失败、超时或跳过)的阶段。
func Failed(results []Result) (Result, bool) {
	for _, result := range results {
		if !result.OK() {
			return result, true
		}
	}
	return Result{}, false
}

测试覆盖四种情况:父 context 取消后关键保存仍执行、单阶段超时后继续下一阶段、关键阶段自身超时仍有界、父级 deadline 到达后非关键阶段被跳过。没有设置 Timeout 的阶段会继承父 context 的生命周期,因此生产代码应给每个阶段都写明预算。运行方式:

cd .local/checks/gs-practice-lab
gofmt -l ch01/shutdown
go vet ./ch01/shutdown/
go test ./ch01/shutdown/

小结

停服要先区分目标,再排阶段;阶段之间要有依赖顺序,整体要有超时预算。摘流量、停状态推进、保存、落盘、退出这五步里,任何一步缺失都会把“优雅”变成“不可控”。保存接口要幂等,恢复流程要能识别半成品;数据回收则按“能否重建”分级,而不是一律落盘。

常见错误与边界

  • 收到信号直接退出,或在 SIGKILL 前没有任何保存动作。
  • 只关闭监听端口,不摘流量、不等待在途请求。
  • 停机时逐个通知玩家并等待确认,导致重连风暴和超时。
  • 保存接口不幂等,重试造成重复奖励或重复扣费。
  • 把所有内存状态都当成必须落盘,停机预算被日志和缓存耗尽。
  • 关键保存继承父 context 的 deadline,预算一耗尽就再也写不进去。
  • 终止宽限期短于保存时间,编排系统在保存完成前发送 SIGKILL。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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