《移动游戏产品的架构·网络、资源与热更新》9.2 分包与按需下载的调度

《潮汐方舟》首包 350MB,6 个分包共 1,490MB,玩家进入战斗前必须先拿到 260MB 的场景包。本节把按需下载拆成优先级、依赖阶段、并发波次与蜂窝策略四层,用截止时间判断迟到,并给出可运行的调度实现与失败边界,避免玩家卡在加载界面。

开局前的那几十秒:一个还没下完的战斗包

《潮汐方舟》的一名玩家在通勤路上打开游戏,点进排位,匹配成功后进入加载界面。此时战斗场景包还没下载完,加载条停在一半,队友已经在语音里催促。几十秒后他退出了对局,扣了分,也留下了一次断线记录。

这不是网络断了,而是调度没有回答一个本可以提前回答的问题:这个 260MB 的场景包,应该在什么时候开始下载。方舟的首包是 350MB,6 个分包分别是 4 个战斗场景各 260MB、角色 260MB、语音 190MB,合计 1,490MB,总资源 1,840MB。首包之外的体积是安装体积的三倍多,玩家不可能在进入游戏前全部下载完。

方舟的冷启动 P50 是 3.5 秒、P95 是 6.8 秒,一场战斗持续 8 到 12 分钟,会话时长中位 34 分钟。这些数字决定了调度的空间:玩家有足够长的会话,但关键路径上的等待必须被压缩到加载界面的容忍范围内。

按需下载因此不是一个「下载器」,而是一个调度器。它要决定先下谁、什么时候下、下不到时怎么办,并且这些决定要在玩家点进玩法之前就已经做出。

调度回答三个问题

第一个问题是谁先下。玩家点进战斗时,场景包是阻断性的;角色皮肤是锦上添花;语音包甚至可以不下载。优先级不是按体积排,也不是按资源类型排,而是按「缺了它这一步能不能继续」。

第二个问题是何时下。同样的场景包,在玩家还在主界面时下载叫预取,在加载界面里下载叫阻断等待,在战斗结束后下载叫空闲填充。同一份字节,放在不同时机,玩家的感受完全不同。

第三个问题是下不到怎么办。方舟的下载成功率是 98.6%,看起来很高,但乘以 25 万 DAU,每天仍有数千次下载会失败。调度必须为失败准备降级路径,而不是把失败留给加载界面。

问题调度手段失败表现
先下谁优先级 + 依赖阶段关键包排在后面,玩家空等
何时下预取窗口 + 网络策略蜂窝流量被预取吃光
下不到怎么办截止时间 + 降级加载界面无限等待

这三个问题必须一起回答。只做优先级、不做时机,会把所有高优先级包都变成蜂窝流量;只做时机、不做降级,会把失败推迟到最不能失败的时刻。

四类请求与一条依赖链

调度器的输入不是「文件」,而是「请求」。一个请求可以是一个场景包、一组角色资源、一批语音,粒度由分包策略决定。每个请求至少带五个字段:字节数、优先级、依赖、截止时间、是否允许蜂窝下载。

优先级按紧急程度分三档。blocking 是进入某个玩法前必须完成的请求,比如战斗场景包;prefetch 是可以在玩家做别的事情时提前下载的请求,比如下一个可能进入的场景;idle 是只在 Wi-Fi 空闲时才值得下载的请求,比如语音和高清贴图。

依赖关系决定阶段。角色包可能依赖首包里的骨骼与材质,语音包可能依赖角色包里的语音清单。一个请求必须等它依赖的所有请求完成校验后才能开始,这个「完成后才能开始」的关系会自然形成若干阶段。

方舟的依赖图平均出度 4.1、最大深度 9,比消消的 3.2 和 6 更深。深度 9 意味着最长的依赖链要经过九层才能完成,如果每一层都串行等待,玩家会看到一条很长的加载条。调度的价值就是把同一阶段内没有依赖关系的请求并发起来,把串行链压到最短。

依赖链还有一个常被忽略的性质:它是单向的。分包可以依赖首包,首包不能依赖分包;后一阶段的请求可以依赖前一阶段,反之不成立。一旦出现环,调度器无法决定谁先开始,必须在构建期失败,而不是在客户端死锁。

