《游戏服务端编程实践》20.5 部署与监控全流程

梳理网关、世界、战斗、跨服、数据库与缓存的部署拓扑,比较进程与容器的取舍,给出构建、迁移、灰度、回滚的发布流程和监控告警口径;用 Go 实现启动探针、就绪探针与优雅摘流,并分析迁移与代码不同步导致的事故。文章还给出探针状态机、摘流顺序和值班检查清单。

一、先画拓扑,再谈部署

部署不是把二进制拷到机器上,而是把一组有不同状态特征的服务放到合适的运行环境里。SLG 的典型拓扑至少包括网关、世界、战斗、跨服、数据库和缓存。每一层的扩缩容方式、故障影响和发布节奏都不同,混在一起谈“服务端怎么部署”只会得到模糊的结论。

组件是否有状态扩容方式发布影响关键依赖
网关无状态按连接数水平扩容可滚动,连接会重连世界服地址、证书
世界服有状态(地图、联盟缓存)按服/分片扩容需要摘流和存档数据库、缓存
战斗服有状态(进行中的战斗)按战斗实例扩容不能中途换版本战斗快照、消息队列
跨服有状态(赛季、匹配)按赛季/分组扩容需要停服窗口各服数据、消息队列
数据库有状态主从、分库分表迁移需兼容备份、复制
缓存有状态(可重建)分片扩容可能短暂击穿数据库、预热脚本

网关尽量保持无状态,只做连接、鉴权、限流和转发;这样扩容和发布都简单。世界服持有地图和联盟的在线状态,发布时要先摘流、存档、再退出,不能像网关一样随时重启。战斗服最特殊:一场战斗进行到一半时换版本,结算逻辑可能不一致,所以战斗服要按“排空后发布”处理,或者让新战斗进入新版本、旧战斗留在旧版本跑完。

拓扑图还要标出“跨机房”和“跨服”的边界。跨服调用比同机房调用多一层网络故障和版本差异,不能假设对方永远可用。所有跨服请求都要有超时、重试上限和降级返回;跨服写操作要有幂等键,避免超时重试造成重复结算。

二、进程还是容器

进程部署和容器部署不是新旧之争,而是运维能力的匹配。进程部署启动快、排查直接,但环境一致性靠人;容器部署环境一致、发布标准化,但网络、存储和时钟需要额外配置。游戏服务端常见的做法是:无状态服务用容器,战斗和数据库这类对延迟、磁盘敏感的服务用进程或独立主机。

维度进程部署容器部署
环境一致性依赖手工配置镜像固定,较一致
启动速度快镜像拉取和编排有开销
资源隔离靠机器/ cgroup天然隔离,可设限额
网络直接使用主机网络需要端口映射或 host 网络
排障登机器看进程看日志、exec、指标
适用战斗、数据库、低延迟服务网关、登录、后台、工具

容器里跑游戏服务要特别注意时钟和 CPU 限额。战斗结算依赖时间,容器被限流后定时器会漂移;数据库和缓存对磁盘 IO 敏感,容器的存储驱动可能带来额外延迟。把这些服务放进容器不是不行,但要做压测,不能只看“能启动”。

三、发布流程:构建、迁移、灰度、回滚

一次可回滚的发布,至少包含五步:构建不可变产物、执行兼容迁移、灰度一小部分流量、观察关键指标、确认后全量。任何一步失败,都要能停下来并回到上一个稳定版本。

数据库迁移要遵循“扩展—收缩”两阶段。第一阶段只做兼容变更:加可空列、加索引、建新表、双写;代码同时兼容新旧结构。等所有实例都升级、旧数据回填完成后,第二阶段再删旧列、加非空约束、切读路径。把“加列”和“改代码”放在同一次发布里,滚动更新期间新旧实例会同时存在,必然有一方读写失败。

灰度要按可观测的维度切,而不是随机切机器。SLG 可以按服、按渠道、按联盟 ID 哈希切;先放一个低活服,观察登录成功率、战斗错误率和支付回调。灰度期间新旧版本可能同时写同一张表,所以迁移必须向后兼容;如果做不到,就只能停服发布,不能假装可以灰度。

