数值出问题的那十五分钟
《潮汐方舟》在周三晚上推送了一次战斗数值热更。二十分钟后,客服开始收到「伤害计算不对」的反馈;又过了十分钟,排行榜出现异常数据。发布负责人没有去改代码,而是打开回滚清单:冻结灰度、切回上一版战斗数值、恢复配置快照、强制客户端重新拉取版本索引,目标是在 15 分钟内完成。
同一工作室的《潮汐消消》是另一种节奏。它的热更内容是活动配置、关卡、皮肤和文本,不改玩法规则与付费点;配置出问题时,回滚时限是 30 分钟。消消的文件数 12,800、总资源 480MB,双周一次平均 12MB 的增量;方舟的文件数 86,000、总资源 1,840MB,每周一次平均 45MB。资源规模差了一个量级,回滚时限反而更短。
原因不在资源大小,而在变更性质。战斗数值影响实时对局和排行榜,错误会持续放大;活动配置影响的是入口和展示,回滚可以稍微从容。回滚时限不是拍脑袋的 SLA,而是由「错误传播速度」和「可回滚对象」共同决定的。
两种回滚,对应两种变更
两个案例的回滚对象不同,流程也不同。
| 维度 | 潮汐消消 | 潮汐方舟 |
|---|---|---|
| 主要热更内容 | 活动配置、关卡、皮肤、文本 | 战斗数值、赛季配置、活动、UI、语音 |
| 回滚对象 | 配置快照 | 战斗数值与配置快照 |
| 回滚时限 | 30 分钟 | 15 分钟 |
| 变更风险 | 展示与入口错误 | 实时对局与排行榜错误 |
| 代码热更 | 不涉及 | 仅 Android,iOS 只走资源与配置 |
消消的回滚更像「换一份配置」。配置是纯数据,旧快照保留着,切回去只需要改指针并让客户端重新拉取;30 分钟的预算里,大部分时间用于确认影响范围和通知运营,真正切换的动作很短。
方舟的回滚更像「停血」。战斗数值已经进入实时对局,回滚不只是换数据,还要让在线客户端尽快丢掉旧数值、让排行榜停止写入异常数据、让正在进行的对局走完或安全终止。15 分钟里,冻结灰度和切清单必须并行,观测窗口只能压到几分钟。
两条流程的共同点是:回滚对象必须事先准备好。配置快照、上一版清单、旧资源索引都要在发布前落盘并校验;事故发生时才去找「上一版是什么」,15 分钟肯定不够。
什么能回滚,什么只能前滚
回滚能力不是无限的。两个案例都遵守同一组红线:不得通过热更改支付流程、抽奖概率、核心玩法规则;不得热更原生权限与隐私声明相关逻辑。这些内容一旦发布,就不能靠「切回上一版」解决,只能走商店新版本覆盖。
| 对象 | 能否回滚 | 原因 | 正确做法 |
|---|---|---|---|
| 活动配置、文本 | 能 | 纯数据,旧快照可保留 | 切配置指针,客户端重拉 |
| 战斗数值、赛季配置 | 能,但时限更短 | 进入实时对局,错误传播快 | 冻结灰度 + 切快照 + 停榜 |
| UI 资源、语音 | 能 | 只影响展示,不改变规则 | 切资源清单,按需重新下载 |
| Android 代码补丁 | 有限 | 已安装补丁的客户端不会自动回退 | 关闭开关或发新补丁覆盖 |
| 商店整包 | 不能 | 应用商店不提供降级通道 | 发新版本覆盖,等待审核 |
| 支付、抽奖、权限逻辑 | 不能 | 合规与资金安全红线 | 走完整发版与审核流程 |
「整包不可回滚」是最容易被低估的边界。方舟每 6 周发一次商店整包,整包差量 60 到 90MB;一旦整包里的原生逻辑出问题,回滚手段不是「退回上一版」,而是「尽快发一个修复版」。这意味着整包发布前必须有更严格的灰度和更长的观察窗口,因为它的失败没有退路。
Android 代码补丁也不是真正回滚。补丁一旦被客户端应用,旧代码已经被替换;所谓回滚,是发一个把行为改回去的新补丁,或者通过服务端开关让新代码不生效。开关方案更快,但要求代码在发布时就预留开关,而不是事故后临时加。
回滚计划的时间预算
15 分钟和 30 分钟不是口号,要拆成可执行步骤并逐项估时。下面是一组示例拆解,单步耗时是计划参数,不是案例实测值:
| 顺序 | 动作 | 预算 | 并行关系 |
|---|---|---|---|
| 1 | 冻结灰度,停止新客户端拉取 | 1 分钟 | 与第 2 步并行 |
| 2 | 切换活动清单到上一版 | 2 分钟 | 与第 1 步并行 |
| 3 | 恢复快照并校验哈希 | 3 分钟 | 依赖第 2 步 |
| 4 | 强制客户端重新同步版本索引 | 2 分钟 | 依赖第 3 步 |
| 5 | 观察崩溃、ANR 与下载成功率 | 4 分钟 | 最后执行 |
合计 12 分钟,留给 15 分钟时限 3 分钟余量。余量不是浪费,而是给「切换失败后重试一次」用的;没有余量的计划,第一次失败就会超时。
消消的 30 分钟预算可以容纳更长的确认环节:先确认影响范围,再通知运营和客服,然后切配置,最后观察活动入口和付费展示。它的步骤更多,但单步风险更低,因为配置错误通常不会在几秒内扩散到排行榜。
时间预算必须定期演练。纸面上的 2 分钟切清单,在事故现场可能因为权限、审批或工具故障变成 10 分钟;只有演练过的步骤才能写进 SLA。
版本收敛:七天升级比例
回滚解决「当前版本错了」,版本收敛解决「旧版本还有多少人」。两个案例都使用同一个口径:发布后 7 天内升级到新版本的用户占比。
收敛率受三个因素影响:
- 发布节奏。消消双周一次增量、每 8 周一次商店整包;方舟每周一次增量、每 6 周一次整包。节奏越快,旧版本堆积的窗口越短,但每次发布可用的观察时间也越少。
- 新增安装。消消每月新增安装 42 万,方舟 8 万。新装用户直接拿到最新版本,会自然抬高收敛率;消消的 DAU 是 80 万、方舟 25 万,新增占比更高,因此消消的总体收敛数字更容易被新装拉动,评估老用户升级必须分群看。
- 下载条件。消消的下载成功率是 97.5%,弱网下 92%;方舟是 98.6%。消消低端机占 35%、弱网会话占 22%,方舟分别是 15% 和 14%,同样的补丁在消消的长尾里更难送达。
收敛率不是越高越好,也不是越低越差。它要和发布策略匹配:如果产品选择「不强制更新」,收敛率天然更慢;如果选择「强更」,收敛率会快速接近 100%,但弱网和低端机用户的流失风险也会上升。指标要回答的是「旧版本是否还在产生不可接受的成本」,而不是「能不能明天就全员升级」。
收敛慢的三个原因与处置
| 原因 | 表现 | 处置 |
|---|---|---|
| 渠道审核慢 | iOS 版本长期落后 | 提前提审,资源与配置走热更补齐 |
| 下载失败 | 弱网、低端机反复重试 | 提高差量命中率,支持断点续传与后台下载 |
| 玩家不更新 | 老版本仍可进入游戏 | 设置最低版本门槛,或对旧版本限制新玩法 |
渠道审核是外部约束,能做的只有提前量和分层发布。方舟的 iOS 只走资源与配置,代码变更必须等审核;因此 iOS 的版本收敛天然慢于 Android,观测时要按渠道分开,而不是看总体。
下载失败是技术问题。消消的弱网下载成功率 92%,意味着每 100 个弱网用户里有 8 个可能卡在更新;提高差量命中率、减少补丁体积、支持断点续传,都是直接手段。12.x 会展开下载器设计。
玩家不更新是产品问题。老版本还能玩,玩家就没有动力升级;设置最低版本门槛可以强制收敛,但会把一部分玩家挡在门外。这个取舍不属于回滚流程,却决定了回滚之后的版本分布。
实现
下面的文件把回滚目标选择、回滚计划编排和版本收敛计算落成一次计算。输入是版本历史、事故时间、客户端版本和时限,输出是回滚或前滚模式、目标版本、步骤、总耗时、是否在时限内以及告警。
// file: src/hotfix/rollback.ts
// tier: L1
// verify: node --test test/hotfix/rollback.test.ts
export type ReleaseKind = "config" | "resource" | "code" | "store";
export interface ReleaseVersion {
readonly version: string;
readonly kind: ReleaseKind;
readonly releasedAt: string;
readonly rollbackable: boolean;
readonly minClient: string;
}
export interface RollbackRequest {
readonly currentVersion: string;
readonly incidentAt: string;
readonly clientVersion: string;
readonly deadlineMinutes: number;
readonly forwardFixAvailable: boolean;
}
export interface RollbackStep {
readonly order: number;
readonly action: string;
readonly minutes: number;
}
export interface RollbackPlan {
readonly mode: "rollback" | "forward-fix";
readonly target: ReleaseVersion | null;
readonly steps: readonly RollbackStep[];
readonly totalMinutes: number;
readonly withinDeadline: boolean;
readonly warnings: readonly string[];
}
export interface VersionShare {
readonly version: string;
readonly users: number;
}
function validateVersion(version: string): void {
if (!/^\d+(\.\d+)*$/.test(version)) {
throw new Error(`invalid version: ${version}`);
}
}
export function compareVersions(left: string, right: string): number {
validateVersion(left);
validateVersion(right);
const a = left.split(".").map((part) => Number.parseInt(part, 10));
const b = right.split(".").map((part) => Number.parseInt(part, 10));
const length = Math.max(a.length, b.length);
for (let index = 0; index < length; index += 1) {
const x = a[index] ?? 0;
const y = b[index] ?? 0;
if (x !== y) return x < y ? -1 : 1;
}
return 0;
}
function requireRelease(
history: readonly ReleaseVersion[],
version: string,
): ReleaseVersion {
const found = history.find((release) => release.version === version);
if (found === undefined) {
throw new Error(`unknown release: ${version}`);
}
return found;
}
export function selectRollbackTarget(
history: readonly ReleaseVersion[],
request: RollbackRequest,
): ReleaseVersion | null {
const incident = Date.parse(request.incidentAt);
if (Number.isNaN(incident)) {
throw new Error(`invalid incidentAt: ${request.incidentAt}`);
}
const current = requireRelease(history, request.currentVersion);
const candidates = history.filter((release) => {
if (release.version === current.version) return false;
if (!release.rollbackable) return false;
const released = Date.parse(release.releasedAt);
if (Number.isNaN(released) || released >= incident) return false;
if (compareVersions(release.version, current.version) >= 0) return false;
return compareVersions(release.minClient, request.clientVersion) <= 0;
});
candidates.sort((a, b) => {
const byTime = Date.parse(b.releasedAt) - Date.parse(a.releasedAt);
if (byTime !== 0) return byTime;
return compareVersions(b.version, a.version);
});
return candidates[0] ?? null;
}
export function planRollback(
history: readonly ReleaseVersion[],
request: RollbackRequest,
): RollbackPlan {
if (!Number.isInteger(request.deadlineMinutes) || request.deadlineMinutes <= 0) {
throw new Error(`invalid deadline: ${request.deadlineMinutes}`);
}
const current = requireRelease(history, request.currentVersion);
const warnings: string[] = [];
const target = current.kind === "store" ? null : selectRollbackTarget(history, request);
if (current.kind === "store") {
warnings.push("store packages cannot be rolled back; only a new store version can replace them");
}
let mode: "rollback" | "forward-fix";
const actions: { action: string; minutes: number }[] = [];
if (target !== null) {
mode = "rollback";
actions.push({ action: `freeze rollout of ${current.version}`, minutes: 1 });
actions.push({
action: `switch active manifest ${current.version} -> ${target.version}`,
minutes: 2,
});
actions.push({
action: `restore ${target.kind} snapshot ${target.version} and verify checksum`,
minutes: 3,
});
actions.push({ action: "force clients to re-sync the version index", minutes: 2 });
actions.push({ action: "watch crash, ANR and download success", minutes: 4 });
} else {
mode = "forward-fix";
if (request.forwardFixAvailable) {
actions.push({ action: `build forward fix on top of ${current.version}`, minutes: 10 });
actions.push({ action: "canary the forward fix on the internal group", minutes: 5 });
actions.push({ action: "promote the forward fix and watch the gates", minutes: 5 });
} else {
actions.push({ action: "submit a new store package and wait for review", minutes: 60 });
warnings.push("no forward fix is available; the store package is the only path");
}
}
const steps: RollbackStep[] = actions.map((item, index) => ({
order: index + 1,
action: item.action,
minutes: item.minutes,
}));
const totalMinutes = steps.reduce((sum, step) => sum + step.minutes, 0);
if (totalMinutes > request.deadlineMinutes) {
warnings.push(
`plan needs ${totalMinutes} minutes but the deadline is ${request.deadlineMinutes} minutes`,
);
}
return {
mode,
target,
steps,
totalMinutes,
withinDeadline: totalMinutes <= request.deadlineMinutes,
warnings,
};
}
export function convergenceRate(
shares: readonly VersionShare[],
targetVersion: string,
): number {
const seen = new Set<string>();
let total = 0;
let target = 0;
for (const share of shares) {
if (seen.has(share.version)) {
throw new Error(`duplicate version share: ${share.version}`);
}
seen.add(share.version);
if (!Number.isInteger(share.users) || share.users < 0) {
throw new Error(`invalid users for ${share.version}: ${share.users}`);
}
total += share.users;
if (share.version === targetVersion) target += share.users;
}
if (total === 0) return 0;
return Number((target / total).toFixed(4));
}
export function formatRollbackPlan(plan: RollbackPlan): string {
const lines = [
`mode ${plan.mode}`,
`target ${plan.target === null ? "none" : plan.target.version}`,
`budget ${plan.totalMinutes}min within=${plan.withinDeadline ? "yes" : "no"}`,
];
for (const step of plan.steps) {
lines.push(`${step.order}\t${step.minutes}min\t${step.action}`);
}
for (const warning of plan.warnings) {
lines.push(`warning\t${warning}`);
}
return lines.join("\n");
}
compareVersions 按点分段做数值比较,1.10.0 大于 1.9.0;字符串比较会得到相反结果,这是版本号处理最常见的错误。
selectRollbackTarget 只考虑「早于事故时间、可回滚、版本低于当前、最低客户端版本不高于当前客户端」的版本,再按发布时间倒序选最新一个。它不会选一个需要更高客户端的版本,也不会选事故之后才发布的版本。
planRollback 先处理商店整包:整包不可回滚,直接进入前滚模式并给出告警。普通版本则编排五步回滚,累计耗时并与时限比较;没有可用目标时,根据是否有前滚修复给出不同步骤。耗时是计划参数,不是案例实测值。
convergenceRate 按版本统计用户占比,拒绝重复版本和负数;formatRollbackPlan 输出模式、目标、预算和逐步动作,适合贴进事故记录。
运行结果与边界
用一组示例版本历史运行,输出如下:
mode rollback
target 1.11.0
budget 12min within=yes
1 1min freeze rollout of 1.13.0
2 2min switch active manifest 1.13.0 -> 1.11.0
3 3min restore resource snapshot 1.11.0 and verify checksum
4 2min force clients to re-sync the version index
5 4min watch crash, ANR and download success
同一组示例的版本占比是 1.13.0 300、1.12.0 60、1.11.0 40,收敛率为 0.75。这些数字是示例输入,不是两个案例的真实分布。
边界一:回滚计划不判断「该不该回滚」。它假设事故已经确认、回滚目标可用;是否回滚由灰度和观测门禁决定,属于 11.2。
边界二:回滚不修复根因。切回旧版本只是停止影响,根因分析、修复和重新发布是另一条流程;把回滚当成修复,会让同一个问题在下一版重演。
边界三:版本收敛依赖上报口径。服务端登录日志、客户端事件、渠道后台数据可能给出不同结果;对外口径要固定一个来源,并注明统计窗口和分母。
边界四:商店整包没有回滚路径。任何把「整包可回滚」写进预案的做法都是错的;整包只能前滚,发布前必须用更长的观察窗口换取安全。
与相邻章节的关系
11.1 定义了补丁的生成与校验,11.2 定义了灰度分组与门禁,本节回答「灰度失败后怎么退、退多快、旧版本还剩多少」。10.3 讨论的是单机视角的热更失败兜底,本节是发布与版本管理视角。
本节不讨论下载器、CDN 和断点续传,那属于 12.x;也不讨论资源分组与包边界,那属于 8.x。回滚计划只编排发布侧动作,不改变客户端代码。
小结
- 回滚时限由错误传播速度决定:消消配置 30 分钟,方舟战斗数值 15 分钟。
- 可回滚对象要事先准备快照与清单;支付、抽奖、权限和商店整包只能前滚。
- 回滚计划要拆成有预算、可并行的步骤,并留出重试余量。
- 版本收敛用发布后 7 天升级占比,按渠道和新增/老用户分群看,不能只看总体。
- 收敛慢的三个原因是渠道审核、下载失败和玩家不更新,对应三种不同处置。
阅读导航:上一节:11.2 灰度发布:分组、比例与观测 · 下一节:12.1 下载器设计:分片、并发与断点续传 。
常见错误与边界
- 把回滚时限当成口号。没有拆步骤、估时和演练,事故现场第一步就会超时。
- 事故后才找上一版。配置快照和旧清单必须发布前落盘并校验,临时找一定来不及。
- 认为商店整包可以回滚。应用商店不提供降级通道,整包只能发新版本覆盖。
- 把 Android 代码补丁当可回滚。补丁应用后旧代码已被替换,只能发新补丁或关开关。
- 回滚计划没有余量。每步都按最乐观时间估,第一次重试就会超过时限。
- 回滚步骤全部串行。冻结灰度和切清单可以并行,串行编排会白白浪费几分钟。
- 把回滚当根因修复。切回旧版本只是止血,根因不解决,下一版会重演。
- 版本收敛只看总体。新装用户会抬高比例,必须按渠道和新老用户分群。
- iOS 和 Android 用同一个收敛目标。审核周期不同,渠道必须分开看。
- 用强更解决一切收敛问题。强更会提高升级率,也会把弱网和低端机用户挡在门外。
- 边界:回滚计划不判断是否回滚。是否回滚由 11.2 的观测门禁决定,本节只负责怎么退。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。