一、先画拓扑,再谈部署
部署不是把二进制拷到机器上,而是把一组有不同状态特征的服务放到合适的运行环境里。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 混在一起,最后所有人静音。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。