17.1 灰度与回滚
《方块冲刺》在第 14 到 16 章已经能构建、能发布、能被冒烟测试盯住。但「能发布」和「敢发布」是两件事:一次 100% 全量推送,如果第 30 秒就发现新版本在低端安卓机上崩,你手上没有刹车。这一节补上刹车——灰度与回滚。
本节把《方块冲刺》推进到「可控上线」:先想清楚三端各自能用的灰度手段,再落一个远端配置组件做开关与版本门,用设备哈希做分组,最后备好三套回滚预案。收口时工程里会多出
assets/scripts/platform/remote-config.ts与一份configs/remote.json的约定。
与站内 game/client/ 下那 116 篇运营类文章的分工:那些文章讲「运营策略该怎么做」,本节讲的是工程侧怎么让策略落地——开关放在哪、谁来读、读不到时的兜底是什么。
17.1.1 为什么小游戏比原生更需要灰度
原生 App 的发布链路里,商店审核本身就是一道缓冲:版本提交后要等审核,你还有时间改主意。小游戏和 H5 没有这道缓冲——wx 后台点一下「发布」,几分钟内所有用户就是新版本,而且是热的,用户不会重启。
这带来两个后果:
- 坏版本传播速度 = 你的 CDN 传播速度。 原生要等用户手动更新,小游戏是「下次进入即新版」。
- 回滚同样快,但前提是你留了后门。 如果新版把启动逻辑写死了,你连一个「切回旧版」的开关都推不下去。
所以卷二把灰度当成发布链路的默认组成部分,而不是「大厂才用的高级玩法」。哪怕你只有几千用户,一次 10% 灰度也能让你在崩之前先听到响。
17.1.2 三端各自能用的灰度手段
三端的灰度能力差别很大,不能一套方案打天下。下面这张表是《方块冲刺》上线前实测的结论:
| 端 | 灰度手段 | 生效速度 | 粒度 | 备注 |
|---|---|---|---|---|
| H5 | 入口 HTML 上的远端开关 + 版本门 | 刷新即生效 | 用户级 | 最容易做,改 CDN 上一个 JSON 即可 |
| 微信小游戏 | 后台「分阶段发布」+ 远端开关 | 后台分钟级,开关刷新级 | 后台按比例 / 开关按用户 | 后台能力用足,开关做兜底 |
| 原生 | 远端开关 + 应用商店分阶段发布 | 开关刷新级,商店天级 | 开关按用户 | 商店回滚慢,开关是主力 |
三条结论:
- 远端开关是三端的公共底座。 微信和原生都有各自的分阶段发布,但它们都慢或不可控,真正能让你在 5 分钟内刹车的只有「游戏启动时读一个远端 JSON」。
- H5 不要只依赖入口 HTML。 入口 HTML 缓存策略一旦出错,开关改不动,所以开关必须由运行时的代码去拉,而不是编译进 HTML。
- 原生不要指望商店回滚。 商店审核周期是「天」,你要么用开关绕过坏逻辑,要么接受坏版本活几天。
17.1.3 版本门:先确认「该不该放行」
灰度分两层:第一层是版本门——这个包到底允不允许运行;第二层是功能开关——允许运行了,某个功能开不开。先写版本门。
版本门解决的问题很具体:你在 1.3.0 里发现了一个致命 bug,想直接拦住这批用户,让他们走降级逻辑(提示更新、或者进入只读的离线模式)。这比「回滚」还早一步。
先看纯逻辑部分。分桶与版本比较与引擎无关,可以直接单测——这正是卷二里 L1 的位置:
// core/remote-gate.ts —— 与引擎无关,可 vitest 直接测
export interface GateConfig {
minVersion: string;
killSwitch: boolean;
}
/** 语义化版本比较:a < b 返回 -1,相等 0,a > b 返回 1 */
export function compareVersion(a: string, b: string): number {
const pa = a.split('.').map((n) => parseInt(n, 10));
const pb = b.split('.').map((n) => parseInt(n, 10));
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
const x = pa[i] ?? 0;
const y = pb[i] ?? 0;
if (x !== y) return x < y ? -1 : 1;
}
return 0;
}
export function shouldBlock(current: string, gate: GateConfig): boolean {
if (gate.killSwitch) return true;
return compareVersion(current, gate.minVersion) < 0;
}
compareVersion 故意不用 localeCompare:版本号 1.10.0 和 1.9.0 用字符串比会得出错误结论,必须逐段转数字。这个坑在卷二第 17.2 节的热更版本比对里还会再遇到一次。
版本门读到的 minVersion 一旦上调,低于它的客户端就被拦。这个开关的危险之处在于:一旦推错,等于全量停服。所以它必须和 17.1.5 的回滚预案绑定——推 minVersion 之前先确认你能在 60 秒内把它改回来。
17.1.4 远端配置组件
把开关读进游戏,用一个挂在常驻节点上的组件。这是 L2 片段,每个 cc 符号都对着 cc.d.ts 核过:
import { _decorator, Component, JsonAsset, assetManager, sys } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('RemoteConfig')
export class RemoteConfig extends Component {
@property
configUrl = '';
private _flags: Record<string, boolean> = {};
async load(): Promise<void> {
const url = `${this.configUrl}?t=${Date.now()}`;
await new Promise<void>((resolve) => {
assetManager.loadRemote<JsonAsset>(url, (err, asset) => {
if (!err && asset) {
const data = asset.json as Record<string, unknown> | null;
this._flags = (data?.flags ?? {}) as Record<string, boolean>;
}
resolve();
});
});
}
isOn(key: string): boolean {
return this._flags[key] === true;
}
get channel(): string {
if (sys.platform === sys.Platform.WECHAT_GAME) return 'wx';
if (sys.isNative) return 'native';
return 'web';
}
}
几个刻意的选择:
- URL 后面拼
?t=Date.now()。 小游戏的 CDN 与浏览器缓存都可能在开关上帮你「省钱」,结果你改了远端 JSON 却看到旧值。加时间戳是最省事的破缓存手段。 - 读不到就用默认值。
this._flags初始是空对象,isOn('xxx')返回false。开关的兜底永远是「关」——因为「关」意味着走老逻辑,是已知安全的。 channel用sys.platform判断,不用字符串。 卷二里判错平台是最高频的一类 bug,第 18.2 节会专门复盘。sys.Platform.WECHAT_GAME与sys.Platform.MOBILE_BROWSER都是 3.8.8 里真实存在的枚举成员。
组件挂到常驻节点上,load() 在启动流程里 await 一次,之后全工程用 isOn() 读开关。
17.1.5 分桶:谁进灰度
有了开关,还要决定「谁」被开关命中。最朴素的做法是随机——但随机有个致命问题:同一个用户每次启动可能被分到不同组,体验前后不一致,日志也没法归因。
正确做法是确定性哈希分桶:用设备 ID 或用户 ID 哈希出一个稳定值,取模 100,落在 [0, 灰度比例) 区间内的用户进灰度。同一台设备永远在同一桶里:
// core/bucket.ts —— 纯函数,可单测
export function hashBucket(id: string, buckets: number): number {
let h = 2166136261;
for (let i = 0; i < id.length; i += 1) {
h ^= id.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return Math.abs(h) % buckets;
}
export function inGray(id: string, percent: number): boolean {
return hashBucket(id, 100) < percent;
}
用的是 FNV-1a 哈希。它不加密,只求「稳定 + 均匀」,正是分桶需要的性质。inGray(deviceId, 10) 就是 10% 灰度。
注意:
Math.imul保证 32 位整数乘法不丢精度,这是 FNV-1a 在 JS 里的标准写法;用普通*会在超过 2^53 时失真,导致分桶不均。
17.1.6 灰度期看什么指标
灰度不是「推出去等着」,而是「推出去盯着一张表」。这张表在 16.3 节的埋点里已经有了数据源,这里只需要定义判定阈值:
| 指标 | 灰度组基线 | 报警阈值 | 含义 |
|---|---|---|---|
| 崩溃率 | < 0.3% | > 1% 持续 10 分钟 | 稳定性问题,先回滚再看日志 |
| 启动成功率 | > 99% | < 97% | 资源加载或版本门出了问题 |
| 首局完成率 | > 85% | 比全量低 10 个百分点 | 玩法回归,可能改坏了手感 |
| 平均帧率 | 55–60 fps | < 40 fps 占比超 20% | 性能回归,见第 13 章 |
| 次日留存 | 与全量持平 | 低 5 个百分点以上 | 慢性问题,灰度期看不到 |
关键认知:前四项能在灰度窗口(通常 1–2 小时)内看出来,留存看不出来。 所以灰度的目的是抓「硬伤」,不是验证「产品好不好」。别把灰度窗口拉长到几天来等留存——那时坏版本早扩散了。
阈值要提前定死,不要现场拍脑袋。现场判断的结果往往是「再等等看」,而「再等等看」是小游戏灰度最常见的翻车方式。
17.1.7 回滚的三套预案
灰度一旦命中问题,回滚速度决定了损失。按「从快到慢」备三套:
| 预案 | 手段 | 生效时间 | 代价 | 何时用 |
|---|---|---|---|---|
| P0 开关回滚 | 把远端 JSON 里的 flags 改回 false | 分钟级 | 无 | 新功能有问题、老逻辑还在 |
| P1 版本门拦停 | 上调 minVersion,把坏版本用户拦住 | 分钟级 | 该版本用户全被拦 | 新版启动即崩,无法开关绕过 |
| P2 回滚构建产物 | CDN 上把旧版本目录换回来 | 分钟级(H5)/ 天级(原生) | 需保留上一版产物 | P0/P1 都失效 |
P2 的前提是「保留上一版产物」,这条要写进发布流程:每次发布时,build/web-mobile/ 不要原地覆盖,而是按版本号归档,例如 build/web-mobile/1.3.0/,CDN 上只切换一个指向当前版本的入口。这样回滚就是「把入口指回 1.2.1」,而不是「重新构建一次」——重新构建至少几分钟,还可能因为源码已改而构建不出旧版本。
把预案写成值班同学能照做的步骤,而不是「看情况处理」:
P0 开关回滚(预计 3 分钟)
1. 打开 CDN 上的 config/remote.json,把出问题的 flag 改成 false
2. 用 ?t= 时间戳参数 curl 一次,确认返回已更新
3. 微信后台「版本管理」里确认无需额外操作(开关是运行时读的)
4. 在埋点看板确认灰度组崩溃率 10 分钟内回落
P1 版本门拦停(预计 5 分钟)
1. 把 remote.json 的 minVersion 上调到「坏版本号 + 1」
2. 自测:用坏版本号模拟请求,确认 shouldBlock 返回 true
3. 确认兜底页(提示更新)已随旧包发布过,否则用户会看到黑屏
P2 产物回滚(H5 预计 3 分钟,原生天级)
1. 把 CDN 入口从 /1.3.0/ 指回 /1.2.1/
2. 验证首页返回的是 1.2.1 的资源
3. 原生端走商店审核,同时用 P1 拦停降低损失
值班同学照这三段做就行。注意 P1 的第 3 步——兜底页必须提前随包发过,否则拦停后用户看到的是白屏,比崩溃更糟。
17.1.8 在编辑器里看什么
本节涉及两处工程文件,都需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
第一处是常驻节点上的组件挂载。打开 assets/scenes/main.scene,在层级管理器里会看到 Main 场景根下有一个名为 PersistRoot 的节点,它的 _components 数组里有一项:
{
"__type__": "RemoteConfig",
"_name": "",
"node": { "__id__": 12 },
"configUrl": "https://cdn.example.com/blockdash/config/remote.json"
}
__type__ 是脚本的类名(由 @ccclass('RemoteConfig') 决定),configUrl 是你在属性面板里填的完整地址。注意 configUrl 不能填相对路径——小游戏里没有「当前页面」这个概念,相对路径会解析失败。
第二处是 configs/remote.json,它放在 assets/configs/ 下,作为本地兜底配置:远端拉不到时用这份。它的结构是:
{
"flags": {
"new_level_select": false,
"reward_double": false,
"analytics_v2": true
},
"minVersion": "1.0.0",
"killSwitch": false
}
打开编辑器后,选中 assets/configs/remote.json,属性面板会显示它是一个 JsonAsset,json 字段里就是上面这段对象。这份本地配置的版本必须永远比远端保守——远端出问题时它是最后一道防线。
17.1.9 灰度上线前的自检
按这几条逐项打勾,缺一条就不要开灰度:
-
remote.json已在 CDN 上可访问,且返回Content-Type: application/json - 游戏内读开关走的是运行时拉取,不是编译进包的常量
- 读不到远端配置时,所有开关默认
false(走老逻辑) - 分桶用设备 ID 哈希,同一设备重复启动结果一致
- 灰度比例先设 5%,观察 30 分钟再抬到 20%
- 三条回滚预案各演练过一次,且值班同学手上有一份步骤
- 兜底页(提示更新)已随当前线上包发布,P1 才可用
最后一条最容易漏。很多人第一次配版本门时,兜底页还没发出去就先把 minVersion 写高了,结果拦停的用户直接黑屏。
17.1.10 工程收口
这一章做完,工程里多了这些东西:
assets/scripts/platform/remote-config.ts:远端配置组件,挂常驻节点。assets/scripts/core/remote-gate.ts:版本门纯逻辑,可单测。assets/scripts/core/bucket.ts:确定性分桶,可单测。assets/configs/remote.json:本地兜底配置。- 发布流程约定:构建产物按版本号归档,CDN 入口可切换。
下一节接手时,你会需要「不改包也能修线上逻辑」——这就是热更。下一节我们讲热更的边界:哪些能热更、哪些绝对不能,以及版本比对为什么又一次踩了语义化版本这个坑。
阅读导航:上一节:目录 · 下一节:17.2 热更与线上修复 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。