波次:并发上限与带宽均分

同一阶段的请求可以并发,但并发不是无限的。同时开太多连接会争抢带宽,每个请求都变慢,首个可用包的就绪时间反而被拉长。调度器因此需要一个并发上限,把同一阶段切成若干波次。

波次内部按优先级、截止时间、字节数和 id 排序。排序必须确定,否则同一份请求清单在两次运行中会得到不同的下载顺序,日志无法复现,问题也无法定位。

带宽在波次内均分。假设带宽是 1,000 字节每秒,一个波次里有三个请求,每个请求实际分到约 333 字节每秒;体积大的请求完成得更晚,体积小的先完成。这个模型不是精确的 TCP 仿真,但它足以回答「这个包最早什么时候可用」。

参数作用取值不当的后果
并发上限控制同时下载的请求数太大争抢带宽,太小串行等待
带宽估计换算完成时间高估导致截止时间普遍误判
波次排序决定同阶段先后不确定排序导致日志不可复现

带宽估计本身要保守。方舟的 RTT P50 是 62ms、P95 是 240ms,丢包率中位会话 0.6%,弱网会话占 14%。用 P50 估算完成时间,会让调度器系统性乐观;用 P95 估算,又会把预取窗口排得过长。工程上通常取一个介于两者之间的值,并在运行时分档修正。

波次之间是串行的,但它们之间的等待只应该发生在真正有依赖的地方。如果没有依赖却被排进不同波次,说明并发上限设得太小,玩家会为此多等一个波次的时长。

蜂窝网络不是「能不能下」,而是「值不值得下」

移动游戏最常见的流量事故,是把预取做成了无条件下载。玩家在 4G 下打开游戏,后台悄悄拉走几百 MB,月底账单出来才发现。这类事故的根因不是下载器有 bug,而是调度策略没有区分网络类型。

方舟的每会话流量是 18MB,每 DAU 日流量 21MB,这两个数字都不含资源下载。也就是说,资源下载一旦失控,它会轻易超过业务流量本身。调度必须把「允许蜂窝」作为一个显式字段,而不是全局开关。

合理的策略是:blocking 请求永远允许蜂窝,因为玩家已经在等;prefetch 和 idle 默认只在 Wi-Fi 下进行,除非玩家显式同意。这个策略的代价是弱网玩家可能错过预取窗口,收益是不会产生意外的流量账单。

优先级Wi-Fi蜂窝理由
blocking立即立即玩家已在关键路径上等待
prefetch允许默认拒绝可延迟,流量敏感
idle允许拒绝只在空闲与 Wi-Fi 下有意义

被蜂窝策略拒绝的请求不应该被静默丢弃,而要记录原因。更关键的是依赖传播:如果一个 prefetch 请求被拒绝,依赖它的 blocking 请求也不能开始,否则会进入「等一个永远不会来的前置」的死锁。调度器必须把这类请求标记为 dependency-deferred,让上层知道它不是失败,而是被策略挡住。

截止时间:迟到比失败更常见

每个请求都带一个截止时间,表示「到这个时刻还没完成,玩家就会感知到」。截止时间不是超时,而是体验边界:加载界面能等 5 秒,匹配确认能等 10 秒,进入战斗前的预下载窗口可能有几十秒。

方舟的战斗是 8 到 12 分钟一场,会话中位 34 分钟,这给了预取很大的空间。玩家结束一场战斗后通常会停留、看结算、领奖励,这段时间足够预取下一个场景包的一部分。调度器要做的不是「尽快下完」,而是「在玩家需要之前下完」。

迟到和失败是两件事。下载失败会重试,迟到不会:玩家已经看到了加载条,时间已经过去。调度报表应该把 missedDeadline 单独统计,而不是混进失败率。一个系统可以失败率很低但迟到率很高,这种系统在体验上依然是坏的。

截止时间还会反向约束优先级。一个 prefetch 请求如果截止时间很近,它的实际紧急程度可能高于一个截止时间很远的 blocking 请求。排序时先看阶段,再看优先级,最后看截止时间,是为了保证依赖正确;如果在同一阶段内忽略截止时间,玩家会在加载界面等一个「不着急」的包。