回滚预案要在发布前写好,而不是出事时再想。回滚包括代码回滚、配置回滚、数据回滚三部分。代码回滚最简单;配置回滚要求配置有版本和审计;数据回滚最危险,因为新版本可能已经写入新格式。采用扩展—收缩迁移时,回滚代码通常不需要回滚数据,这是它最大的价值。

四、配置与密钥

配置要分层:镜像内的默认配置、环境变量、配置中心的动态配置。优先级从低到高,启动时合并并校验,缺必填项直接失败。不要把生产配置打进镜像,也不要把密钥写进代码仓库;密钥通过编排系统的 secret 或密钥管理服务注入,进程只从环境变量或内存文件读取。

动态配置要有版本、发布记录和回滚按钮。改一个战斗参数和发一次代码的风险可能一样大,不能因为“只是改配置”就跳过评审。高风险配置支持按服灰度,观察一段时间再全量;配置中心不可用时,进程要能使用最后一次成功加载的配置继续运行,而不是启动失败。

五、监控与告警

监控要覆盖“玩家能不能进来、进来后能不能玩、玩得卡不卡、服务端有没有积压”。指标不在多,而在每个指标都有明确的负责人和处理动作。下面是一张最小监控表,阈值只是示例值,必须按自己的容量压测后确定。

指标含义示例告警阈值首要排查
在线人数当前连接数5 分钟跌 30%网关、登录服、网络
登录成功率成功/尝试低于 98% 持续 5 分钟账号、数据库、验证码
战斗结算延迟 p99结算耗时超过 500ms 持续 3 分钟战斗服 CPU、锁、跨服调用
消息队列积压待处理消息数超过 10 万且持续增长消费者、下游数据库
错误率5xx/总请求超过 1% 持续 3 分钟最近发布、依赖
数据库慢查询超过 200ms 的查询每分钟超过 10 条索引、锁、迁移

告警要分级:P0 是玩家大面积不可玩,立即电话;P1 是核心功能受损,15 分钟内响应;P2 是局部或体验问题,工作时间处理。每一条 P0/P1 告警都要有对应的值班手册条目,写清楚“先看哪个面板、再执行哪个命令、什么条件下升级”。没有处理动作的告警只会制造噪音,最后被所有人静音。

六、值班手册

值班手册不是故障发生时才翻的文档,而是发布前就要演练的检查表。最小内容包括:服务拓扑和依赖、常用面板链接、日志查询入口、重启和摘流命令、数据库备份位置、升级联系人。每条命令都要能在预发环境执行一遍,不能只写在文档里。

交接班要记录当前发布状态、未完成事项和已知风险。比如“今晚 22:00 计划灰度战斗服新版本,回滚包已准备,数据库迁移为兼容变更”,这类信息比“一切正常”有用得多。

七、失败场景:迁移与代码不同步

某次版本给玩家表加了一个 last_login_ip 非空列,迁移脚本先执行,代码后发布。滚动更新期间,旧版本实例仍在运行,插入玩家记录时不写这个列,数据库直接报非空约束错误,新玩家无法注册。发布同学看到“迁移成功”,以为最危险的步骤已经过去,没有观察注册失败率,直到客服反馈才回滚。

问题有两个。第一,迁移不是兼容变更:给已有表加非空列,必须给默认值或分三步走(加可空列、回填、再加约束)。第二,发布顺序错了:应该先发兼容新旧结构的代码,再执行迁移;或者迁移和代码同时支持旧写入。根本原因是把数据库迁移当成独立的运维动作,没有和代码版本绑定。

修复方案是把迁移纳入发布流水线:每个迁移文件声明“向前兼容”还是“破坏性”,破坏性迁移必须单独审批并选择停服窗口;CI 在迁移后跑一次旧版本写入冒烟;发布系统按“迁移—灰度—观察—全量”的顺序执行,任一步指标异常自动暂停。回滚时优先回滚代码,保留兼容的数据库结构,等确认稳定后再执行收缩迁移。

八、Go 实现:deploy.go

