一次强更把玩家卡在登录页
《裂隙战场》赛季更新,客户端要求升级到 1.4.0,服务端同时上线了新的匹配协议。发布当晚,一批没有完成更新的玩家在登录页反复重试:客户端提示“版本过低”,但没有提供跳过、游客登录或只读入口。
客服收到大量工单,运营临时发公告让玩家去应用商店更新。问题不在版本号本身,而在三件事没有提前定义:旧版本还能用到什么时候、强更时玩家有什么退路、服务端在什么条件下可以拒绝连接。
版本兼容不是发布工程师一个人的事,而是平台与产品之间的长期契约。以下场景与数字均为示例值。
三条版本轴
讨论“版本”时,最容易把三个不同的东西混在一起:
| 版本轴 | 描述对象 | 变化频率 | 谁关心 |
|---|---|---|---|
| 制品版本 | 客户端包、服务端二进制、SDK | 每次构建 | 发布、回滚、商店审核 |
| 能力版本 | 匹配协议、支付回调、账号接口 | 按契约演进 | 平台与调用方 |
| 租户灰度 | 哪些游戏、哪些服务器先看到新能力 | 按发布节奏 | 运营、客服、平台 |
制品版本用语义化版本管理是合适的:主版本表示不兼容变更,次版本表示向后兼容的新增,补丁表示修复。但能力版本不能简单等同于制品版本。一个客户端包可以包含多个能力,每个能力的兼容窗口不同。
游戏客户端版本与服务端能力必须解耦。服务端不能假设“客户端 1.4.0 一定支持新匹配协议”,因为渠道包、审核延迟、玩家不更新都会让版本分布长期存在长尾。
兼容矩阵写什么
一个能力的兼容矩阵至少包含四项:最小客户端版本、最大客户端版本、开始弃用时间、停止服务时间。上下界都是闭区间,边界值要能被测试覆盖。
以下为示例值:匹配能力支持客户端 1.2.0 到 1.4.0;1.3.0 起提示弃用;6 月 1 日停止服务。6 月 1 日之前,1.2.0 仍然可用;6 月 1 日零点起,即使版本在区间内也会被拒绝。
矩阵不是越宽越好。上下界放得太宽,服务端要维护大量分支;放得太窄,长尾玩家会被误伤。合理的做法是按真实版本分布定界,并给弃用留出足够的升级时间。
判定顺序决定故障形态
兼容判定不是一组并列的 if,而是有顺序的决策链:
| 顺序 | 检查 | 结果 | 为什么排在这里 |
|---|---|---|---|
| 1 | 能力是否已停止服务 | 拒绝 | 停止服务优先于版本区间 |
| 2 | 客户端是否低于最小版本 | 拒绝 | 旧客户端缺少必要协议 |
| 3 | 客户端是否高于最大版本 | 拒绝 | 新客户端可能使用未知字段 |
| 4 | 是否已进入弃用期 | 放行并告警 | 仍可用,但提示升级 |
| 5 | 其他情况 | 放行 | 正常路径 |
把“停止服务”放在最前面,是为了避免一个过期的矩阵行继续放行。把“高于最大版本”放在弃用之前,是因为新客户端调用旧服务端时,问题通常不是“该升级”,而是“服务端还不认识它”。
弃用时间线
弃用不是一句公告,而是一条有三个时间点的时间线:公告、开始弃用、停止服务。每个阶段之间要有最短间隔。
以下为示例值:公告到开始弃用至少 30 天;开始弃用到停止服务至少 90 天。这样产品有足够时间排期,平台也不会被无限期拖住。
| 阶段 | 时间点 | 服务端行为 | 产品侧动作 |
|---|---|---|---|
| active | 公告之前 | 正常服务 | 无 |
| announced | 公告日 | 正常服务,文档标记 | 评估升级影响 |
| deprecated | 开始弃用 | 正常服务,返回升级提示 | 排期升级、灰度验证 |
| sunset | 停止服务 | 拒绝旧能力 | 使用替代能力或降级 |
时间线要和矩阵分开管理。矩阵回答“今天谁能调用”,时间线回答“未来会怎么变”。两者混在一张表里,最容易出现“已经公告弃用,但矩阵里没有停止日期”的半成品状态。
双版本并行读写
能力升级最危险的不是新代码,而是数据。新旧版本同时读写同一份数据时,必须按扩展、迁移、收缩的顺序推进:
| 阶段 | 读 | 写 | 退出条件 |
|---|---|---|---|
| 扩展 | 旧字段 | 旧字段 + 新字段 | 新字段写入稳定,无解析错误 |
| 双读 | 旧字段优先,新字段兜底 | 双写 | 双读差异率低于阈值(示例值 0.1%) |
| 切换 | 新字段优先 | 双写 | 新字段成为主路径,回滚演练通过 |
| 收缩 | 新字段 | 新字段 | 旧字段连续多个发布周期无写入 |
每个阶段都要有退出条件,不能按“感觉稳定了”推进。双写阶段还要有对账任务,否则差异会一直存在,直到切换当天才暴露。
强制升级的边界
强制升级是最后手段,不是常规发布手段。可以强更的条件通常只有三类:
| 条件 | 例子 | 必须提供的退路 |
|---|---|---|
| 安全漏洞 | 旧协议可被重放攻击 | 安全公告、补偿、临时只读 |
| 协议不兼容 | 新旧客户端无法互通 | 分区、转服、游客登录 |
| 平台政策 | 应用商店下架旧包 | 引导更新、客服工单通道 |
无论哪种条件,都不能把玩家直接卡在登录页。至少要有三个兜底之一:只读模式、游客/临时账号、明确的更新引导与补偿。强更的目标是让玩家进入新版本,不是让玩家无法进入游戏。
强更前还要确认版本分布。如果旧版本占比仍然很高,强更会把一次发布事故放大成一次用户事故。版本分布数据应该来自服务端真实连接,而不是商店下载量。
租户级灰度
能力灰度应该按租户稳定分桶,而不是按请求随机。按请求随机会让同一个玩家在两次请求中看到不同行为,排障时无法复现。
灰度包含两个机制:百分比分桶和白名单。百分比用稳定哈希,保证同一租户始终落在同一侧;白名单用于内部测试公会、合作方或特定服务器,优先级高于百分比。
以下为示例值:新匹配能力按 1%、5%、25%、50%、100% 五档扩大,每档观察 24 小时;内部测试公会始终在白名单中。扩大条件不是时间到了,而是错误率、匹配时长、退出率都回到基线。
版本相关的度量
| 指标 | 定义 | 用途 |
|---|---|---|
| 旧版本连接占比 | 低于最小支持版本的连接数 / 总连接数 | 判断强更风险 |
| 弃用版本占比 | 处于弃用期的连接占比 | 判断升级推进速度 |
| 登录拦截率 | 因版本被拒绝的登录尝试占比 | 发现误伤 |
| 升级转化率 | 提示后完成升级的玩家占比 | 评估引导效果 |
| 灰度错误率 | 灰度租户与对照组的错误率差 | 决定是否扩大 |
这些指标要按渠道、区服、客户端版本分桶。只看看全量平均值,会把某个渠道的长尾掩盖掉。
六种反模式
| 反模式 | 表现 | 后果 |
|---|---|---|
| 版本号按字符串比较 | 1.10.0 < 1.9.0 | 新版本被误判为旧版本 |
| 矩阵没有停止日期 | 只有最小/最大版本 | 旧能力永远无法下线 |
| 强更没有兜底 | 直接拒绝登录 | 玩家卡在登录页,客服爆单 |
| 灰度按请求随机 | 同一玩家行为漂移 | 无法复现、无法归因 |
| 双写不做回填 | 只写新字段,不迁移旧数据 | 切换后大量数据缺失 |
| 时区不统一 | 时间点按本地时区解释 | 停止服务提前或延后数小时 |
上线检查表
- 制品版本、能力版本、租户灰度三条轴分别登记。
- 兼容矩阵包含最小版本、最大版本、弃用时间、停止时间。
- 版本比较按数值进行,覆盖
1.10.0与1.9.0的边界。 - 判定顺序先检查停止服务,再检查版本上下界。
- 弃用公告到停止服务满足组织规定的最短窗口。
- 双版本读写有扩展、双读、切换、收缩四阶段及退出条件。
- 强制升级至少提供只读、游客或明确引导之一。
- 灰度分桶对同一租户稳定,白名单优先级明确。
- 旧版本占比、登录拦截率、灰度错误率已接入看板。
- 所有时间点以 UTC 存储,展示时再转本地时区。
可运行片段
下面的包实现版本矩阵、兼容判定、弃用时间线和租户灰度。版本号的解析与数值比较在配套的 version.go,这里展示真正决定放行与拒绝的部分。
// file: internal/platformversion/compatibility.go
// tier: L1
// verify: go test ./internal/platformversion/
package platformversion
import (
"fmt"
"hash/fnv"
"time"
)
// Matrix is the compatibility contract for one capability. MinClient and
// MaxClient are inclusive. DeprecatedAt starts upgrade warnings; SunsetAt
// stops serving the recorded client range entirely.
type Matrix struct {
Capability string
MinClient Version
MaxClient Version
DeprecatedAt time.Time
SunsetAt time.Time
}
func (m Matrix) Validate() error {
if m.Capability == "" {
return fmt.Errorf("%w: capability is empty", ErrMatrix)
}
if m.MinClient.Compare(m.MaxClient) > 0 {
return fmt.Errorf("%w: min %s is above max %s", ErrMatrix, m.MinClient, m.MaxClient)
}
if !m.DeprecatedAt.IsZero() && !m.SunsetAt.IsZero() && !m.SunsetAt.After(m.DeprecatedAt) {
return fmt.Errorf("%w: sunset must follow deprecation", ErrMatrix)
}
return nil
}
// Evaluate checks sunset before version bounds. A client on a sunset
// capability is blocked even if its version is inside the recorded range;
// otherwise a stale matrix row would keep a retired feature alive.
func (m Matrix) Evaluate(client Version, at time.Time) Decision {
if !m.SunsetAt.IsZero() && !at.Before(m.SunsetAt) {
return Decision{Verdict: Blocked, Reason: "capability sunset"}
}
if client.Compare(m.MinClient) < 0 {
return Decision{Verdict: Blocked, Reason: "client is older than the minimum supported version"}
}
if client.Compare(m.MaxClient) > 0 {
return Decision{Verdict: Blocked, Reason: "client is newer than the supported maximum"}
}
if !m.DeprecatedAt.IsZero() && !at.Before(m.DeprecatedAt) {
return Decision{Verdict: Warned, Reason: "capability is deprecated; upgrade before sunset"}
}
return Decision{Verdict: Allowed}
}
// Phase is the timeline phase at a point in time.
type Phase string
const (
PhaseActive Phase = "active"
PhaseAnnounced Phase = "announced"
PhaseDeprecated Phase = "deprecated"
PhaseSunset Phase = "sunset"
)
// Timeline is the published deprecation schedule. It is separate from Matrix:
// the matrix says who may call today, the timeline says what changes and when.
type Timeline struct {
Capability string
AnnouncedAt time.Time
DeprecatedAt time.Time
SunsetAt time.Time
}
type TimelinePolicy struct {
MinNotice time.Duration
MinSupport time.Duration
}
func DefaultTimelinePolicy() TimelinePolicy {
return TimelinePolicy{MinNotice: 30 * 24 * time.Hour, MinSupport: 90 * 24 * time.Hour}
}
func (t Timeline) Validate(p TimelinePolicy) error {
if t.Capability == "" {
return fmt.Errorf("%w: capability is empty", ErrTimeline)
}
if t.AnnouncedAt.IsZero() || t.DeprecatedAt.IsZero() || t.SunsetAt.IsZero() {
return fmt.Errorf("%w: all three timestamps are required", ErrTimeline)
}
if !t.DeprecatedAt.After(t.AnnouncedAt) {
return fmt.Errorf("%w: deprecation must follow announcement", ErrTimeline)
}
if !t.SunsetAt.After(t.DeprecatedAt) {
return fmt.Errorf("%w: sunset must follow deprecation", ErrTimeline)
}
if t.DeprecatedAt.Sub(t.AnnouncedAt) < p.MinNotice {
return fmt.Errorf("%w: %s < %s", ErrNotice, t.DeprecatedAt.Sub(t.AnnouncedAt), p.MinNotice)
}
if t.SunsetAt.Sub(t.DeprecatedAt) < p.MinSupport {
return fmt.Errorf("%w: %s < %s", ErrSupport, t.SunsetAt.Sub(t.DeprecatedAt), p.MinSupport)
}
return nil
}
func (t Timeline) Phase(at time.Time) Phase {
switch {
case !at.Before(t.SunsetAt):
return PhaseSunset
case !at.Before(t.DeprecatedAt):
return PhaseDeprecated
case !at.Before(t.AnnouncedAt):
return PhaseAnnounced
default:
return PhaseActive
}
}
func (t Timeline) DaysUntilSunset(at time.Time) int {
if !at.Before(t.SunsetAt) {
return 0
}
return int(t.SunsetAt.Sub(at).Hours() / 24)
}
// Rollout is tenant-level exposure for a new capability version. Percent is a
// stable hash bucket; Allow names tenants included regardless of the bucket,
// which is how an internal test guild gets a build early.
type Rollout struct {
Percent int
Allow []string
}
func (r Rollout) Validate() error {
if r.Percent < 0 || r.Percent > 100 {
return fmt.Errorf("%w: %d", ErrPercent, r.Percent)
}
return nil
}
func (r Rollout) Includes(tenant string) bool {
if tenant == "" {
return false
}
for _, allowed := range r.Allow {
if allowed == tenant {
return true
}
}
if r.Percent >= 100 {
return true
}
if r.Percent <= 0 {
return false
}
h := fnv.New32a()
_, _ = h.Write([]byte(tenant))
return int(h.Sum32()%100) < r.Percent
}
读代码:四个设计选择
第一,Matrix.Evaluate 先判断 SunsetAt,再判断版本上下界。停止服务是硬边界,不能被“版本仍在区间内”覆盖。这个顺序如果反过来,过期能力会继续服务。
第二,Timeline.Validate 把公告、弃用、停止三个时间点都设为必填,并检查两段间隔。只填一个停止日期,不叫时间线,叫愿望。
第三,Rollout.Includes 用 FNV 哈希做稳定分桶,白名单优先。同一租户多次请求得到同一结果,灰度扩大时也不会把已进入的租户踢出去。
第四,Version.Compare 按数值比较三个分量。字符串比较会把 1.10.0 排在 1.9.0 前面,这是版本治理里最常见、也最隐蔽的错误。
失败路径与兜底
| 失败路径 | 现象 | 处理 |
|---|---|---|
| 时钟或时区不一致 | 停止服务提前/延后 | 全部存 UTC,边界用同一时钟源 |
| 客户端伪造版本 | 旧客户端绕过限制 | 服务端校验签名与能力握手,不信任自报 |
| 矩阵配置错误 | 上下界写反或漏填 | 启动时校验,配置变更走评审 |
| 灰度扩大过快 | 新能力错误率上升 | 自动回滚到上一档,保留白名单 |
| 旧版本长尾 | 强更后仍有大量拦截 | 提供只读/游客通道,延长过渡期 |
兜底原则是:版本治理的目标是让玩家继续玩,而不是让版本号整齐。任何拒绝连接的策略,都必须同时回答“玩家下一步能做什么”。
与相邻章节的边界
这一节定义平台能力的版本契约。具体的活动配置变更与热更新节奏,在 2.2 热更新与灰度 ;从旧系统迁移到新平台的渐进路径,在 10.1 绞杀者与增量迁移 。
如果兼容问题最终变成事故,复盘方法可以对照 7.2 复盘方法与改进闭环 ,但本节只讨论事前契约。
小结
版本兼容的核心不是“支持多少个版本”,而是三条轴各自有边界、有顺序、有退出条件。制品版本管构建,能力版本管契约,租户灰度管暴露;矩阵回答今天谁能调用,时间线回答未来怎么变。
把这三件事写进契约,强更才不会变成玩家事故。平台化走到这里,能力有承诺、交付有路径、演进有窗口,接下来才谈得上日常的 LiveOps 变更。
阅读导航:上一节:1.2 内部开发者平台 · 下一节:2.1 活动编排与配置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。