周三晚上的 45MB
方舟的周更在周三晚上线,平均 45MB。发布后半小时,监控发现一部分 Android 设备在校验阶段失败,另有一部分设备已经激活了新的战斗数值。此时距离回滚窗口关闭还有 15 分钟,值班同学要在三个选项中快速决定:继续重试、回滚到上一份数值、还是先关掉受影响的功能。
热更失败不是一个动作,而是一条链。链上的每个阶段有不同的证据、不同的恢复手段,也有不同的时间成本。把所有失败都当成「重试一下」或者「回滚就行」,会让真正需要快速止损的场景被拖慢。
失败分成四个阶段
方舟的热更流程分成拉取、校验、应用、激活四步。每一步的失败都能被独立观测,也应该有独立的默认动作。
| 阶段 | 失败证据 | 常见原因 | 默认动作 |
|---|---|---|---|
| 拉取 fetch | 分片超时、连接中断、断点信息存在 | 弱网、CDN 节点异常 | 有断点则续传,否则重试 |
| 校验 verify | 哈希不匹配、清单签名失败 | 补丁损坏、版本错配 | 回滚到上一份已校验版本 |
| 应用 apply | 写入失败、空间不足、文件占用 | 磁盘满、权限异常 | 回滚并清理残留 |
| 激活 activate | 新配置加载报错、运行时不变量被破坏 | 配置语义错误、脚本异常 | 回滚或关闭功能开关 |
四个阶段的共同点是:都必须能在不重新下载整包的前提下恢复。整包 350MB,重新下载的成本远高于回滚;热更的价值一半来自体积,另一半来自失败后能快速退回去。
拉取阶段最容易被低估。弱网会话在方舟占 14%,切后台、地铁隧道、Wi-Fi 与蜂窝切换都会打断下载。如果每次失败都从头开始,45MB 的包在弱网下可能永远下不完。断点续传不是优化项,而是拉取阶段能够收敛的前提。
校验阶段是安全边界。哈希不匹配意味着拿到的字节不可信,不能「先应用看看」。这一阶段没有重试以外的修复手段时,正确动作是回滚,而不是跳过校验。跳过一次校验,后面所有阶段都失去可信基础。
应用与激活阶段失败,通常说明客户端环境或配置语义有问题。前者可以回滚文件,后者要靠版本指针或功能开关止损。两者的共同要求是:上一份可用版本必须还在本地,并且能在窗口内切换回去。
15 分钟窗口怎么计算
方舟的战斗数值回滚窗口是 15 分钟。这个数字不是从发布开始计时,而是从「确认需要回滚」开始,到「旧版本重新生效并完成验证」结束。工程上用一个更保守的口径:记录已经用掉的分钟数,剩余时间等于 15 减去已用。
minutesIntoWindow 应该由发布系统写入,而不是由值班同学心算。监控告警、人工确认、执行回滚、验证恢复,每一步都在消耗窗口;如果只在执行回滚时才开始计时,验证阶段往往会被漏掉。
窗口用尽后,回滚不再是可选项。此时即使本地还有旧版本,也不应该继续尝试,因为无法在承诺时间内完成验证;正确动作转为关闭功能或发商店新版本覆盖。代码里把这种情况标成 terminal,就是为了让发布门禁能识别「已经进入不可逆路径」。
作为对照,消消的配置回滚窗口是 30 分钟,热更是双周一次、平均 12MB,内容集中在活动、关卡、皮肤与文本。变更更窄、体量更小,窗口可以更长;窗口长度应该跟变更类型匹配,而不是全产品统一。
哪些能回滚,哪些只能覆盖
回滚能力取决于变更载体,不取决于团队意愿。
| 对象 | 能否回滚 | 手段 | 限制 |
|---|---|---|---|
| 战斗数值 | 15 分钟内可回滚 | 切回上一份已校验数值 | 超过窗口只能关功能或发商店 |
| 活动与赛季配置 | 按配置版本回退 | 版本指针切回上一份 | 生效时间与结算顺序要一并还原 |
| 资源热更 | 可回退 | 切回上一份资源清单并重新校验 | 本地需保留旧资源或可快速重下 |
| 商店整包 | 不可回滚 | 只能发新版本覆盖 | 周期长,差量 60 到 90MB |
整包不可回滚是必须写进预案的事实。原生代码、权限与 SDK 一旦随整包发布,就没有「切回上一版」的通道;只能靠服务端开关降级,或者尽快发新版本覆盖。把整包当成可回滚对象,是发布预案里最常见的错误假设。
资源回退也有前提:本地要保留上一份已校验清单,或者能在窗口内重新拉取。若旧资源已被清理,回退就退化成重新下载,15 分钟窗口可能不够。资源清理策略必须给「可回滚版本」留出空间,不能一上线就把旧版本删干净。
兜底顺序:回滚、关开关、发商店
兜底动作有明确优先级,不能凭现场感觉跳步。
- 回滚。上一份已校验版本可用,且剩余窗口足够,优先回滚;它恢复的是确定可用的旧行为。
- 关闭功能开关。无法回滚或回滚会丢失更多数据时,先关掉受影响功能,把影响面隔离在一个可解释的范围内。
- 发商店新版本覆盖。既不能回滚又没有功能开关时,才进入终端路径;这是最后手段,不是默认手段。
回滚与关开关都是非终端动作,系统仍可继续服务;发商店是终端动作,意味着本次热更无法在客户端侧收敛。发布门禁应该单独统计终端路径的数量,任何一条终端路径都需要在复盘里解释为什么没有更早的兜底。
关开关不是万能的。它只能隔离被开关覆盖的行为;如果新配置已经写入了玩家存档或改变了经济产出,关开关只能止血,不能回滚已经发生的结果。这类变更在 10.2 的复核阶段就应该被识别出来,要求更强的回滚方案。
发商店也有代价。方舟每 6 周一次整包,整包差量 60 到 90MB;为了一个热更失败立刻发版,会打乱节奏并让所有玩家承担一次大体积下载。所以终端路径应该尽量少,且必须由能对成本负责的人确认。
演练:把每个阶段都失败一次
兜底方案不能只写在文档里。每个版本发布前,用演练注入四类失败,确认系统给出的动作与预期一致。
演练的输入是一组失败尝试:阶段、体积、是否可重试、是否有断点、是否有可回滚版本、是否有功能开关、已经用掉多少分钟。输出是每条尝试的恢复动作、剩余分钟数与是否终端。发布门禁读取终端路径与超预算标记,而不是读一段自然语言说明。
演练要覆盖三种最容易出错的组合:拉取失败但有断点、校验失败但没有可回滚版本、窗口已经用尽但本地仍有旧版本。第一种考验续传是否真的保存了偏移;第二种考验功能开关是否存在;第三种考验系统会不会在不可逆路径上继续尝试回滚。
演练结果要留档。下一次复盘时,能对比「上次这个阶段是回滚,这次为什么变成发商店」,从而发现是能力退化还是策略调整。没有留档的演练,只是一次性演示。
实现
下面的文件把四阶段失败映射成五类恢复动作。输入是一批失败尝试与一份演练策略,输出恢复计划、剩余分钟数、终端标记与汇总计数。
// file: src/hotfix/failure-drill.ts
// tier: L1
// verify: node --test test/hotfix/failure-drill.test.ts
export type FailureStage = "fetch" | "verify" | "apply" | "activate";
export type RecoveryAction =
| "resume"
| "retry"
| "rollback"
| "disable-feature"
| "store-release";
export interface HotfixAttempt {
readonly id: string;
readonly stage: FailureStage;
readonly bytes: number;
readonly retryable: boolean;
readonly partialDownload: boolean;
readonly rollbackAvailable: boolean;
readonly featureFlagAvailable: boolean;
readonly minutesIntoWindow: number;
}
export interface DrillPolicy {
readonly name: string;
readonly rollbackBudgetMinutes: number;
readonly batchBudgetBytes: number;
}
export interface RecoveryPlan {
readonly id: string;
readonly stage: FailureStage;
readonly action: RecoveryAction;
readonly remainingMinutes: number;
readonly terminal: boolean;
readonly reason: string;
}
export interface DrillReport {
readonly policy: string;
readonly plans: readonly RecoveryPlan[];
readonly counts: Readonly<Record<RecoveryAction, number>>;
readonly oversize: boolean;
readonly maxRemainingMinutes: number;
}
export const MB = 1024 * 1024;
export const TIDE_ARK_DRILL: DrillPolicy = {
name: "潮汐方舟",
rollbackBudgetMinutes: 15,
batchBudgetBytes: 45 * MB,
};
export const TIDE_MATCH3_DRILL: DrillPolicy = {
name: "潮汐消消",
rollbackBudgetMinutes: 30,
batchBudgetBytes: 12 * MB,
};
const STAGES: readonly FailureStage[] = ["fetch", "verify", "apply", "activate"];
function validateAttempt(attempt: HotfixAttempt): void {
if (attempt.id.trim() === "") throw new Error("attempt id is required");
if (!STAGES.includes(attempt.stage)) {
throw new Error(`unknown stage: ${String(attempt.stage)}`);
}
if (!Number.isInteger(attempt.bytes) || attempt.bytes < 0) {
throw new Error(`invalid bytes for ${attempt.id}: ${attempt.bytes}`);
}
if (
!Number.isInteger(attempt.minutesIntoWindow) ||
attempt.minutesIntoWindow < 0
) {
throw new Error(
`invalid minutes for ${attempt.id}: ${attempt.minutesIntoWindow}`,
);
}
}
export function planRecovery(
attempt: HotfixAttempt,
policy: DrillPolicy,
): RecoveryPlan {
validateAttempt(attempt);
if (
!Number.isInteger(policy.rollbackBudgetMinutes) ||
policy.rollbackBudgetMinutes < 0
) {
throw new Error(`invalid rollback budget: ${policy.rollbackBudgetMinutes}`);
}
const remainingMinutes =
policy.rollbackBudgetMinutes - attempt.minutesIntoWindow;
if (remainingMinutes < 0) {
return {
id: attempt.id,
stage: attempt.stage,
action: "store-release",
remainingMinutes: 0,
terminal: true,
reason: `已用 ${attempt.minutesIntoWindow} 分钟,超出 ${policy.rollbackBudgetMinutes} 分钟回滚窗口`,
};
}
if (attempt.stage === "fetch" && attempt.retryable) {
const action: RecoveryAction = attempt.partialDownload ? "resume" : "retry";
return {
id: attempt.id,
stage: attempt.stage,
action,
remainingMinutes,
terminal: false,
reason: attempt.partialDownload
? "断点信息完整,从已下载偏移继续"
: "拉取阶段失败,可重新发起下载",
};
}
if (attempt.rollbackAvailable) {
return {
id: attempt.id,
stage: attempt.stage,
action: "rollback",
remainingMinutes,
terminal: false,
reason: "恢复上一份已校验版本",
};
}
if (attempt.featureFlagAvailable) {
return {
id: attempt.id,
stage: attempt.stage,
action: "disable-feature",
remainingMinutes,
terminal: false,
reason: "无法回滚,先关闭功能开关隔离影响",
};
}
return {
id: attempt.id,
stage: attempt.stage,
action: "store-release",
remainingMinutes,
terminal: true,
reason: "无法回滚且没有功能开关,只能发商店新版本覆盖",
};
}
export function runDrill(
attempts: readonly HotfixAttempt[],
policy: DrillPolicy,
): DrillReport {
const seen = new Set<string>();
const plans: RecoveryPlan[] = [];
for (const attempt of attempts) {
if (seen.has(attempt.id)) throw new Error(`duplicate attempt id: ${attempt.id}`);
seen.add(attempt.id);
plans.push(planRecovery(attempt, policy));
}
const counts: Record<RecoveryAction, number> = {
resume: 0,
retry: 0,
rollback: 0,
"disable-feature": 0,
"store-release": 0,
};
let oversize = false;
let maxRemainingMinutes = 0;
for (let index = 0; index < attempts.length; index += 1) {
counts[plans[index].action] += 1;
if (attempts[index].bytes > policy.batchBudgetBytes) oversize = true;
if (plans[index].remainingMinutes > maxRemainingMinutes) {
maxRemainingMinutes = plans[index].remainingMinutes;
}
}
return {
policy: policy.name,
plans,
counts,
oversize,
maxRemainingMinutes,
};
}
export function formatDrillReport(report: DrillReport): string {
const lines = ["id\tstage\taction\tremaining\tterminal\treason"];
for (const plan of report.plans) {
lines.push(
`${plan.id}\t${plan.stage}\t${plan.action}\t${plan.remainingMinutes}min\t${plan.terminal ? "yes" : "no"}\t${plan.reason}`,
);
}
lines.push(
`summary\tresume=${report.counts.resume}\tretry=${report.counts.retry}\trollback=${report.counts.rollback}\tdisable-feature=${report.counts["disable-feature"]}\tstore-release=${report.counts["store-release"]}\toversize=${report.oversize ? "yes" : "no"}`,
);
return lines.join("\n");
}
planRecovery 先算剩余窗口,超时直接进入 store-release 终端路径;未超时时,拉取阶段优先续传或重试,其它阶段按「回滚、关开关、发商店」的顺序兜底。runDrill 负责批量演练:重复 id 报错,统计五类动作,并标记超出 45MB 批次预算的尝试。formatDrillReport 输出带剩余分钟数与终端标记的表格。
运行结果与边界
用一组小样本运行,输出如下。样本只演示动作映射,不代表方舟的真实故障分布:
id stage action remaining terminal reason
partial fetch resume 13min no 断点信息完整,从已下载偏移继续
corrupt verify rollback 13min no 恢复上一份已校验版本
blocked apply disable-feature 13min no 无法回滚,先关闭功能开关隔离影响
late activate store-release 0min yes 已用 16 分钟,超出 15 分钟回滚窗口
summary resume=1 retry=0 rollback=1 disable-feature=1 store-release=1 oversize=no
这张表把「现在该做什么」变成可执行字段。值班同学不需要临场推理,按 action 执行、按 remaining 判断是否还来得及、按 terminal 决定是否升级。
边界要写清楚。第一,代码只做动作映射,不负责真正执行回滚;执行器、文件切换与验证仍由发布系统完成。第二,剩余分钟数是策略计算,不是实时监控;真实的窗口消耗需要发布系统持续写入 minutesIntoWindow。第三,store-release 是终端路径,不保证立刻生效;商店审核与玩家更新都需要时间,它只是客户端侧最后的收敛手段。
与相邻章节的关系
10.2 给出了内容边界与复核要求,本节处理「复核通过但执行失败」的情况。10.1 的通道判定决定了哪些变更本来就不该进入热更,从源头上减少需要兜底的场景。11.3 会从版本收敛的角度讨论回滚之后如何让玩家回到同一版本;12.3 则讨论下载失败的重试与降级。
本节不讨论差量算法与灰度分组,也不讨论商店审核流程。失败演练只回答「失败发生在哪个阶段、现在该做什么、还剩多少时间」。
小结
- 热更失败分四个阶段:拉取、校验、应用、激活;每个阶段有独立证据与默认动作。
- 15 分钟窗口从确认需要回滚开始,到旧版本重新生效并验证结束;代码记录已用分钟数。
- 能回滚的对象有限:战斗数值 15 分钟内可回滚,整包不可回滚,只能发新版本覆盖。
- 兜底顺序固定:先回滚,再关功能开关,最后才发商店;后两者是隔离与终端手段。
- 演练要覆盖断点续传、无回滚版本、窗口用尽三种组合,并保留结果供复盘。
阅读导航:上一节:10.2 热更的内容边界与红线 · 下一节:11.1 差量算法与补丁格式 。
常见错误与边界
- 把重试当万能动作。校验失败后重试同一份坏包,只会重复消耗窗口。
- 跳过校验先应用。不可信字节进入客户端,后续所有阶段都失去基础。
- 窗口从发布开始计时。真正可用的是「确认回滚到验证完成」的时间,不是发布时间。
- 假设整包可回滚。原生层发布后只能靠开关降级或发新版本覆盖。
- 旧资源一上线就清理。回退时无处可退,只能重新下载,窗口被下载吃掉。
- 没有功能开关。无法回滚时只剩发商店一条路,终端路径数量会失控。
- 关开关当成回滚。已经写入存档或经济产出的结果不会因为关开关而消失。
- 演练只跑成功路径。不注入失败,就不知道断点、回滚与开关是否真的可用。
- 终端路径不做升级。
store-release需要人确认与商店配合,不能静默执行。 - 边界:代码不执行回滚。它只输出动作与剩余时间,文件切换与验证由发布系统负责。
- 边界:剩余分钟不是实时监控。窗口消耗要由发布系统持续写入,不能靠一次计算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。