开局前的那几十秒:一个还没下完的战斗包
《潮汐方舟》的一名玩家在通勤路上打开游戏,点进排位,匹配成功后进入加载界面。此时战斗场景包还没下载完,加载条停在一半,队友已经在语音里催促。几十秒后他退出了对局,扣了分,也留下了一次断线记录。
这不是网络断了,而是调度没有回答一个本可以提前回答的问题:这个 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 。
常见错误与边界
- 把调度器当成下载器。它决定顺序与时机,不决定分片、重试和续传。
- 按体积或资源类型排优先级。优先级应该按「缺了这一步能不能继续」,不是按包大小。
- 所有阶段串行执行。同阶段无依赖的请求必须并发,否则深度 9 的依赖链会变成九次等待。
- 并发上限设得过大。连接互相争抢带宽,首个可用包的就绪时间反而变长。
- 排序键不确定。缺少 id 兜底会让同一份请求清单产生不同顺序,日志无法复现。
- 预取无条件走蜂窝。方舟每 DAU 日流量 21MB 不含资源下载,失控的预取会轻易超过业务流量。
- 被策略挡住的请求静默丢弃。依赖它的请求会进入永久等待,必须标记 dependency-deferred。
- 把迟到混进失败率。失败可以重试,迟到已经发生在玩家眼前,两个指标要分开统计。
- 用 P50 带宽估算截止时间。调度会系统性乐观,弱网会话占 14% 时尤其明显。
- 边界:调度不保证内容正确。校验属于下载器,包里有什么属于打包策略。
- 边界:调度不处理断线与重连。断线率 1.4%、重连成功率 98.1% 属于连接层。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。