实现

下面的文件把优先级、依赖阶段、并发波次、蜂窝策略和截止时间落成一次调度计算。输入是请求列表与三个运行参数:并发上限、带宽估计、当前网络类型。

// file: src/resource/on-demand.ts
// tier: L1
// verify: node --test test/resource/on-demand.test.ts

export type Priority = "blocking" | "prefetch" | "idle";

export interface DownloadRequest {
  readonly id: string;
  readonly bytes: number;
  readonly priority: Priority;
  readonly dependsOn: readonly string[];
  readonly deadlineMs: number;
  readonly cellularAllowed: boolean;
}

export interface ScheduledItem {
  readonly id: string;
  readonly priority: Priority;
  readonly wave: number;
  readonly startMs: number;
  readonly finishMs: number;
  readonly missedDeadline: boolean;
}

export interface DeferredItem {
  readonly id: string;
  readonly reason: "cellular-policy" | "dependency-deferred";
}

export interface DownloadSchedule {
  readonly network: "wifi" | "cellular";
  readonly items: readonly ScheduledItem[];
  readonly deferred: readonly DeferredItem[];
  readonly waves: readonly (readonly string[])[];
  readonly totalBytes: number;
  readonly totalMs: number;
  readonly missed: readonly string[];
}

export const MB = 1024 * 1024;

const PRIORITY_RANK: Record<Priority, number> = {
  blocking: 0,
  prefetch: 1,
  idle: 2,
};

function stageRequests(requests: readonly DownloadRequest[]): Map<string, number> {
  const byId = new Map<string, DownloadRequest>();
  for (const request of requests) {
    if (byId.has(request.id)) throw new Error(`duplicate request: ${request.id}`);
    if (!Number.isInteger(request.bytes) || request.bytes <= 0) {
      throw new Error(`invalid bytes for ${request.id}: ${request.bytes}`);
    }
    if (!Number.isInteger(request.deadlineMs) || request.deadlineMs < 0) {
      throw new Error(`invalid deadline for ${request.id}: ${request.deadlineMs}`);
    }
    byId.set(request.id, request);
  }

  const stage = new Map<string, number>();
  const visiting = new Set<string>();

  const stageOf = (id: string): number => {
    const cached = stage.get(id);
    if (cached !== undefined) return cached;
    if (visiting.has(id)) throw new Error(`dependency cycle at ${id}`);
    visiting.add(id);
    const request = byId.get(id);
    if (request === undefined) throw new Error(`unknown request: ${id}`);
    let value = 0;
    for (const dependency of request.dependsOn) {
      if (!byId.has(dependency)) {
        throw new Error(`unknown dependency: ${id} -> ${dependency}`);
      }
      value = Math.max(value, stageOf(dependency) + 1);
    }
    visiting.delete(id);
    stage.set(id, value);
    return value;
  };

  for (const request of requests) stageOf(request.id);
  return stage;
}

