《游戏服务端编程实践》14.4 实战:服务重启不中断设计

围绕服务重启不中断,拆解优雅停机的五步顺序、摘流量与在途请求排空、会话与连接迁移、滚动发布的容量预留与分批策略,以及信号处理、超时兜底和可观测设计,并给出一个带在途统计、超时强制退出与最后快照的可测试实现,分析完整的失败场景,并列出常见错误与边界。

一、重启不中断,先要定义“中断”

玩家感知到的“服务中断”通常不是进程退出本身,而是四类现象:新连接连不上、已经在途的请求失败、登录态或房间状态丢失、断线后重连风暴把剩余节点打满。进程重启只是触发条件,真正决定体验的是这四类现象有没有被控制住。

主动重启和崩溃重启要分开看。滚动发布、配置变更、版本升级属于主动重启,进程有机会执行清理逻辑;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,最后快照没有机会执行。
  • 排空超时大于或等于终止宽限期,没有给快照和资源释放留余量。
  • 先保存状态再排空请求,排空期间到达的写入又让状态变得不一致。
  • 滚动发布只按平均负载预留容量,忽略重连风暴和并发节点故障。
  • 第二次终止信号仍然走完整排空流程,错过强制退出的窗口。
  • 停机指标只看进程是否退出,不记录在途请求、拒绝数和强制退出次数。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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