一次午夜发版,断了两百局
《裂隙战场》的发版时间原本定在凌晨两点,理由很朴素:那个时段在线人数最低。某次版本包含结算逻辑修正,运维按无状态服务的习惯滚动重启,结果正在进行的对局全部断开,两百多局被判负,第二天客服收到成片的申诉。
问题不在“凌晨”这个选择,而在把对局服当成了无状态服务。无状态服务重启只影响正在处理的请求,重试即可;有状态服务的进程里装着房间、连接、Buff、待结算的伤害和已经打了一半的局,重启等于把这些一起扔掉。
这一节讨论三件事:发布单元怎么选,排空怎么排,新旧版本怎么在一段时间里共存。故障恢复(第 8 章)处理的是“已经坏了怎么救”,发布处理的是“主动变更时不要自己制造故障”,两者的判据不能混用:故障恢复允许丢一部分状态换可用性,发布不允许用玩家的对局去换发布速度。
三款产品的发布约束完全不同
| 产品 | 对局/状态时长 | 发布单元 | 主要约束 |
|---|---|---|---|
| 《裂隙战场》 | 单局 8–15 分钟 | 对局进程 | 晚间高峰不能断局,结算不能跨版本 |
| 《群岛纪元》 | 世界服常驻,赛季数月 | 区服 / 地图分片 | 大地图状态无法重启,只能迁移 |
| 《泡泡工坊》 | 单局 2–5 分钟 | 大厅 + 房间进程 | 大厅可滚动,房间排空很快 |
《泡泡工坊》最接近无状态服务:大厅不保存对局状态,直接滚动重启;房间进程虽然有状态,但一局只有几分钟,等三十秒基本就空了。它的发布成本低,不是因为架构更先进,而是因为状态寿命短。
《群岛纪元》相反。世界服上挂着联盟、地块、行军和赛季进度,重启一次意味着整张地图回到某个存档点,玩家能立刻看出来。它的发布不是“换进程”,而是“迁移一份活的世界”,必须按赛区排期,并且和活动日历绑定。
《裂隙战场》在两者之间:进程可以换,但进程上跑着几十个房间,换之前要等这些房间自然结束。
三款产品共用 northstar-platform 的发布单,但发布单只统一了“流程外壳”:谁审批、记录哪些字段、失败怎么回滚。真正决定一次发布要多久、能不能中断对局的,是每个产品自己的状态寿命和状态位置。平台把发布单做厚,不能替代产品侧回答“这个单元能不能排空”。
为什么不能像无状态服务一样滚动重启
有状态服务的发布要同时满足四个条件,缺一个就会伤到玩家:
- 新连接不再进入。发布中的进程必须先从匹配池和网关路由里摘除,否则一边排空一边进人,永远排不空。
- 已有对局自然结束。对局结束意味着结算完成、奖励落库、战绩可查;在半途迁移会把“这局算不算”变成一个需要人工裁决的问题。
- 超过截止时间要强制。无限等待会把发布窗口拖进活动时段,尾部几个长尾房间必须有一个可承诺的截断点。
- 移除前状态为零。进程下线时如果还有会话挂着,网关会保留幽灵连接,玩家看到的是“重连中”而不是“已结束”。
这四条看起来只是流程,实际上决定了发布单元的选择:如果状态寿命远长于发布窗口,就不能用“进程”做单元,只能用“区服 + 迁移方案”。
发布单元:房间、进程还是区服
| 单元 | 粒度 | 影响面 | 代价 | 适用 |
|---|---|---|---|---|
| 房间 | 单局 | 一局玩家 | 调度复杂,需要房间级生命周期 | 房间独立容器、休闲品类 |
| 进程 | 一台对局宿主 | 几十局 | 需要排空与匹配池摘除 | 主流对局服 |
| 区服 / 分片 | 整个世界 | 全服玩家 | 需要迁移方案与更长窗口 | SLG 世界服、跨服战场 |
《裂隙战场》选进程:房间在进程内,没有独立的房间容器,按房间发布做不到;一个进程四十到六十个房间,排空十分钟能覆盖绝大多数对局。
《群岛纪元》选区服 / 分片:世界服不能按进程换,地图分片的迁移是单独的一套流程,发布计划里必须带迁移方案编号,代码里用 Plan 字段强制这一点——没有方案的 shard 连注册都不允许。
《泡泡工坊》选房间:房间本身就是短生命周期对象,发布时让新房间落到新版本,旧房间自然结束,不需要进程级排空。代价是调度器要能按版本筛选房间宿主。
发布单元不是越细越好。单元越细,调度和版本追踪越复杂;单元越粗,一次失败的影响面越大。判断标准是“状态寿命与发布窗口的比值”:状态寿命远小于窗口,可以粗;接近或超过窗口,必须细,或者改用迁移。
一次发布的完整顺序
把上面的约束落到操作上,一次有状态发布大致是这十步。顺序本身就是设计,任何一步提前或延后都会留下漏洞:
- 查活动日历,确认当前在允许窗口内,且未来两小时没有活动开场。
- 冻结匹配池中的目标区服或进程,新对局不再落到这些单元。
- 从网关路由摘除目标单元,阻止新连接进入;老连接保留到对局结束。
- 对每个单元调用
BeginDrain,记录开始时间与当时的对局数。 - 每分钟调用一次
Tick,观察draining的数量,确认在下降。 - 对局结束事件调用
EndSession,直到单元进入drained。 - 截止时间到达后,对剩余单元执行强制回收,记录
Forced。 - 部署新版本,把单元注册回匹配池与网关,但先只放金丝雀流量。
- 观察错误率、匹配时长、结算差异,达标后按批次扩大。
- 全部批次完成后解除冻结,关闭发布单,归档强制迁移清单。
第 2 步和第 3 步经常被合成一步,结果是对局结束了、连接还在,网关把重连请求又送回正在排空的进程。第 7 步的清单如果只写日志,补偿流程就找不到依据;它必须是一个可查询的结构,而不只是一行文本。
分批:金丝雀、区服、全量
有状态发布不能一次换完,但“分批”的维度不是随便切的:
| 批次 | 范围(示例值) | 目的 | 晋级条件 |
|---|---|---|---|
| 金丝雀 | 1 个空闲进程,约 40–60 个房间容量 | 验证启动、注册、结算链路 | 30 分钟内无启动失败、无结算差异 |
| 小批 | 1 个低峰区服的 10% 进程 | 验证真实对局与匹配 | 错误率、匹配时长与基线一致 |
| 大批 | 该区服剩余进程 | 验证容量与调度 | 持续 2 小时指标稳定 |
| 全量 | 其余区服 | 完成发布 | 全量后 24 小时复盘 |
金丝雀必须是空闲单元,这一点在 PlanWaves 里是硬校验:带对局的金丝雀会把“验证新版本”变成“拿玩家做实验”,而且失败时无法区分是版本问题还是排空问题。
区服维度比进程维度更重要。《裂隙战场》的匹配池按区服隔离,先换哪个区服决定了哪些玩家先看到新版本;如果只按进程比例切,同一个区服里会出现新旧版本混跑,跨版本匹配的风险立刻上升。分批顺序应该先区服、后进程,而不是反过来。
每批之间要有明确的观察窗口,不能用“部署完成”当作“验证通过”。观察窗口内至少要覆盖一次匹配高峰和一次结算高峰,否则错误率的分母太小,指标没有意义。
排空发布的状态机
serving --BeginDrain--> draining --最后一局结束--> drained --> retired
└── 截止时间到达仍有对局 --> forced --> retired
四条转移规则:
BeginDrain只允许从serving进入,重复调用返回ErrInvalidTransition。发布系统重试是常态,状态机必须自己挡住重复操作。- 房间数为零的单元在
BeginDrain时直接进入drained,不占用宽限期。空进程没有必要等十分钟。 EndSession每结束一局减一,减到零转drained;不在draining状态的调用一律拒绝。Tick在截止时间到达时把剩余对局计入Forced并转forced。强制结束的对局不能按失败计分,这个计数要进补偿流程和对局审计。
宽限期按单元类型配置,不用全局一个值:
| 单元类型 | 宽限期(示例值) | 理由 |
|---|---|---|
| 房间 | 30 秒 | 休闲对局最长 5 分钟,尾部很短 |
| 对局进程 | 10 分钟 | 覆盖 8–15 分钟对局的大部分 |
| 世界服 / 分片 | 30 分钟 | 这是迁移窗口,不是等待窗口 |
宽限期不是“对局最长时长”。它要覆盖的是“大多数对局能在截止前自然结束”,剩下的是明确的强制迁移预算。把宽限期设成最长对局时长,等于把每次发布都拖到最坏情况;设得太短,强制迁移比例上升,补偿成本跟着上升。
发布窗口与活动日历
有状态服务的发布窗口不能只看在线人数,还要看活动日历:
- 赛季结算前后各两小时不发布。结算脚本和发布脚本同时改数据,出问题无法归因。
- 跨服战、限时活动开服后三十分钟不发布。玩家刚进来就遇到重连,客诉成本远高于晚发一小时。
- 大版本首日不排第二批。首日观察指标还没稳定,叠加第二个变更会让回滚判断失去基准。
日历用“允许窗口 + 黑色时段”表达,黑色时段优先。这一点要在代码里固定:后加的活动不能被前面的允许窗口悄悄覆盖。发布系统在拿到时间后先问日历,再决定是否开始排空;被拒绝时返回明确原因,而不是让值班同学自己判断。
混版本:三个必须分开的问题
排空发布期间,新旧版本一定同时存在。混版本不是一件事,而是三个独立问题:
| 问题 | 规则 | 违反后的表现 |
|---|---|---|
| 协议兼容 | 客户端 N 与 N+1 可互操作,服务端内部 RPC 允许一个 minor 的偏差 | 老客户端进新房间报错、匹配失败 |
| 房间内一致 | 同一局的所有参与者使用同一版本 | 两端结算公式不同,比分对不上 |
| 跨版本匹配 | 默认禁止;开启必须有确定性验证 | 同 seed 不同结果,回放无法复现 |
协议兼容看的是“新旧能否对话”,房间一致看的是“一局内规则是否相同”,跨版本匹配看的是“不同规则能否得到同一结果”。第三件事最容易被低估:即使协议能通,两个版本的伤害公式只要有一位小数不同,结算就会分叉。
北辰的做法是:MaxMinorSkew 控制整个集群的版本漂移,默认 0,即发布期间也只允许一个 minor 版本在服务;确实需要并行时放宽到 1,同时 AllowCrossVersionMatch 保持关闭,匹配器按版本分组。只有在新版本经过回放比对、证明同 seed 同结果之后,才允许打开跨版本匹配。
一次典型的三十分钟共存期是这样过去的:
- T+0 开始排空,v1.24 进程停止接新局,v1.25 进程注册进池。
- T+5 匹配器只把 v1.25 的玩家分到 v1.25 的房间;v1.24 的玩家继续在旧房间打完。
- T+10 最后一批 v1.24 对局结束,旧进程进入
drained,但仍在服务观战与回放查询。 - T+20 旧进程
retired,网关连接表清零,v1.24 只保留回放服务。 - T+30 集群版本收敛到 v1.25,
CheckFleet通过,发布单关闭。
共存期里最危险的不是对局,而是“对局之外”的路径:好友邀请、战队、跨服观战、排行榜。这些请求不带房间上下文,很容易被路由到旧版本,再拿旧版本的数据结构去读新版本写的字段。发布检查要覆盖这些旁路,而不只是匹配和结算。
代码:发布单元与排空状态机
下面的 internal/statefulrelease/release.go 把单元类型、宽限期、阶段转移和强制回收放在一个文件里。时间通过参数注入,测试可以精确控制截止点;store.go 负责注册与查询,policy.go 负责版本漂移、发布波次和活动日历。
// file: internal/statefulrelease/release.go
// tier: L1
// verify: go test ./internal/statefulrelease/
package statefulrelease
import (
"errors"
"sort"
"time"
)
var (
ErrUnknownUnit = errors.New("statefulrelease: unknown unit")
ErrInvalidUnit = errors.New("statefulrelease: invalid unit")
ErrInvalidTransition = errors.New("statefulrelease: invalid phase transition")
)
// Kind is the release unit. It is chosen by what must stay consistent during
// the release, not by how the process is packaged.
type Kind string
const (
Room Kind = "room" // one match; shortest life, smallest blast radius
Process Kind = "process" // a match host; holds many rooms
Shard Kind = "shard" // world or map shard; authoritative long-lived state
)
func (k Kind) valid() bool { return k == Room || k == Process || k == Shard }
// Grace bounds how long a draining unit may hold the release open. A shard is
// not drained on a timer at all: it is migrated, so the grace is the longest
// migration the release plan is allowed to wait for.
func Grace(k Kind) (time.Duration, bool) {
switch k {
case Room:
return 30 * time.Second, true
case Process:
return 10 * time.Minute, true
case Shard:
return 30 * time.Minute, true
default:
return 0, false
}
}
type Phase string
const (
Serving Phase = "serving"
Draining Phase = "draining"
Drained Phase = "drained"
Forced Phase = "forced" // deadline hit while sessions were still running
Retired Phase = "retired"
)
// Unit is one release unit. Sessions counts running matches for a room or
// process, and in-flight map migrations for a shard.
type Unit struct {
ID string
Kind Kind
Version Version
Sessions int
Plan string // required for shards: the migration plan id
Phase Phase
Deadline time.Time
Forced int
}
type Event struct {
UnitID string
Phase Phase
Forced int
}
type Manager struct {
units map[string]*Unit
}
func New() *Manager { return &Manager{units: map[string]*Unit{}} }
// BeginDrain removes the unit from admission and starts its grace timer. The
// unit keeps serving existing sessions; that is the point of draining instead
// of restarting.
func (m *Manager) BeginDrain(id string, at time.Time) (Unit, error) {
u, ok := m.units[id]
if !ok {
return Unit{}, ErrUnknownUnit
}
if u.Phase != Serving {
return Unit{}, ErrInvalidTransition
}
grace, ok := Grace(u.Kind)
if !ok {
return Unit{}, ErrInvalidUnit
}
u.Phase = Draining
u.Deadline = at.Add(grace)
if u.Sessions == 0 {
u.Phase = Drained
u.Deadline = time.Time{}
}
return *u, nil
}
// EndSession records one session finishing normally.
func (m *Manager) EndSession(id string) (Unit, error) {
u, ok := m.units[id]
if !ok {
return Unit{}, ErrUnknownUnit
}
if u.Phase != Draining {
return Unit{}, ErrInvalidTransition
}
if u.Sessions > 0 {
u.Sessions--
}
if u.Sessions == 0 {
u.Phase = Drained
u.Deadline = time.Time{}
}
return *u, nil
}
// Tick forces units whose deadline has passed. Forced sessions are counted
// separately because they need compensation and a match audit entry.
func (m *Manager) Tick(at time.Time) []Event {
var out []Event
for _, u := range m.units {
if u.Phase != Draining || at.Before(u.Deadline) {
continue
}
if u.Sessions == 0 {
u.Phase = Drained
u.Deadline = time.Time{}
out = append(out, Event{UnitID: u.ID, Phase: Drained})
continue
}
u.Forced += u.Sessions
out = append(out, Event{UnitID: u.ID, Phase: Forced, Forced: u.Sessions})
u.Sessions = 0
u.Phase = Forced
u.Deadline = time.Time{}
}
sort.Slice(out, func(i, j int) bool { return out[i].UnitID < out[j].UnitID })
return out
}
func (m *Manager) Retire(id string) error {
u, ok := m.units[id]
if !ok {
return ErrUnknownUnit
}
if u.Sessions != 0 || (u.Phase != Drained && u.Phase != Forced) {
return ErrInvalidTransition
}
u.Phase = Retired
return nil
}
几个关键点:
Grace返回(duration, bool),未知类型直接拒绝,而不是给一个默认值。默认值会让新加的单元类型在没人注意的时候按错误窗口排空。BeginDrain在房间数为零时立刻转drained,测试里用空闲房间和忙碌进程两组用例覆盖。Tick的返回事件按 ID 排序,发布报告和补偿流程才能稳定比对。Retire要求对局数为零,且只能从drained或forced进入,防止“进程已下线、会话还挂着”。
store.go 里的 Add 对 shard 强制要求 Plan 非空;这条校验看似多余,实际拦住了“世界服没有迁移方案就直接排空”的操作。policy.go 里的 PlanWaves 要求金丝雀单元必须空闲,因为带对局的金丝雀不是金丝雀,只是一次普通发布。
发布期间看什么
发布期的观测指标和日常运维不是一套。日常看“系统是否活着”,发布期要看“变更是否在扩散”:
| 指标 | 基线口径 | 发布期异常信号 |
|---|---|---|
| 匹配成功率 | 分区分品类统计 | 某个区服骤降,通常是旧进程已摘除但匹配器还在投递 |
| 对局创建时长 | P50 与 P99 分开看 | P99 上升说明新进程冷启动或容量不足 |
| 结算差异 | 与回放比对 | 任何非零差异都要停下来,不能归因于“偶发” |
| 强制迁移数 | 按发布单聚合 | 超过批次房间数的 1% 需要人工确认是否继续 |
| 重连失败率 | 网关侧统计 | 反映连接注册表是否清理干净 |
这些指标要按批次切片,而不是看全局平均。全局错误率 0.1% 可能掩盖某个区服 5% 的失败;发布系统如果只提供聚合视图,值班同学会在大盘正常的情况下放过一个坏批次。
回滚预案
发布的另一半是“发坏了怎么办”。有状态服务的回滚有三条硬约束:
- 旧版本二进制要热备。回滚不是重新构建,构建时间不可控;上一个版本的镜像和配置必须已经在目标机器或就近仓库里。
- 数据必须能向前兼容。新版本写下的字段,旧版本要能忽略;这一条由下一节的兼容窗口保证,发布前就要确认,而不是回滚时才发现。
- 回滚也要排空。新版本进程上的对局同样要等结束,除非接受强制迁移;把回滚当成“再重启一次”会重复第一次事故。
回滚的决策权和触发条件属于灰度与开关的范畴,放到 11.3 展开。这里只强调一点:发布单在开始之前就要写清楚“什么条件下回滚、谁拍板、回滚后数据怎么处理”,事后再补这两行,通常意味着已经错过了最佳时机。
失败模式与兜底
- 排空期间仍有新进:先从匹配池和网关摘除,再调用
BeginDrain。顺序反了,Sessions永远减不到零,最后只能强制迁移。 - 强制迁移的补偿:
Forced计数要写进对局审计,按“不计负、补奖励”处理;只记日志不做补偿,等于把发布成本转给玩家。 - 世界服误排空:
Add拒绝没有迁移方案的 shard;如果业务确实要迁移,走独立的地图迁移流程,不混在常规发布里。 - 长尾对局拖过活动开始:宽限期到点就强制,不要因为“只剩三局”手动延长。延长一次,下一次值班同学就会默认可以延长。
- 发布窗口被活动挤掉:日历拒绝时返回明确原因,让发布计划提前一天调整,而不是当天临时找窗口。
常见错误与边界
- 把发布当成重启,直接滚动替换有状态进程。
- 只按进程灰度,不考虑区服维度,导致同一世界的玩家看到不同版本。
- 开了跨版本匹配却没有回放比对,结算分叉后无法归因。
- 宽限期设成最长对局时长,每次发布都等到最坏情况。
- 排空完成后忘记清理网关连接注册表,留下幽灵会话。
小结
有状态服务的发布,核心不是“怎么换二进制”,而是“怎么让状态在换之前自然结束”。发布单元由状态寿命决定,排空状态机把停新进、等对局、超时强制和回收串成一条可审计的路径,活动日历保证发布不和玩家活动抢时间。混版本要分开看协议、房间一致和跨版本匹配三件事,前两件是兼容问题,第三件是正确性问题。
发布之后,新旧版本还要共存一段时间,数据库和配置也要能同时被两边读写。下一节讨论这个共存期有多长、迁移分几步、回滚时数据还能不能被旧代码读懂。
阅读导航:上一节:10.3 日志留存、审计与取证 · 下一节:11.2 兼容窗口与数据迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。