export function planDownloads(
  requests: readonly DownloadRequest[],
  options: {
    readonly concurrency: number;
    readonly bandwidthBps: number;
    readonly network: "wifi" | "cellular";
  },
): DownloadSchedule {
  const { concurrency, bandwidthBps, network } = options;
  if (!Number.isInteger(concurrency) || concurrency < 1) {
    throw new Error(`invalid concurrency: ${concurrency}`);
  }
  if (!Number.isFinite(bandwidthBps) || bandwidthBps <= 0) {
    throw new Error(`invalid bandwidth: ${bandwidthBps}`);
  }

  const stage = stageRequests(requests);
  const ordered = [...requests].sort(
    (a, b) => (stage.get(a.id) ?? 0) - (stage.get(b.id) ?? 0),
  );

  const deferred = new Map<string, DeferredItem>();
  const active: DownloadRequest[] = [];
  for (const request of ordered) {
    const policyBlocked =
      network === "cellular" &&
      !request.cellularAllowed &&
      request.priority !== "blocking";
    if (policyBlocked) {
      deferred.set(request.id, { id: request.id, reason: "cellular-policy" });
      continue;
    }
    const blockedBy = request.dependsOn.find((dependency) => deferred.has(dependency));
    if (blockedBy !== undefined) {
      deferred.set(request.id, { id: request.id, reason: "dependency-deferred" });
      continue;
    }
    active.push(request);
  }

  const waves: string[][] = [];
  const items: ScheduledItem[] = [];
  const finishById = new Map<string, number>();
  const maxStage = active.reduce(
    (max, request) => Math.max(max, stage.get(request.id) ?? 0),
    -1,
  );
  let clock = 0;

  for (let index = 0; index <= maxStage; index += 1) {
    const layer = active
      .filter((request) => stage.get(request.id) === index)
      .sort((a, b) => {
        const rank = PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority];
        if (rank !== 0) return rank;
        if (a.deadlineMs !== b.deadlineMs) return a.deadlineMs - b.deadlineMs;
        if (a.bytes !== b.bytes) return b.bytes - a.bytes;
        return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
      });

    for (let offset = 0; offset < layer.length; offset += concurrency) {
      const wave = layer.slice(offset, offset + concurrency);
      const startMs = wave.reduce((max, request) => {
        let value = clock;
        for (const dependency of request.dependsOn) {
          value = Math.max(value, finishById.get(dependency) ?? 0);
        }
        return Math.max(max, value);
      }, clock);
      const share = bandwidthBps / wave.length;
      let waveEnd = startMs;

      for (const request of wave) {
        const finishMs = startMs + Math.round((request.bytes / share) * 1000);
        finishById.set(request.id, finishMs);
        waveEnd = Math.max(waveEnd, finishMs);
        items.push({
          id: request.id,
          priority: request.priority,
          wave: waves.length,
          startMs,
          finishMs,
          missedDeadline: finishMs > request.deadlineMs,
        });
      }

      waves.push(wave.map((request) => request.id));
      clock = waveEnd;
    }
  }

  const totalBytes = active.reduce((sum, request) => sum + request.bytes, 0);

  return {
    network,
    items,
    deferred: [...deferred.values()],
    waves,
    totalBytes,
    totalMs: items.reduce((max, item) => Math.max(max, item.finishMs), 0),
    missed: items
      .filter((item) => item.missedDeadline)
      .map((item) => item.id),
  };
}

export function formatSchedule(schedule: DownloadSchedule): string {
  const lines = ["request\tpriority\twave\tstart\tfinish\tstatus"];
  for (const item of schedule.items) {
    lines.push(
      `${item.id}\t${item.priority}\t${item.wave}\t${item.startMs}ms\t${item.finishMs}ms\t${item.missedDeadline ? "late" : "ok"}`,
    );
  }
  for (const item of schedule.deferred) {
    lines.push(`${item.id}\tdeferred\t-\t-\t-\t${item.reason}`);
  }
  lines.push(
    `summary\t${schedule.network}\twaves=${schedule.waves.length}\titems=${schedule.items.length}\tdeferred=${schedule.deferred.length}\ttotal=${schedule.totalMs}ms`,
  );
  return lines.join("\n");
}

stageRequests 先校验 id 唯一、字节为正、截止时间非负,再用带访问状态的深度优先遍历计算阶段。阶段值等于所有依赖阶段的最大值加一;遇到正在访问的节点说明存在环,立即抛错;依赖不存在也直接抛错,避免请求悄悄消失。

planDownloads 先按阶段排序,再逐项应用蜂窝策略。被策略挡住的请求进入 deferred;依赖被挡住的请求标记为 dependency-deferred。因为按阶段遍历,依赖一定先于被依赖者处理,传播只需要一轮。

波次在阶段内按并发上限切分。每个波次的开始时间取「当前时钟」与「所有依赖完成时间」的最大值,带宽在波次内均分,完成时间按字节数换算。missedDeadline 在生成每个条目时就算好,报表不需要二次遍历。

formatSchedule 输出每个请求的优先级、波次、起止时间和状态,被推迟的请求单独列出并附原因,最后一行是汇总。它可以直接贴进客户端日志,也可以作为调度参数的回归对比。

运行结果与边界

