一、停服不是一种动作,而是四类目标
“停服”经常被当成一个按钮:收到信号、关进程、重启。但在生产环境里,停服至少有四种完全不同的目标,流程混用就会出事故。
| 类型 | 触发场景 | 首要目标 | 允许的玩家感知 |
|---|---|---|---|
| 计划维护 | 版本更新、合服 | 数据完整、公告明确 | 提前通知,短暂不可用 |
| 滚动更新 | 集群升级、扩缩容 | 单实例无损、整体不中断 | 无感或秒级重连 |
| 故障摘除 | 实例不健康、节点故障 | 尽快止损、避免流量继续进入 | 部分玩家重连 |
| 紧急终止 | OOM、死锁、安全事件 | 保留关键数据、尽快释放资源 | 可接受回档 |
计划维护可以等玩家下线,滚动更新必须让流量先走,故障摘除优先摘流量而不是保存全量数据,紧急终止则要在极短时间内做最少但最关键的动作。把四类场景套进同一段“先 sleep 30 秒再 exit”的脚本,等于在故障时主动扩大影响面。
二、优雅停机的阶段顺序
优雅停机的核心不是“等一会儿”,而是按依赖顺序停止产生新状态,并给在途状态一个收尾窗口。一个可复用的顺序是:
- 停止接受新连接,把监听 socket 关闭或从负载均衡摘除;
- 等待网关或注册中心把本实例标记为 draining,让新请求不再路由进来;
- 通知在线玩家,给出重连目标或维护提示;
- 停止房间 tick、AI、定时器等状态推进源;
- 保存玩家、房间、排行榜等关键数据,必要时生成快照;
- 等待落盘和确认,再关闭数据库、消息队列等下游连接;
- 退出进程。
顺序里有两个容易忽略的点。第一,摘流量要早于停 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。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。