下面的实现用显式状态机描述启动、就绪、摘流和停止。启动探针在宽限期内返回未就绪,超时则发出重启信号;就绪探针只在 StateReady 时通过;BeginDrain 先让就绪探针失败,Stop 要求已经摘流且没有在途请求。

// file: ch20/deploy/deploy.go
// tier: L1
// verify: go test ./ch20/deploy/

package deploy

import (
	"errors"
	"sync"
	"time"
)

var (
	ErrInvalidConfig  = errors.New("deploy: invalid probe configuration")
	ErrAlreadyStarted = errors.New("deploy: server already started")
	ErrNotStarted     = errors.New("deploy: server has not started")
	ErrNotReady       = errors.New("deploy: server is not ready")
	ErrDraining       = errors.New("deploy: server is draining")
	ErrStopped        = errors.New("deploy: server has stopped")
	ErrDrainRequired  = errors.New("deploy: drain before stop")
	ErrInflight       = errors.New("deploy: requests still in flight")
	ErrNoInflight     = errors.New("deploy: no request to complete")
	ErrStartupTimeout = errors.New("deploy: startup probe timed out")
)

// State 是进程生命周期状态,顺序与发布阶段一致。
type State int

const (
	StateNew State = iota
	StateStarting
	StateReady
	StateDraining
	StateStopped
)

// Server 用显式状态机描述“启动中、就绪、摘流、停止”四个阶段。
// 所有时间都由调用方传入,测试不需要 sleep,线上由时钟驱动。
type Server struct {
	mu             sync.Mutex
	state          State
	startedAt      time.Time
	startupTimeout time.Duration
	failureLimit   int
	failures       int
	inflight       int
	restart        chan struct{}
}

func New(startupTimeout time.Duration, failureLimit int) (*Server, error) {
	if startupTimeout <= 0 || failureLimit <= 0 {
		return nil, ErrInvalidConfig
	}
	return &Server{
		state:          StateNew,
		startupTimeout: startupTimeout,
		failureLimit:   failureLimit,
		restart:        make(chan struct{}, 1),
	}, nil
}

func (s *Server) Start(now time.Time) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.state != StateNew {
		return ErrAlreadyStarted
	}
	s.state = StateStarting
	s.startedAt = now
	return nil
}

// Startup 是启动探针:初始化未完成时返回 ErrNotReady;超过启动宽限期
// 仍未就绪,则返回 ErrStartupTimeout 并发出重启信号。
func (s *Server) Startup(now time.Time) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	switch s.state {
	case StateNew:
		return ErrNotStarted
	case StateReady, StateDraining, StateStopped:
		return nil
	}
	if now.Sub(s.startedAt) > s.startupTimeout {
		s.signalRestartLocked()
		return ErrStartupTimeout
	}
	return ErrNotReady
}

// Liveness 只回答“进程是否还活着”,不判断能否接流量。
func (s *Server) Liveness() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	switch s.state {
	case StateNew:
		return ErrNotStarted
	case StateStopped:
		return ErrStopped
	default:
		return nil
	}
}

// Readiness 回答“现在能否接新流量”。摘流开始后立即失败。
func (s *Server) Readiness() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.readinessLocked()
}

func (s *Server) readinessLocked() error {
	switch s.state {
	case StateNew:
		return ErrNotStarted
	case StateStarting:
		return ErrNotReady
	case StateReady:
		return nil
	case StateDraining:
		return ErrDraining
	default:
		return ErrStopped
	}
}

// MarkReady 由业务初始化完成后调用;重复调用是幂等的。
func (s *Server) MarkReady() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	switch s.state {
	case StateNew:
		return ErrNotStarted
	case StateStarting:
		s.state = StateReady
		return nil
	case StateReady:
		return nil
	case StateDraining:
		return ErrDraining
	default:
		return ErrStopped
	}
}

// BeginDrain 先让就绪探针失败,负载均衡停止转发新请求;已有请求继续执行。
func (s *Server) BeginDrain() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	switch s.state {
	case StateNew:
		return ErrNotStarted
	case StateStopped:
		return ErrStopped
	case StateDraining:
		return nil
	default:
		s.state = StateDraining
		return nil
	}
}

