《移动游戏产品的架构·网络、资源与热更新》12.3 下载失败的降级与重试

消消下载成功率 97.5%、弱网只有 92%,方舟 98.6%,差距不只是网络分布。本节把失败分成可重试与不可重试两类,用两层链路估算重试预算与浪费的字节,再说明消消能延后活动资源、方舟不能延后战斗资源的原因,并给出三类必须立刻停止重试的情况。

97.5% 与 98.6% 之间差的不是 1.1 个百分点

《潮汐消消》的下载成功率是 97.5%,弱网只有 92%;《潮汐方舟》是 98.6%。把这两个数字并排,很容易得出「方舟的下载链路更好」的结论。但这个结论经不起拆解。

消消的弱网会话占 22%,方舟占 14%。假设消消的非弱网会话成功率为 99% 左右,那么整体 97.5% 里,绝大部分失败来自那 22% 的弱网玩家:0.22 乘以 8% 已经是 1.76 个百分点,剩下 0.74 个百分点分摊到 78% 的非弱网会话上。方舟的 98.6% 建立在更低的弱网占比上,不代表它的重试策略更强。

下载失败从来不是单一原因,而是一个分布。 把失败当成一个笼统的「下载失败」去重试,就会出现两种浪费:对不该重试的错误反复重试,对应该换条路的错误死磕同一个节点。降级与重试的第一步,是把失败分类。

先分类,再决定重试

失败可以按「重试有没有意义」分成两类。第一类是瞬时的、与本次请求相关的错误:超时、5xx、签名过期。第二类是确定性的、与请求次数无关的错误:资源不存在、磁盘没空间、玩家主动取消。

失败类型可重试动作理由
HTTP 404否停止发布并告警资源不在发布清单里,重试一万次也不会出现
HTTP 403是,一次刷新签名后重试签名过期是时间问题,刷新即可
HTTP 5xx是切到下一家 CDN当前边缘或源站有问题,换条路更快
超时是用更小的分片续传已完成的分片仍然有效,只补缺口
校验失败是,有限次换 CDN 重新拉整个对象续传修不好坏字节,只能整份重取
磁盘不足否引导清理空间重试只会再次失败
玩家取消否尊重选择,下次再提醒与玩家对抗是最差的产品决策
不支持 Range是退化为单流下载协议能力问题,换一种请求方式

这张表里最关键的一行是 404。它看起来像网络错误,实际是发布事故:清单里写了一个不存在的对象。客户端的正确反应不是重试,而是上报并停止,让服务端尽快发现发布残缺。把 404 当瞬时错误重试,只会把发布事故掩盖成「个别玩家下载失败」。

校验失败也不能用续传解决。分片校验只能证明「这一片传对了」,如果整文件校验失败,说明某个分片在传输中被污染,而续传会保留这些坏分片。正确做法是丢弃本地分片,换一个 CDN 重新拉整份对象。

重试预算:两层链路算出来的 99.9996% 只是上限

重试需要预算,否则弱网玩家会在同一个资源上无限消耗流量。预算可以用一个简单的概率模型来估:假设主链路成功率 97.5%、备用链路 92%,每条链路各试两次,那么整体成功率是

1 - (1 - 0.975)^2 × (1 - 0.92)^2 = 1 - 0.000625 × 0.0064 ≈ 99.9996%

assessFallback 就是按这个公式算的,同时给出期望尝试次数、期望传输字节和最坏情况字节。两条链路、各两次尝试,期望尝试次数约 1.026 次,最坏情况要传 4 份对象的字节。

但这个 99.9996% 只是上限,不是承诺。公式假设两次失败互相独立,而真实世界里它们高度相关:玩家的网络断了,换 CDN 也断;设备的磁盘满了,换哪家都写不进去;DNS 被污染,两条链路一起解析失败。相关性越强,实际成功率越接近单条链路的成功率,而不是公式算出的乘积。

所以重试预算要按「最坏情况」留,而不是按「公式结果」留。方舟的每周热更是 45MB,如果最坏要传 4 份,就是 180MB;消消的双周热更平均 12MB,最坏 48MB。这个数字要能和玩家的流量套餐对上,否则重试策略会在月底制造投诉。

降级:哪些资源可以晚点再来

