《移动游戏产品的架构·网络、资源与热更新》12.1 下载器设计:分片、并发与断点续传

消消总资源 480MB、12,800 个文件,弱网会话占 22%,弱网下载成功率只有 92%。本节把下载拆成分片、并发与续传状态三件事,说明分片大小如何决定单次中断的重下上限、并发数为什么必须按网络和机型分级,以及续传状态怎样在进程被杀后仍然可信。

一次 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-Fi8MB63链路稳定,大分片减少请求开销
4G4MB43兼顾吞吐与中断损失
弱网1MB25单次中断最多损失 1MB,小片更值得多试

这张表里的数值是下载器的设计参数,不是消消的线上统计。它们的依据是同一个权衡:一次中断的期望损失约等于「分片大小 × 中断概率」,而请求开销随分片变小而上升。弱网的中断概率高,所以把分片压到 1MB;Wi-Fi 的中断概率低,8MB 更划算。

用 90MB 的分包算一遍:按 1MB 切是 90 个分片,按 8MB 切是 12 个分片。前者每个分片独立校验、独立重试,一次中断最多重下 1MB;后者请求数少,但一次中断可能白传 8MB。对 22% 的弱网会话来说,前者明显更合适。

分片还带来一个副作用:它把「文件级重试」变成了「分片级重试」。文件级重试在失败时要从头再来,分片级重试只补缺失的片。下载器因此需要为每个分片维护独立状态,而不能只记一个文件偏移量。

并发:不是越多越快

并发数解决的是「同时开几条连接」。直觉上开得越多越快,但在移动网络里,这个直觉经常是反的。

第一条限制来自链路本身。弱网下可用带宽有限,多条连接只会互相争抢,每条都变慢,超时反而更频繁。弱网并发取 2,是为了让每条连接都有足够的带宽完成一个分片,而不是让所有连接一起卡在超时边缘。

第二条限制来自设备。消消有 35% 的用户使用 4GB 以下内存的低端机。并发下载意味着同时打开多个文件句柄、多个缓冲区、多个校验任务,内存和磁盘 I/O 都会被放大。低端机的并发上限被压到 3,分片同时被压到不超过 4MB,这是对设备的保护,不是对网络的妥协。

第三条限制来自服务端。CDN 对单 IP 的并发连接有配额,开得过多会被限速甚至拒绝。下载器的并发数应该是「客户端愿意开」和「服务端允许开」的较小值,而不是客户端单方面决定的数字。

设备/网络组合并发分片说明
Wi-Fi + 普通机68MB默认档
Wi-Fi + 低端机34MB内存与 I/O 优先
4G + 普通机44MB默认档
弱网(任意机型)21MB先保证完成,再谈速度

断点续传:状态必须能跨进程

续传的难点不在 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 选型与回源策略 。

常见错误与边界

  1. 全局一个分片大小。Wi-Fi 与弱网用同一档,弱网的中断损失被放大数倍。
  2. 把并发当成越快越好。移动网络下并发过高会让每条连接都变慢,超时反而增加。
  3. 忽略低端机。35% 的 4GB 以下机型承受不住高并发带来的内存与 I/O 峰值。
  4. 用字节偏移代替分片状态。偏移量无法表达「这一片是否校验通过」,续传会拼出坏文件。
  5. 续传状态只放内存。进程被系统回收后状态丢失,玩家回来发现要从头下载。
  6. 续传记录不绑定版本。资源升级后按旧偏移继续,得到的是两个版本的混合体。
  7. 把失败分片无限重排。没有尝试上限,弱网玩家会卡在同一个分片上反复消耗流量。
  8. 只统计成功与失败,不统计浪费。没有 wasted,就无法判断重试策略是否值得。
  9. 边界:分片校验不等于文件校验。整文件仍需一次端到端校验才能加载。
  10. 边界:Range 不是所有节点都支持。不支持时只能退化为单流,策略要在 12.3 收口。
  11. 边界:下载器不决定降级。是否跳过资源、是否换 CDN,属于产品与交付策略,不属于分片逻辑。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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