一、重启不中断,先要定义“中断”
玩家感知到的“服务中断”通常不是进程退出本身,而是四类现象:新连接连不上、已经在途的请求失败、登录态或房间状态丢失、断线后重连风暴把剩余节点打满。进程重启只是触发条件,真正决定体验的是这四类现象有没有被控制住。
主动重启和崩溃重启要分开看。滚动发布、配置变更、版本升级属于主动重启,进程有机会执行清理逻辑;OOM、panic、机器掉电属于崩溃重启,进程没有机会说再见,只能靠副本、持久化和客户端重连兜底。本文讨论的是主动重启,但设计时必须假设“清理逻辑也可能执行不完”,所以每一步都要有超时和兜底。
二、优雅停机的五步顺序
优雅停机的核心不是“收到信号后 sleep 几秒”,而是按固定顺序完成五件事。顺序错了,后面的等待再长也没有意义。
| 步骤 | 动作 | 做错后的典型后果 |
|---|---|---|
| 1. 停止接新连接 | 关闭 listener,或把 readiness 置为 false | 新连接继续进来,永远排不空 |
| 2. 摘流量 | 从负载均衡、服务发现中下线,等待传播 | 流量还在打进来,排空变成拉锯 |
| 3. 等待在途请求 | 等待活跃请求、连接、房间 tick 结束 | 直接退出导致请求失败、状态写一半 |
| 4. 保存状态 | 检查点、会话交接、最后一批日志落盘 | 重启后回档或房间状态丢失 |
| 5. 释放资源并退出 | 关闭数据库连接、文件、goroutine,返回 0 | 资源泄漏、退出码异常触发反复重启 |
第一步和第二步的顺序经常被写反。正确做法是先把 readiness 置为 false,让负载均衡停止把新流量送过来,但进程继续服务已有连接;等传播时间过去,再关闭 listener 并进入排空。如果先关 listener 再摘流量,负载均衡还没收到下线通知,新连接仍然会被转发到已经不再监听的端口,客户端直接报连接失败。
2.1 摘流量有传播延迟
服务发现、DNS、负载均衡健康检查都需要时间传播。Kubernetes 的 Endpoint 更新、注册中心的推送、四层负载均衡的健康检查周期,任何一个环节都可能让“已经下线”的节点在几秒内继续收到流量。停机逻辑必须把这段传播时间算进去,而不是假设“调用下线接口后流量立刻归零”。
一个可操作的做法是:readiness 置 false 后,先等待至少“健康检查间隔 × 失败阈值”,再关闭 listener。等待时间可以通过配置注入,并在预发环境验证。不要用一个拍脑袋的固定 sleep 覆盖所有环境,生产与预发的传播时间往往不同。
三、会话与连接迁移
长连接游戏服的会话迁移比无状态 HTTP 难得多。玩家的连接可能持续几十分钟,网关内存里保存着会话上下文、订阅关系和房间路由。重启一个网关,不能只让客户端“重连一下”就结束,否则一次发布可能触发全服重连风暴。
| 迁移方式 | 玩家体验 | 实现成本 | 风险 |
|---|---|---|---|
| 客户端自动重连 | 短暂卡顿,可能重进房间 | 低 | 重连风暴、重复登录 |
| 会话票据续接 | 接近无感 | 中 | 票据有效期、密钥管理 |
| 会话快照交接 | 平滑,保留上下文 | 高 | 交接窗口、双写冲突 |
| 粘性路由加排空 | 老连接自然结束 | 中 | 节点负载不均、排空时间长 |
| 断线保护期 | 保留角色一段时间 | 低 | 保护期内重复登录 |
最常用的是“会话票据 + 断线保护期”。客户端持有短期票据,重连时携带票据恢复登录态;服务端在保护期内保留角色和房间位置,避免玩家一断线就被踢出队伍。票据要能防重放,保护期要有上限,否则玩家会利用保护期制造重复登录。
3.1 重连也要限流
发布期间大量客户端同时重连,接入层会收到数倍于平时的连接请求。如果重连不做限流,剩余节点可能先被连接风暴打满,再触发新一轮超时和重连。接入层应当对重连请求做令牌桶限流、按玩家分批放行,并在容量不足时返回明确的排队提示,而不是让连接一直挂起。
四、滚动发布与容量预留
滚动发布的关键参数是“同时下线多少节点”和“下线期间剩余容量是否足够”。无状态 HTTP 服务可以靠副本数硬扛,长连接游戏服还要考虑每个节点承载的房间数、玩家数和重连放大系数。
假设一个区有 20 个房间服节点,每节点承载 5% 的在线玩家,发布时允许同时下线 2 个节点。下线期间剩余 18 个节点要承接原本 20 个节点的玩家,平均负载上升到约 111%;如果重连风暴让瞬时连接请求变成平时的 3 倍,接入层需要额外的排队与限流,否则剩余节点会在重连阶段过载。这些数字是示例值,实际容量必须按房间分布和压测结果计算。
容量预留要覆盖三部分:被下线节点的玩家、重连风暴的瞬时放大、以及发布期间可能出现的节点故障。只按平均负载预留,遇到“发布 + 活动 + 一个节点故障”的叠加就会击穿。滚动策略应当限制 maxUnavailable,优先采用“先扩容再缩容”的 surge 方式,并给长连接服务更长的排空时间。
4.1 分批发布,不要一次全推
一次把所有节点更新完,等于主动制造一次全服重启。分批发布可以把故障局限在一个批次,发现异常时立即暂停。批次之间要留观察窗口,至少覆盖一次完整的连接建立、登录、进房间和战斗流程;只看进程存活和 CPU 指标,无法发现“重连后进不了房间”这类问题。
五、信号处理与超时兜底
进程收到的终止信号通常来自编排系统或运维脚本。SIGTERM 表示“请优雅退出”,SIGINT 常见于本地调试,第二次 SIGTERM 往往意味着“已经等不及了”。服务应当第一次收到信号时启动优雅停机,第二次收到信号时跳过剩余等待,执行最后快照后立即退出。
超时关系必须显式设计。编排系统给的终止宽限期是外层上限,服务内部的排空超时必须小于它,并留出快照和资源释放的时间;否则外层会在服务还没保存状态时直接 SIGKILL。反过来,排空超时也不能无限长,否则每次发布都要等到宽限期结束,发布速度被最慢的连接拖死。
| 超时项 | 关系(示例值) | 说明 |
|---|---|---|
| 编排终止宽限期 | 60 秒 | 外层硬上限,超过后 SIGKILL |
| 流量传播等待 | 10 秒 | readiness 下线到负载均衡停止转发 |
| 在途请求排空 | 30 秒 | 等待请求、连接和房间 tick 结束 |
| 最后快照 | 10 秒 | 有界执行,失败也要继续退出 |
| 安全余量 | 10 秒 | 覆盖调度延迟与未知抖动 |
信号处理要避免两个极端:收到信号立即 os.Exit,以及等待时间设为无限。前者会丢掉在途写入,后者会让编排系统在宽限期结束时强杀,最后一份快照反而没有机会执行。正确做法是给每个阶段设置独立预算,并在日志里记录每个阶段的开始与结束时间。
六、失败场景:收到信号立即退出
某次版本发布把停机逻辑写成了“收到 SIGTERM 后直接退出”。发布开始后,网关进程瞬间消失,客户端连接收到 RST。玩家正在进行的战斗被中断,部分已经扣体力、发奖励的请求只写了一半;大量客户端在同一秒重连,接入层连接数瞬间翻倍,剩余节点开始超时,超时又触发更多重连。
| 时间 | 动作 | 观察到的现象 |
|---|---|---|
| T+0s | 收到 SIGTERM,立即退出 | 在途请求全部失败,连接被重置 |
| T+1s | 客户端检测到断线,开始重连 | 接入层连接请求快速上升 |
| T+3s | 重连请求超过剩余节点容量 | 登录超时,客户端继续重试 |
| T+10s | 重试风暴叠加发布批次 | 剩余节点 CPU 打满,房间 tick 抖动 |
| T+30s | 运维暂停发布 | 部分玩家仍无法登录,需要手动降级 |
修复不是“把 sleep 从 0 秒改成 5 秒”,而是补齐五步顺序:先摘流量并等待传播,再停止接新连接,排空在途请求,保存状态,最后退出。sleep 只能覆盖传播延迟,不能替代在途请求的确认和状态保存。
七、可运行实现:带在途请求与超时兜底的优雅停机
下面的实现把停机拆成三个状态:Running 接收请求,Draining 拒绝新请求并等待在途请求,Closed 表示流程结束。Handle 在状态检查和计数之间用同一把锁保护,避免“检查时还在运行、计数时已经 Wait”的竞态;Shutdown 在 ctx 到期时执行最后一次快照并返回 ErrDrainTimeout,把是否继续等待交给编排系统决定。
// file: ch14/graceful/graceful.go
// tier: L1
// verify: go test ./ch14/graceful/
package graceful
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
)
var (
// ErrShuttingDown 表示服务器已经进入 Drain 状态,不再接收新请求。
ErrShuttingDown = errors.New("graceful: server is shutting down")
// ErrDrainTimeout 表示在途请求未在期限内完成,服务器强制退出。
ErrDrainTimeout = errors.New("graceful: drain timeout")
)
// State 是服务器的生命周期状态。
type State int
const (
// Running 正常接收请求。
Running State = iota
// Draining 已停止接收新请求,等待在途请求完成。
Draining
// Closed 停机流程结束,可能正常排空,也可能超时强制退出。
Closed
)
// String 返回状态的可读名称。
func (s State) String() string {
switch s {
case Running:
return "running"
case Draining:
return "draining"
case Closed:
return "closed"
default:
return "unknown"
}
}
// Handler 处理一个请求。ctx 由调用方传入,便于在强制退出时取消。
type Handler func(ctx context.Context, request string) (string, error)
// Stats 是停机过程的可观测快照。
type Stats struct {
State State
Started int64
Rejected int64
InFlight int64
Forced bool
}
// Server 是一个带在途请求统计和超时兜底的优雅停机服务器。
//
// Shutdown 先把状态切到 Draining,拒绝新请求,再等待在途请求结束;
// 如果调用方传入的 ctx 先到期,服务器会执行最后一次快照并返回
// ErrDrainTimeout,把“是否继续等待”的决定权交还给编排系统。
type Server struct {
mu sync.Mutex
state State
handler Handler
snapshot func() error
inflight sync.WaitGroup
active atomic.Int64
started atomic.Int64
rejected atomic.Int64
forced atomic.Bool
}
// New 创建服务器。snapshot 可为 nil,用于在强制退出前保存最后状态。
func New(handler Handler, snapshot func() error) *Server {
if handler == nil {
handler = func(context.Context, string) (string, error) {
return "", nil
}
}
return &Server{handler: handler, snapshot: snapshot}
}
// Handle 处理一个请求。服务器处于 Draining 或 Closed 时立即返回
// ErrShuttingDown,并且不会增加在途计数。
func (s *Server) Handle(ctx context.Context, request string) (string, error) {
s.mu.Lock()
if s.state != Running {
s.mu.Unlock()
s.rejected.Add(1)
return "", ErrShuttingDown
}
s.inflight.Add(1)
s.active.Add(1)
s.started.Add(1)
s.mu.Unlock()
defer func() {
s.active.Add(-1)
s.inflight.Done()
}()
return s.handler(ctx, request)
}
// Shutdown 停止接收新请求,并等待在途请求完成。ctx 到期时执行最后一次
// 快照并返回 ErrDrainTimeout;正常排空返回 nil。重复调用是安全的。
func (s *Server) Shutdown(ctx context.Context) error {
s.mu.Lock()
if s.state == Closed {
s.mu.Unlock()
return nil
}
s.state = Draining
s.mu.Unlock()
done := make(chan struct{})
go func() {
s.inflight.Wait()
close(done)
}()
select {
case <-done:
s.mu.Lock()
s.state = Closed
s.mu.Unlock()
return nil
case <-ctx.Done():
s.forced.Store(true)
s.mu.Lock()
s.state = Closed
s.mu.Unlock()
if s.snapshot != nil {
if err := s.snapshot(); err != nil {
return fmt.Errorf("%w: snapshot failed: %v", ErrDrainTimeout, err)
}
}
return ErrDrainTimeout
}
}
// Stats 返回当前状态的快照,可在停机过程中被并发调用。
func (s *Server) Stats() Stats {
s.mu.Lock()
state := s.state
s.mu.Unlock()
return Stats{
State: state,
Started: s.started.Load(),
Rejected: s.rejected.Load(),
InFlight: s.active.Load(),
Forced: s.forced.Load(),
}
}
实现里有三个刻意选择。第一,拒绝新请求时立即返回 ErrShuttingDown,调用方可以快速失败并重试到其他节点,而不是挂起等待。第二,在途计数同时使用 WaitGroup 和原子变量:前者负责等待,后者负责观测,避免为了读一个数字去阻塞停机流程。第三,超时后的快照是有界的普通函数调用,快照失败会包装进 ErrDrainTimeout,但不会阻止进程退出。
7.1 测试覆盖
测试覆盖四个边界:在途请求完成前 Shutdown 不返回,且新请求返回 ErrShuttingDown;ctx 超时后返回 ErrDrainTimeout,强制退出标志置位并执行最后快照;没有在途请求时停机立即完成且重复调用安全;关闭后的请求统一被拒绝并计入指标。
cd .local/checks/gs-practice-lab
gofmt -l ch14/graceful
go vet ./ch14/graceful/
go test ./ch14/graceful/ -count=1
7.2 接到真实服务器上
HTTP 服务可以先把 readiness 置 false,等待流量传播,再调用 http.Server.Shutdown(ctx);gRPC 服务可以用 GracefulStop 配合独立的计时器,超时后调用 Stop。自研 TCP 网关则需要自己维护连接表:先关闭 listener,再给每条连接发送下线通知,等待房间 tick 结束,最后关闭连接。无论哪种实现,停机指标至少要包含在途请求数、拒绝数、排空耗时、是否强制退出和快照耗时。
八、和前后章节的衔接
优雅停机解决的是“主动重启时如何少丢请求、少触发重连风暴”,它依赖 14.1 的心跳与就绪检查来判断节点是否该被摘除,也依赖 /game-server-in-action-14-3/ 的状态副本在进程真的崩溃时兜底。把三者分开看,任何一项都只能覆盖一部分故障;组合起来,才能让“重启”从一次事故变成一次常规操作。
小结
服务重启不中断的关键是顺序而不是等待时长:先让流量停止进入,再排空在途请求,然后保存状态,最后退出。摘流量有传播延迟,排空要有上限,快照要有界,所有超时必须小于编排系统的终止宽限期。滚动发布还要预留容量,覆盖被下线节点、重连风暴和并发故障的叠加。把这些环节写成可测试的状态机,比在发布脚本里加一个 sleep 可靠得多。
常见错误与边界
- 收到 SIGTERM 立即退出,在途请求和未落盘状态一起丢失。
- 只关闭 listener 不摘流量,负载均衡仍把新连接转发到已下线节点。
- 停机等待时间设为无限,编排系统在宽限期结束时 SIGKILL,最后快照没有机会执行。
- 排空超时大于或等于终止宽限期,没有给快照和资源释放留余量。
- 先保存状态再排空请求,排空期间到达的写入又让状态变得不一致。
- 滚动发布只按平均负载预留容量,忽略重连风暴和并发节点故障。
- 第二次终止信号仍然走完整排空流程,错过强制退出的窗口。
- 停机指标只看进程是否退出,不记录在途请求、拒绝数和强制退出次数。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。