《游戏服务端架构·平台化与架构治理》1.3 平台服务的版本与兼容

客户端强更把玩家卡在登录页,往往不是版本号写错,而是兼容窗口和弃用时间线没有契约。本文区分制品版本、能力版本与租户灰度,给出兼容矩阵、判定顺序、双版本并行读写与强制升级边界,并用 Go 实现版本矩阵、弃用阶段和稳定灰度分桶,最后给出避免误伤长尾玩家的检查表。

一次强更把玩家卡在登录页

《裂隙战场》赛季更新,客户端要求升级到 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 活动编排与配置 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查