《移动游戏产品的架构·网络、资源与热更新》11.3 回滚与版本收敛

消消要求 30 分钟内回滚配置,方舟要求 15 分钟内回滚战斗数值,而商店整包只能发新版本覆盖。本节对照两个案例,拆解可回滚与只能前滚的边界、回滚计划的时间预算,以及发布后 7 天版本收敛的统计口径与处置,并给出可执行的步骤、时间预算与告警边界。

数值出问题的那十五分钟

《潮汐方舟》在周三晚上推送了一次战斗数值热更。二十分钟后,客服开始收到「伤害计算不对」的反馈;又过了十分钟,排行榜出现异常数据。发布负责人没有去改代码,而是打开回滚清单:冻结灰度、切回上一版战斗数值、恢复配置快照、强制客户端重新拉取版本索引,目标是在 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 天内升级到新版本的用户占比。

收敛率受三个因素影响:

  1. 发布节奏。消消双周一次增量、每 8 周一次商店整包;方舟每周一次增量、每 6 周一次整包。节奏越快,旧版本堆积的窗口越短,但每次发布可用的观察时间也越少。
  2. 新增安装。消消每月新增安装 42 万,方舟 8 万。新装用户直接拿到最新版本,会自然抬高收敛率;消消的 DAU 是 80 万、方舟 25 万,新增占比更高,因此消消的总体收敛数字更容易被新装拉动,评估老用户升级必须分群看。
  3. 下载条件。消消的下载成功率是 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 下载器设计:分片、并发与断点续传 。

常见错误与边界

  1. 把回滚时限当成口号。没有拆步骤、估时和演练,事故现场第一步就会超时。
  2. 事故后才找上一版。配置快照和旧清单必须发布前落盘并校验,临时找一定来不及。
  3. 认为商店整包可以回滚。应用商店不提供降级通道,整包只能发新版本覆盖。
  4. 把 Android 代码补丁当可回滚。补丁应用后旧代码已被替换,只能发新补丁或关开关。
  5. 回滚计划没有余量。每步都按最乐观时间估,第一次重试就会超过时限。
  6. 回滚步骤全部串行。冻结灰度和切清单可以并行,串行编排会白白浪费几分钟。
  7. 把回滚当根因修复。切回旧版本只是止血,根因不解决,下一版会重演。
  8. 版本收敛只看总体。新装用户会抬高比例,必须按渠道和新老用户分群。
  9. iOS 和 Android 用同一个收敛目标。审核周期不同,渠道必须分开看。
  10. 用强更解决一切收敛问题。强更会提高升级率,也会把弱网和低端机用户挡在门外。
  11. 边界:回滚计划不判断是否回滚。是否回滚由 11.2 的观测门禁决定,本节只负责怎么退。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查