一次 90MB 分包在弱网里重下了四遍
《潮汐消消》的总资源是 480MB,其中首包 120MB,其余 360MB 是 4 个 90MB 的分包,在玩家首次进入对应玩法时下载。CDN 月下载量约 201TB,下载成功率整体 97.5%,弱网只有 92%。消消的弱网会话占比是 22%,这意味着每 100 个弱网玩家中,大约有 8 个会在下载环节失败。
失败的原因很少是「完全连不上」。更常见的是下载到一半,玩家切后台、接电话、进地铁,连接断了。如果下载器以 8MB 为单位请求,那么在任何一次中断里,最多已经传完但尚未校验的 8MB 都要重来。一个 90MB 的分包,在 1MB/s 左右的弱网里要 90 秒;90 秒里断一次是常态,断两次也不稀奇。
这一节讨论的不是「怎么把文件下下来」,而是「怎么让一次中断只损失一个可控的小块」。分片决定损失的粒度,并发决定链路的利用率,续传状态决定进程被杀之后还能不能接着下。三者共同构成下载器的基本盘。
分片:把重传成本压进一片
分片的第一条规则是:分片大小必须按网络分级,而不是全局一个值。消消的资源文件平均约 38KB(480MB 除以 12,800 个文件),大量小文件本来就小于一个分片,分片策略真正影响的是那些几十 MB 的包体和热更文件。
| 网络 | 分片大小 | 并发 | 最大尝试次数 | 设计理由 |
|---|---|---|---|---|
| Wi-Fi | 8MB | 6 | 3 | 链路稳定,大分片减少请求开销 |
| 4G | 4MB | 4 | 3 | 兼顾吞吐与中断损失 |
| 弱网 | 1MB | 2 | 5 | 单次中断最多损失 1MB,小片更值得多试 |
这张表里的数值是下载器的设计参数,不是消消的线上统计。它们的依据是同一个权衡:一次中断的期望损失约等于「分片大小 × 中断概率」,而请求开销随分片变小而上升。弱网的中断概率高,所以把分片压到 1MB;Wi-Fi 的中断概率低,8MB 更划算。
用 90MB 的分包算一遍:按 1MB 切是 90 个分片,按 8MB 切是 12 个分片。前者每个分片独立校验、独立重试,一次中断最多重下 1MB;后者请求数少,但一次中断可能白传 8MB。对 22% 的弱网会话来说,前者明显更合适。
分片还带来一个副作用:它把「文件级重试」变成了「分片级重试」。文件级重试在失败时要从头再来,分片级重试只补缺失的片。下载器因此需要为每个分片维护独立状态,而不能只记一个文件偏移量。
并发:不是越多越快
并发数解决的是「同时开几条连接」。直觉上开得越多越快,但在移动网络里,这个直觉经常是反的。
第一条限制来自链路本身。弱网下可用带宽有限,多条连接只会互相争抢,每条都变慢,超时反而更频繁。弱网并发取 2,是为了让每条连接都有足够的带宽完成一个分片,而不是让所有连接一起卡在超时边缘。
第二条限制来自设备。消消有 35% 的用户使用 4GB 以下内存的低端机。并发下载意味着同时打开多个文件句柄、多个缓冲区、多个校验任务,内存和磁盘 I/O 都会被放大。低端机的并发上限被压到 3,分片同时被压到不超过 4MB,这是对设备的保护,不是对网络的妥协。
第三条限制来自服务端。CDN 对单 IP 的并发连接有配额,开得过多会被限速甚至拒绝。下载器的并发数应该是「客户端愿意开」和「服务端允许开」的较小值,而不是客户端单方面决定的数字。
| 设备/网络组合 | 并发 | 分片 | 说明 |
|---|---|---|---|
| Wi-Fi + 普通机 | 6 | 8MB | 默认档 |
| Wi-Fi + 低端机 | 3 | 4MB | 内存与 I/O 优先 |
| 4G + 普通机 | 4 | 4MB | 默认档 |
| 弱网(任意机型) | 2 | 1MB | 先保证完成,再谈速度 |
断点续传:状态必须能跨进程
续传的难点不在 HTTP 的 Range 头,而在「状态存在哪里、什么时候算数」。
一个分片只有两种可信状态:完整校验通过,或者没有。下载到 60% 的分片不能记为「已完成 60%」,因为下一次连接拿到的字节未必和上一次来自同一个版本。分片是原子单位,续传的粒度就是一个分片。
状态需要落到磁盘,而不是只放在内存里。玩家切后台后进程可能被系统回收,回来时下载器是全新启动的;如果完成记录只存在内存,之前下的分片全部作废。消消的冷启动 P95 是 3.2s,玩家不会接受每次回来都从头下载。
状态还要能识别「计划变了」。资源版本升级后,同一个文件的分片数量、边界甚至内容都会变。续传记录必须绑定资源版本与文件校验值,版本不匹配时整份记录作废,而不是按旧偏移量继续拼。
// file: src/cdn/downloader.ts
// tier: L1
// verify: node --test test/cdn/downloader.test.ts
export type NetworkClass = "wifi" | "four-g" | "weak";
export interface DownloadProfile {
readonly network: NetworkClass;
readonly chunkBytes: number;
readonly concurrency: number;
readonly maxAttempts: number;
}
export const DEFAULT_PROFILES: Record<NetworkClass, DownloadProfile> = {
wifi: { network: "wifi", chunkBytes: 8 * 1024 * 1024, concurrency: 6, maxAttempts: 3 },
"four-g": { network: "four-g", chunkBytes: 4 * 1024 * 1024, concurrency: 4, maxAttempts: 3 },
weak: { network: "weak", chunkBytes: 1024 * 1024, concurrency: 2, maxAttempts: 5 },
};
export interface FileSpec {
readonly id: string;
readonly bytes: number;
}
export interface Chunk {
readonly id: string;
readonly fileId: string;
readonly index: number;
readonly offset: number;
readonly bytes: number;
}
export interface ChunkProgress {
readonly attempts: number;
readonly done: boolean;
readonly failed: boolean;
}
export interface DownloadState {
readonly profile: DownloadProfile;
readonly chunks: readonly Chunk[];
readonly progress: ReadonlyMap<string, ChunkProgress>;
readonly totalBytes: number;
readonly completedBytes: number;
readonly wastedBytes: number;
}
export interface ChunkOutcome {
readonly ok: boolean;
readonly transferredBytes: number;
}
export function chooseProfile(network: NetworkClass, lowEnd = false): DownloadProfile {
const base = DEFAULT_PROFILES[network];
if (base === undefined) throw new Error(`unknown network class: ${String(network)}`);
if (!lowEnd || network === "weak") return base;
return {
...base,
chunkBytes: Math.min(base.chunkBytes, 4 * 1024 * 1024),
concurrency: Math.min(base.concurrency, 3),
};
}
export function planChunks(fileId: string, bytes: number, chunkBytes: number): readonly Chunk[] {
if (fileId.length === 0) throw new Error("file id must not be empty");
if (!Number.isInteger(bytes) || bytes < 0) throw new Error(`invalid file size: ${bytes}`);
if (!Number.isInteger(chunkBytes) || chunkBytes < 1) throw new Error(`invalid chunk size: ${chunkBytes}`);
const chunks: Chunk[] = [];
for (let offset = 0, index = 0; offset < bytes; offset += chunkBytes, index += 1) {
chunks.push({
id: `${fileId}#${index}`,
fileId,
index,
offset,
bytes: Math.min(chunkBytes, bytes - offset),
});
}
return chunks;
}
export function createDownloadState(files: readonly FileSpec[], profile: DownloadProfile): DownloadState {
if (!Number.isInteger(profile.chunkBytes) || profile.chunkBytes < 1) {
throw new Error(`invalid chunk size: ${profile.chunkBytes}`);
}
if (!Number.isInteger(profile.concurrency) || profile.concurrency < 1) {
throw new Error(`invalid concurrency: ${profile.concurrency}`);
}
if (!Number.isInteger(profile.maxAttempts) || profile.maxAttempts < 1) {
throw new Error(`invalid max attempts: ${profile.maxAttempts}`);
}
const seen = new Set<string>();
const chunks: Chunk[] = [];
const progress = new Map<string, ChunkProgress>();
let totalBytes = 0;
for (const file of files) {
if (seen.has(file.id)) throw new Error(`duplicate file id: ${file.id}`);
seen.add(file.id);
for (const chunk of planChunks(file.id, file.bytes, profile.chunkBytes)) {
chunks.push(chunk);
progress.set(chunk.id, { attempts: 0, done: false, failed: false });
}
totalBytes += file.bytes;
}
return { profile, chunks, progress, totalBytes, completedBytes: 0, wastedBytes: 0 };
}
export function applyChunkResult(
state: DownloadState,
chunkId: string,
outcome: ChunkOutcome,
): DownloadState {
const current = state.progress.get(chunkId);
if (current === undefined) throw new Error(`unknown chunk: ${chunkId}`);
if (current.done) throw new Error(`chunk already completed: ${chunkId}`);
if (current.failed) throw new Error(`chunk already failed: ${chunkId}`);
if (!Number.isInteger(outcome.transferredBytes) || outcome.transferredBytes < 0) {
throw new Error(`invalid transferred bytes: ${outcome.transferredBytes}`);
}
const chunk = state.chunks.find((item) => item.id === chunkId);
if (chunk === undefined) throw new Error(`chunk not found in plan: ${chunkId}`);
if (outcome.transferredBytes > chunk.bytes) {
throw new Error(`transferred more than chunk size: ${chunkId}`);
}
const attempts = current.attempts + 1;
const next = new Map(state.progress);
if (outcome.ok) {
next.set(chunkId, { attempts, done: true, failed: false });
return { ...state, progress: next, completedBytes: state.completedBytes + chunk.bytes };
}
const failed = attempts >= state.profile.maxAttempts;
next.set(chunkId, { attempts, done: false, failed });
return { ...state, progress: next, wastedBytes: state.wastedBytes + outcome.transferredBytes };
}
export function nextBatch(state: DownloadState, limit: number = state.profile.concurrency): readonly string[] {
if (!Number.isInteger(limit) || limit < 1) throw new Error(`invalid batch size: ${limit}`);
const ids: string[] = [];
for (const chunk of state.chunks) {
const item = state.progress.get(chunk.id);
if (item !== undefined && !item.done && !item.failed) {
ids.push(chunk.id);
if (ids.length === limit) break;
}
}
return ids;
}
export function formatDownloadProgress(state: DownloadState): string {
let chunks = 0;
let done = 0;
let failed = 0;
let retrying = 0;
for (const item of state.progress.values()) {
chunks += 1;
if (item.done) done += 1;
else if (item.failed) failed += 1;
else if (item.attempts > 0) retrying += 1;
}
return [
`downloaded=${state.completedBytes}/${state.totalBytes}B`,
`chunks=${done}/${chunks}`,
`pending=${chunks - done - failed}`,
`retrying=${retrying}`,
`failed=${failed}`,
`wasted=${state.wastedBytes}B`,
].join(" ");
}
实现把状态拆成三层。planChunks 只负责把一个文件切成确定的分片,分片 id 由文件 id 加序号组成,保证同一版本下可重复。createDownloadState 建立分片表与初始状态,并拒绝重复的文件 id。applyChunkResult 是唯一的状态推进入口:成功则累计完成字节,失败则累计已传输字节并增加尝试次数,达到上限后把分片标记为失败。nextBatch 按分片顺序返回下一批待下载项,进程重启后只要重建同样的分片表,就能从上次的完成记录继续。
这里有一个刻意的设计:失败的分片不会被自动重排到队尾无限重试。尝试次数用尽就是失败,交给上层决定是换 CDN、降级还是提示玩家。下载器只负责如实记账,不负责替产品做取舍。
运行结果与边界
用两个小文件跑一遍:ui 10 字节切成两片,level 5 字节一片;完成 ui#0,ui#1 失败一次并已传 2 字节。formatDownloadProgress 的输出是:
downloaded=5/15B chunks=1/3 pending=2 retrying=1 failed=0 wasted=2B
这行输出回答四个问题:完成了多少字节、完成了几个分片、还有几个在排队、失败重试已经浪费了多少流量。wasted 是关键指标,它让「弱网重试」从一种感觉变成一个可以进报表的数字。
边界同样要说清楚。分片不是越小越好:1MB 分片意味着 90MB 的包要发 90 个请求,请求建立与校验的开销会吃掉一部分收益,所以只在弱网启用。并发也不是静态值:同一台设备从 Wi-Fi 切到 4G 时,下载器应该重算档位,而不是沿用启动时的并发数。续传依赖服务端支持 Range,遇到不支持 Range 的节点只能退化为单流下载,这个分支属于 12.3 的降级策略。
还有一个常被忽略的边界:分片校验通过不等于文件可用。分片校验只能证明「这一片传对了」,整文件还需要一次端到端校验。两层校验都通过,才能把文件标记为可加载。
与相邻章节的关系
11.3 讨论回滚与版本收敛,下载器是回滚的执行末端:清单指回旧版本时,下载器要能识别版本变化并作废不匹配的续传记录。12.2 讨论 CDN 选型与回源,分片请求最终打到哪个边缘节点、节点是否支持 Range,决定了本节的分片策略能否成立。12.3 会把这里的「分片失败」接到重试阶梯与降级策略上,回答失败之后往哪走。
跨卷来看,续传状态要跨进程存活,和客户端卷 4.3 的进程被杀恢复是同一类问题:状态必须落盘、必须带版本、必须能判断自己是否还有效。区别只在于一个恢复的是对局,一个恢复的是下载进度。
小结
- 分片大小按网络分级,弱网用小片换取更低的重传损失。
- 并发数受链路、设备和服务端三重限制,低端机必须单独降档。
- 分片是续传的原子单位,下载到一半的分片不可信,也不可累加。
- 续传状态要落盘、绑定资源版本,并在版本变化时整体作废。
- 下载器只记账不做产品决策,失败分片交给上层处理。
阅读导航:上一节:11.3 回滚与版本收敛 · 下一节:12.2 CDN 选型与回源策略 。
常见错误与边界
- 全局一个分片大小。Wi-Fi 与弱网用同一档,弱网的中断损失被放大数倍。
- 把并发当成越快越好。移动网络下并发过高会让每条连接都变慢,超时反而增加。
- 忽略低端机。35% 的 4GB 以下机型承受不住高并发带来的内存与 I/O 峰值。
- 用字节偏移代替分片状态。偏移量无法表达「这一片是否校验通过」,续传会拼出坏文件。
- 续传状态只放内存。进程被系统回收后状态丢失,玩家回来发现要从头下载。
- 续传记录不绑定版本。资源升级后按旧偏移继续,得到的是两个版本的混合体。
- 把失败分片无限重排。没有尝试上限,弱网玩家会卡在同一个分片上反复消耗流量。
- 只统计成功与失败,不统计浪费。没有
wasted,就无法判断重试策略是否值得。 - 边界:分片校验不等于文件校验。整文件仍需一次端到端校验才能加载。
- 边界:Range 不是所有节点都支持。不支持时只能退化为单流,策略要在 12.3 收口。
- 边界:下载器不决定降级。是否跳过资源、是否换 CDN,属于产品与交付策略,不属于分片逻辑。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。