// BeginRequest 只在就绪状态计数,摘流期间拒绝新请求。
func (s *Server) BeginRequest() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := s.readinessLocked(); err != nil {
		return err
	}
	s.inflight++
	return nil
}

func (s *Server) EndRequest() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.inflight == 0 {
		return ErrNoInflight
	}
	s.inflight--
	return nil
}

func (s *Server) Inflight() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.inflight
}

// Stop 要求先摘流,并确认没有在途请求;顺序错误会返回明确错误,
// 而不是在还有请求时直接退出。
func (s *Server) Stop() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	switch s.state {
	case StateNew:
		return ErrNotStarted
	case StateStopped:
		return ErrStopped
	case StateStarting, StateReady:
		return ErrDrainRequired
	}
	if s.inflight > 0 {
		return ErrInflight
	}
	s.state = StateStopped
	return nil
}

// ReportProbe 记录连续探测失败。达到阈值时发出一次非阻塞重启信号,
// 并把计数清零,避免同一故障持续灌满通道。
func (s *Server) ReportProbe(success bool) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if success {
		s.failures = 0
		return false
	}
	s.failures++
	if s.failures < s.failureLimit {
		return false
	}
	s.failures = 0
	s.signalRestartLocked()
	return true
}

func (s *Server) RestartSignal() <-chan struct{} {
	return s.restart
}

func (s *Server) signalRestartLocked() {
	select {
	case s.restart <- struct{}{}:
	default:
	}
}

Startup、Liveness、Readiness 三个探针的职责被刻意分开:启动探针关心初始化是否完成,存活探针只关心进程是否还在,就绪探针决定负载均衡是否转发流量。把三者合成一个 /health 接口,是很多“重启风暴”的来源:数据库慢查询让就绪失败,编排系统却把进程杀掉重启,问题反而扩大。

ReportProbe 用连续失败计数触发重启信号,成功一次就清零。真实环境里还要加“重启冷却”,避免探测目标持续不可用时反复重启;这个包只负责状态和信号,重启动作由编排系统或守护进程执行。

九、测试与运行

测试覆盖:未就绪时拒绝流量、启动探针超时触发重启、停机必须先摘流、在途请求未完成时拒绝停止、连续探测失败达到阈值才发信号。

cd .local/checks/gs-practice-lab && gofmt -l ch20/deploy && go vet ./ch20/deploy/ && go test ./ch20/deploy/ -count=1
ok  	gspractice/ch20/deploy	0.287s

测试把时间作为参数传入,不依赖 sleep,因此能在 CI 中快速、稳定地运行。真实部署还要在预发环境验证:滚动更新时旧实例是否先摘流、负载均衡是否停止转发、连接是否被优雅关闭。

小结

部署先从拓扑开始,区分无状态网关和有状态的世界、战斗、跨服,再选择进程或容器。发布流程要把构建、兼容迁移、灰度、观察、回滚串成一条流水线,数据库迁移采用扩展—收缩两阶段,代码与结构至少有一个版本的重叠兼容。配置和密钥分层管理,动态配置也要评审和灰度。监控围绕在线、登录、战斗延迟和队列积压建立分级告警,每条告警都要有值班动作。探针要分清启动、存活和就绪,停机先摘流再退出。

常见错误与边界

  • 把网关、世界、战斗放进同一个 Deployment,用同一种方式滚动重启,战斗中途被换版本。
  • 给已有大表直接加非空列或改类型,迁移锁表或旧代码写入失败。
  • 迁移先于兼容代码执行,滚动更新期间新旧实例读写同一张表,必有一方报错。
  • 只做代码回滚预案,没有配置和数据回滚方案,出事时只能停服。
  • 密钥写在镜像、启动脚本或代码仓库里,日志打印配置时泄露凭证。
  • 健康检查只查进程存活,进程还在但数据库连接已断,负载均衡继续转发。
  • 就绪探针依赖数据库,数据库抖动导致所有实例同时被摘流,形成雪崩。
  • 停机时直接 kill,不摘流也不等待在途请求,玩家看到大量断线和重复扣费。
  • 告警没有分级和处置手册,P0 和 P2 混在一起,最后所有人静音。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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