用一组小样本运行,带宽设为 1,000 字节每秒、并发上限 2。字节数是示意值,不是方舟的真实包体积:

request	priority	wave	start	finish	status
base	blocking	0	0ms	200ms	ok
idle-tip	idle	0	0ms	100ms	ok
battle	blocking	1	200ms	400ms	ok
voice	prefetch	2	400ms	800ms	ok
summary	wifi	waves=3	items=4	deferred=0	total=800ms

这张表能回答三个问题:每个请求在第几波、什么时候完成、有没有迟到。真实接入时,字节数会放大到 260MB 这个量级,波次数量取决于并发上限和带宽,结构不变。

把网络切成蜂窝,voice 会因为不允许蜂窝而进入 deferred,依赖它的请求会变成 dependency-deferred。这时 totalBytes 只统计真正会下载的请求,报表不会把被策略挡住的体积算进流量预算。

第一条边界是带宽模型。均分带宽是一个保守的近似,真实网络里先完成的请求会把带宽让给后完成的请求,实际完成时间通常早于估算。调度器给出的应该是上界,而不是精确值。

第二条边界是断点续传。调度只决定顺序与时机,不决定分片大小、重试次数和续传协议。方舟的断线率是 1.4%,重连成功率 98.1%,重连后有 31% 需要补状态;这些属于连接层与同步层,不属于下载调度。

第三条边界是内容正确性。调度器不验证包内容是否与清单一致,校验属于下载器;它也不决定包里有什么,那属于 8.x 的打包策略和 9.1 的首包准入。

与相邻章节的关系

9.1 决定了首包里有什么,本节决定首包之外的内容什么时候下。8.3 给出了包的分层与依赖阶段,本节把阶段接到并发波次和截止时间上;9.3 会把方舟与消消的首包、分包和流量放在同一张账上对照。

本节不讨论 CDN 回源、分片协议和热更差量,那些属于 10.x 与 11.x;也不讨论战斗同步本身,那属于 5.x。调度器只回答「先下谁、何时下、下不到怎么办」,不回答「下载的内容对不对」。

小结

  • 按需下载是一个调度问题:先下谁由优先级决定,何时下由预取窗口决定,下不到由降级路径决定。
  • 依赖关系决定阶段,阶段内再按优先级、截止时间和字节数排序,排序键必须包含 id 以保证可复现。
  • 并发上限把阶段切成波次,带宽在波次内均分;并发太小会串行等待,太大会争抢带宽。
  • 蜂窝策略必须显式到每个请求,被挡住的请求要记录原因,并把依赖它的请求标记为 dependency-deferred。
  • 迟到不等于失败,missedDeadline 要单独统计;截止时间会反向影响同阶段的排序。

阅读导航:上一节:9.1 首包边界:什么必须进包 · 下一节:9.3 双案例的首包账:120MB 与 350MB 。

常见错误与边界

  1. 把调度器当成下载器。它决定顺序与时机,不决定分片、重试和续传。
  2. 按体积或资源类型排优先级。优先级应该按「缺了这一步能不能继续」,不是按包大小。
  3. 所有阶段串行执行。同阶段无依赖的请求必须并发,否则深度 9 的依赖链会变成九次等待。
  4. 并发上限设得过大。连接互相争抢带宽,首个可用包的就绪时间反而变长。
  5. 排序键不确定。缺少 id 兜底会让同一份请求清单产生不同顺序,日志无法复现。
  6. 预取无条件走蜂窝。方舟每 DAU 日流量 21MB 不含资源下载,失控的预取会轻易超过业务流量。
  7. 被策略挡住的请求静默丢弃。依赖它的请求会进入永久等待,必须标记 dependency-deferred。
  8. 把迟到混进失败率。失败可以重试,迟到已经发生在玩家眼前,两个指标要分开统计。
  9. 用 P50 带宽估算截止时间。调度会系统性乐观,弱网会话占 14% 时尤其明显。
  10. 边界:调度不保证内容正确。校验属于下载器,包里有什么属于打包策略。
  11. 边界:调度不处理断线与重连。断线率 1.4%、重连成功率 98.1% 属于连接层。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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