不是所有资源都值得为它重试到成功。有些资源可以晚点再来,有些资源缺了就进不去玩法。降级策略的核心,是划出这条线。

资源组消消(480MB)方舟(1,840MB)能否延后
首包120MB350MB否,没有它游戏打不开
按需分包4 个各 90MB战斗 4×260MB、角色 260MB、语音 190MB方舟的战斗与角色否;语音可降码率或延后
活动、皮肤、文本随热更下发随热更下发是,错过一次活动不影响主循环

消消可以延后活动与皮肤资源,因为它的单局只有 90 秒,玩家进来是为了消一局,不是为了看活动页。方舟不能延后战斗场景与角色,因为一场战斗 8 到 12 分钟,缺一个场景包就直接进不去。同样是「按需下载」,两款产品的可延后边界完全不同。

降级的实现方式是给每个资源组标两个属性:是否关键、优先级。预算不够时,从优先级最低的可选组开始延后,直到剩余字节落进预算。关键组永远不参与延后;如果关键组本身就超预算,那不是降级能解决的问题,而是包体设计问题,要回到 8.x 重新分组。

延后不等于放弃。延后的资源应该记录在本地,下次启动或在 Wi-Fi 下自动续传,并在 UI 上给玩家一个明确的「还差多少」提示。悄悄延后、不告诉玩家,会让玩家在进入某个玩法时突然发现资源缺失,体验比直接说「还需下载 45MB」更差。

什么时候必须放弃

有三类情况必须立即放弃重试:玩家主动取消、磁盘空间不足、资源 404。前两个是边界条件,第三个是发布事故,重试都不会让结果变好。

放弃之后要给出可行动的出口,而不是一句「下载失败」。磁盘不足要告诉玩家还差多少空间;404 要上报并提示「资源更新中,请稍后重启」;玩家取消要记住进度,下次提示「继续下载」而不是从头开始。

还有一类是「预算耗尽」。分片尝试次数用尽、两层 CDN 都失败之后,下载器应该停止,把控制权交还给上层:是提示玩家切换网络,还是延后到下次启动,由产品决定。下载器不替产品做这个决定,但它必须如实报告「已经试过什么、浪费了多少字节」。

实现

下面的模块把失败分类、重试预算与降级选择写成三个纯函数。它不发起请求,只回答「这个错误该怎么处理」「这组链路值不值得试」「预算不够时先砍谁」。

// file: src/cdn/fallback.ts
// tier: L1
// verify: node --test test/cdn/fallback.test.ts

export type FailureKind =
  | "http-404"
  | "http-403"
  | "http-5xx"
  | "timeout"
  | "checksum"
  | "no-space"
  | "user-cancel"
  | "range-unsupported";

export type FallbackAction =
  | "next-cdn"
  | "resume-chunk"
  | "refresh-signature"
  | "free-space"
  | "stop-publish"
  | "ask-user"
  | "single-stream";

export interface FailureAdvice {
  readonly kind: FailureKind;
  readonly retryable: boolean;
  readonly action: FallbackAction;
  readonly note: string;
}

export interface FallbackTier {
  readonly id: string;
  readonly successRate: number;
  readonly maxAttempts: number;
  readonly chunkScale: number;
}

export interface FallbackAssessment {
  readonly successRate: number;
  readonly expectedAttempts: number;
  readonly expectedBytes: number;
  readonly worstBytes: number;
  readonly tiers: number;
}

export interface PackGroup {
  readonly id: string;
  readonly bytes: number;
  readonly critical: boolean;
  readonly priority: number;
}

export interface DegradeResult {
  readonly feasible: boolean;
  readonly retainedBytes: number;
  readonly deferredBytes: number;
  readonly deferred: readonly string[];
  readonly overflowBytes: number;
}

const ADVICE: Record<FailureKind, Omit<FailureAdvice, "kind">> = {
  "http-404": {
    retryable: false,
    action: "stop-publish",
    note: "object missing from the release; retrying cannot create it",
  },
  "http-403": {
    retryable: true,
    action: "refresh-signature",
    note: "signed url expired; refresh once before retrying",
  },
  "http-5xx": {
    retryable: true,
    action: "next-cdn",
    note: "edge or origin error; move the request to the next vendor",
  },
  timeout: {
    retryable: true,
    action: "resume-chunk",
    note: "keep completed ranges and resume with a smaller chunk",
  },
  checksum: {
    retryable: true,
    action: "next-cdn",
    note: "resume cannot repair bad bytes; fetch the object elsewhere",
  },
  "no-space": {
    retryable: false,
    action: "free-space",
    note: "the device cannot store the file until space is freed",
  },
  "user-cancel": {
    retryable: false,
    action: "ask-user",
    note: "the player stopped the download; do not fight the user",
  },
  "range-unsupported": {
    retryable: true,
    action: "single-stream",
    note: "the edge ignores Range; fall back to one stream",
  },
};

export const DEFAULT_TIERS: readonly FallbackTier[] = [
  { id: "primary-cdn", successRate: 0.975, maxAttempts: 2, chunkScale: 1 },
  { id: "secondary-cdn", successRate: 0.92, maxAttempts: 2, chunkScale: 1 },
];

export function classifyFailure(kind: string): FailureAdvice {
  const advice = ADVICE[kind as FailureKind];
  if (advice === undefined) throw new Error(`unknown failure kind: ${kind}`);
  return { kind: kind as FailureKind, ...advice };
}

export function assessFallback(tiers: readonly FallbackTier[], bytes: number): FallbackAssessment {
  if (tiers.length === 0) throw new Error("at least one fallback tier is required");
  if (!Number.isInteger(bytes) || bytes < 0) throw new Error(`invalid object size: ${bytes}`);

  let reach = 1;
  let expectedAttempts = 0;
  let expectedBytes = 0;
  let worstBytes = 0;

  for (const tier of tiers) {
    if (tier.id.length === 0) throw new Error("tier id must not be empty");
    if (!Number.isFinite(tier.successRate) || tier.successRate <= 0 || tier.successRate > 1) {
      throw new Error(`invalid success rate for ${tier.id}: ${tier.successRate}`);
    }
    if (!Number.isInteger(tier.maxAttempts) || tier.maxAttempts < 1) {
      throw new Error(`invalid max attempts for ${tier.id}: ${tier.maxAttempts}`);
    }
    if (!Number.isFinite(tier.chunkScale) || tier.chunkScale <= 0) {
      throw new Error(`invalid chunk scale for ${tier.id}: ${tier.chunkScale}`);
    }

    const fail = 1 - tier.successRate;
    const attempts = (1 - fail ** tier.maxAttempts) / tier.successRate;
    expectedAttempts += reach * attempts;
    expectedBytes += reach * attempts * tier.chunkScale * bytes;
    worstBytes += tier.maxAttempts * tier.chunkScale * bytes;
    reach *= fail ** tier.maxAttempts;
  }

  return { successRate: 1 - reach, expectedAttempts, expectedBytes, worstBytes, tiers: tiers.length };
}

export function degradeToFit(groups: readonly PackGroup[], budgetBytes: number): DegradeResult {
  if (!Number.isInteger(budgetBytes) || budgetBytes < 0) {
    throw new Error(`invalid budget: ${budgetBytes}`);
  }

  const seen = new Set<string>();
  let criticalBytes = 0;
  for (const group of groups) {
    if (group.id.length === 0) throw new Error("group id must not be empty");
    if (seen.has(group.id)) throw new Error(`duplicate group: ${group.id}`);
    seen.add(group.id);
    if (!Number.isInteger(group.bytes) || group.bytes < 0) {
      throw new Error(`invalid bytes for ${group.id}: ${group.bytes}`);
    }
    if (group.critical) criticalBytes += group.bytes;
  }

  if (criticalBytes > budgetBytes) {
    return {
      feasible: false,
      retainedBytes: criticalBytes,
      deferredBytes: 0,
      deferred: [],
      overflowBytes: criticalBytes - budgetBytes,
    };
  }

  const optional = groups
    .filter((group) => !group.critical)
    .sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id));

  let retainedBytes = criticalBytes;
  let deferredBytes = 0;
  const deferred: string[] = [];

  for (const group of optional) {
    if (retainedBytes + group.bytes <= budgetBytes) {
      retainedBytes += group.bytes;
    } else {
      deferred.push(group.id);
      deferredBytes += group.bytes;
    }
  }

  return { feasible: true, retainedBytes, deferredBytes, deferred, overflowBytes: 0 };
}

export function formatFallbackReport(assessment: FallbackAssessment): string {
  return [
    `success=${(assessment.successRate * 100).toFixed(4)}%`,
    `attempts=${assessment.expectedAttempts.toFixed(3)}`,
    `bytes=${Math.round(assessment.expectedBytes)}B`,
    `worst=${assessment.worstBytes}B`,
    `tiers=${assessment.tiers}`,
  ].join(" ");
}

classifyFailure 用一张表把八种失败映射到动作,未知类型直接抛错,避免新错误被静默当成可重试。assessFallback 按链路顺序累计到达概率、期望尝试次数与期望字节,chunkScale 允许后续链路使用更小的分片,从而降低单次尝试的传输量。degradeToFit 先保住关键组,再按优先级从低到高延后可选组;如果关键组本身就超预算,返回不可行与缺口字节,而不是硬塞。

DEFAULT_TIERS 里的 97.5% 与 92% 取自消消的整体与弱网口径,只作为量级示例。真实的多厂商切换链路要单独压测,不能把这两个数字直接当成备用 CDN 的成功率。

运行结果与边界

用默认的两层链路、对象大小 1,000 字节运行,formatFallbackReport 的输出是:

success=99.9996% attempts=1.026 bytes=1026B worst=4000B tiers=2

这行输出给出四个决策依据:成功率上限、期望尝试次数、期望传输字节、最坏传输字节。最坏值和期望值差距越大,说明重试策略对弱网玩家越不友好,越应该考虑更小的分片或更早的降级。

降级的边界同样要明确。延后资源会改变玩家的可见内容,必须配合 UI 提示与后续补下;关键组超预算是包体问题,不是下载问题;degradeToFit 的优先级顺序是产品决策,不能由客户端随意调整。还有一条:降级只影响「这次能不能玩」,不影响「资源是否正确」,校验仍然要在下载完成后执行。

与相邻章节的关系

12.1 定义了分片与续传的原子单位,本节的重试与降级都建立在这个单位上:超时续传补的是分片,校验失败丢弃的也是分片。12.2 定义了 CDN 的缓存与回源,本节说的「换一家 CDN」能否奏效,取决于备用链路是否真的有容量、是否支持 Range。

向前看,11.3 的回滚与版本收敛决定了清单切换的时限:方舟 15 分钟内要能回滚战斗数值,意味着清单与热更文件不能被长缓存,否则降级策略再完整也拿不到正确版本。跨卷来看,降级后的「下次启动再补」要能跨进程存活,和客户端卷 4.3 的进程恢复一样,状态必须落盘并带版本。

小结

  • 失败要先分类:瞬时错误可重试,确定性错误重试没有意义。
  • 404 是发布事故,不是网络错误,客户端应停止并告警。
  • 重试成功率的乘积公式只是上限,真实失败高度相关,预算要按最坏情况留。
  • 可延后的资源边界由玩法决定:消消能延后活动,方舟不能延后战斗场景。
  • 玩家取消、磁盘不足、资源缺失必须放弃,并给出可行动的出口。

阅读导航:上一节:12.2 CDN 选型与回源策略 · 下一节:13.1 网络与资源指标矩阵 。

常见错误与边界

  1. 把所有失败都当网络抖动。404 与磁盘不足被反复重试,真正的问题被掩盖。
  2. 对玩家取消自动重试。玩家刚关掉下载,客户端又偷偷开始,是最差的产品体验。
  3. 用乘积公式承诺成功率。失败相关时实际值远低于公式,承诺会变成事故。
  4. 重试不设上限。弱网玩家在同一个分片上无限消耗流量,月底收到账单投诉。
  5. 校验失败还用续传。坏分片被保留,重试多少次都拼不出正确文件。
  6. 降级不区分关键资源。方舟延后战斗场景,玩家直接进不去玩法。
  7. 降级不通知玩家。资源悄悄缺失,玩家进入玩法才发现,体验更差。
  8. 只按期望值做预算。最坏情况要传 4 份字节,流量套餐按期望值算一定不够。
  9. 边界:降级不改变校验标准。延后的是下载时机,不是资源完整性。
  10. 边界:关键组超预算是包体问题。下载器无法把 1,840MB 塞进 350MB 的首包。
  11. 边界:多厂商切换要压测。备用链路的真实成功率不能从主链路推算。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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