《互联网产品的客户端架构·内容、社交与实时互动》4.1 上传编排:分片、并发与进度语义

一条 48MB 的视频会把上传器的问题全部放大:分片多大、并发几个、进度条该信哪个数、失败算不算完成。本节用内存传输适配器与可注入时钟实现上传编排器,区分线路进度与已确认进度,并复盘一次进度跑到 100% 而服务端合并失败的事故,给出可测试的完成判定。

本节不做什么

一条内容从端上到可播放,会经过选中文件、上传、转码、审核几段。本节只处理第一段里客户端可控的部分:一次上传如何切片、并发、汇报进度、判定结束。跨进程恢复留给 4.2,图片格式与降级留给 4.3;3.3 的懒加载与占位也不在这里重复,那是媒体已经可读之后的问题。

一条 48MB 的视频会暴露什么

「回声」的发布器支持 9 张图或一段 3 分钟视频,验收标准只有一句:弱网下要能看到进度,别卡死。第一次演示出了三种现象。

现象表面解释实际原因
Wi-Fi 下进度从 0 直接跳到 100文件太小、传得太快只按请求完成更新,没有片内进度
切后台回来进度归零页面被系统重建会话与已传清单没有离开内存
失败弹窗出现时进度仍是 100%完成判定看错了事件把最后一个分片的网络回调当成服务端接受

三个现象对应三件事:进度语义、会话保持、完成判定。换一个上传库不会自动解决,因为库只提供传输,语义必须在编排层定义。

分片大小:决定的是失败成本

分片大小同时影响请求开销、重传成本、进度粒度与内存峰值:

偏小偏大
每片都有鉴权与元数据开销,请求数上升单片失败要重传更多字节
服务端临时对象与合并任务变多片内进度粒度粗,看起来像卡住
并发窗口很快被小请求占满端上缓冲区与单请求超时风险上升

一个常见起点是 1–4MB。低于 1MB 时固定开销占比明显,高于 8MB 时一次重传可能几十秒。真正的上限通常来自服务端:对象存储单次 PUT 限制、网关请求体限制、合并任务超时。这些数字要先问服务端,再定端上默认值。

还有一条容易忽略:分片大小是会话属性,不是全局常量。App 升级把默认值从 4MB 改成 2MB,昨天没传完的会话仍按 4MB 切;恢复时沿用旧切法,否则服务端拼出的文件边界全错。这一点 4.2 会展开。

并发:让链路一直有数据

移动端通常选 2–3 个并发。理由不是「多了浪费」,而是:

  • 链路带宽共享,4 个并发只是让每片都变慢,首片完成更晚。
  • 并发越高,弱网下同时失败的分片越多,重试越难收敛。
  • 服务端多按用户或会话限流,超限的请求拿到 429,多一轮往返。

假设单分片有效吞吐 300KB/s、分片 4MB,一片约 14 秒;并发 2 的 10 片理论下限约 70 秒,并发 4 也不会低于链路总带宽决定的下限。收益来自「始终有数据在飞」,不是「同时开更多请求」。

因此并发窗口的实现要点是有空位就补,而不是一次全部发出去。批量 Promise.all 会让一批里最慢的分片决定下一批的开始,长尾被放大。

两个进度不是一回事

这是本节最想讲清楚的一点。

字段含义会回退吗
sentBytes已交给传输层的字节,含在途分片的部分字节会
confirmedBytes服务端已确认落盘的分片字节不会
wireRatiosentBytes / totalBytes会
confirmedRatioconfirmedBytes / totalBytes不会

sentBytes 为什么会回退?一个分片传到 80% 时连接被重置,这 80% 就作废了。如果实现写的是 uploadedBytes += event.loaded,这 80% 会永远留在累加器里;重试一次再加一遍,最后进度超过 100%,或者还有分片没传完就显示完成。

正确做法不是「记得减回去」,而是不用全局累加器:每个分片的已发字节单独记账,汇总时现算;分片失败就把它的记账清零,回退自然发生。

UI 两个都要用:展示用 wireRatio,因为它平滑,但不能回退、不能在未确认时显示 100%;判定用 confirmedRatio 与确认清单,因为只有服务端认过的字节才算数。smoothProgress 就是折中:取历史最大值,未完成时封顶 0.99。

编排器实现

// file: src/upload/orchestrator.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/upload/orchestrator.test.ts

export interface ChunkPlan { index: number; start: number; end: number; size: number }

export interface ChunkSendInput {
  sessionId: string;
  plan: ChunkPlan;
  onProgress: (sentBytes: number) => void;
}

export interface ChunkTransport { send(input: ChunkSendInput): Promise<void> }
export interface Clock { now(): number }

export type ChunkStatus = "pending" | "inflight" | "confirmed" | "failed";

export interface UploadProgress {
  sessionId: string;
  totalBytes: number;
  totalChunks: number;
  confirmedChunks: number;
  confirmedBytes: number;
  sentBytes: number;
  inFlight: number;
  paused: boolean;
  wireRatio: number;
  confirmedRatio: number;
  done: boolean;
}

export interface OrchestratorOptions {
  sessionId: string;
  totalBytes: number;
  chunkSize: number;
  concurrency: number;
  transport: ChunkTransport;
  clock?: Clock;
  throttleMs?: number;
  onProgress?: (progress: UploadProgress) => void;
}

export interface UploadResult { sessionId: string; totalBytes: number; confirmedChunks: number }

export class ChunkUploadError extends Error {
  readonly chunkIndex: number;
  constructor(chunkIndex: number, cause: unknown) {
    super(`分片 ${chunkIndex} 上传失败`);
    this.name = "ChunkUploadError";
    this.chunkIndex = chunkIndex;
    this.cause = cause;
  }
}

export function planChunks(totalBytes: number, chunkSize: number): ChunkPlan[] {
  if (!Number.isSafeInteger(totalBytes) || totalBytes < 0) throw new RangeError("totalBytes 必须是非负安全整数");
  if (!Number.isSafeInteger(chunkSize) || chunkSize <= 0) throw new RangeError("chunkSize 必须是正安全整数");
  const plans: ChunkPlan[] = [];
  for (let start = 0; start < totalBytes; start += chunkSize) {
    const end = Math.min(start + chunkSize, totalBytes);
    plans.push({ index: plans.length, start, end, size: end - start });
  }
  return plans;
}

/** 把线路进度压成一条只前进的展示曲线;未全部确认时封顶 0.99。 */
export function smoothProgress(previous: number, progress: UploadProgress): number {
  const floor = Number.isFinite(previous) ? Math.min(1, Math.max(0, previous)) : 0;
  const target = Math.min(progress.wireRatio, progress.done ? 1 : 0.99);
  return Math.min(1, Math.max(floor, target));
}

export class UploadOrchestrator {
  readonly #sessionId: string;
  readonly #plans: ChunkPlan[];
  readonly #totalBytes: number;
  readonly #concurrency: number;
  readonly #transport: ChunkTransport;
  readonly #clock: Clock;
  readonly #throttleMs: number;
  readonly #onProgress: ((progress: UploadProgress) => void) | undefined;
  readonly #done: Promise<UploadResult>;
  #statuses: ChunkStatus[];
  #sent: number[];
  #inFlight = new Map<number, Promise<void>>();
  #cursor = 0;
  #paused = false;
  #started = false;
  #settled = false;
  #failed = false;
  #lastEmit = Number.NEGATIVE_INFINITY;
  #resolve!: (result: UploadResult) => void;
  #reject!: (error: unknown) => void;

  constructor(options: OrchestratorOptions) {
    if (!Number.isSafeInteger(options.concurrency) || options.concurrency <= 0) {
      throw new RangeError("concurrency 必须是正安全整数");
    }
    this.#sessionId = options.sessionId;
    this.#plans = planChunks(options.totalBytes, options.chunkSize);
    this.#totalBytes = this.#plans.at(-1)?.end ?? 0;
    this.#concurrency = options.concurrency;
    this.#transport = options.transport;
    this.#clock = options.clock ?? { now: () => Date.now() };
    this.#throttleMs = options.throttleMs ?? 100;
    this.#onProgress = options.onProgress;
    this.#statuses = this.#plans.map(() => "pending");
    this.#sent = this.#plans.map(() => 0);
    this.#done = new Promise<UploadResult>((resolve, reject) => {
      this.#resolve = resolve;
      this.#reject = reject;
    });
  }

  get plans(): ChunkPlan[] { return this.#plans.map((plan) => ({ ...plan })); }

  status(index: number): ChunkStatus {
    const status = this.#statuses[index];
    if (status === undefined) throw new RangeError(`分片索引越界: ${index}`);
    return status;
  }

  start(): Promise<UploadResult> {
    if (this.#started) throw new Error("同一个编排器只能 start 一次");
    this.#started = true;
    this.#pump();
    return this.#done;
  }

  pause(): void {
    if (this.#paused || !this.#started || this.#settled) return;
    this.#paused = true;
    this.#emit(true);
  }

  resume(): void {
    if (!this.#paused || this.#settled) return;
    this.#paused = false;
    this.#emit(true);
    this.#pump();
  }

  progress(): UploadProgress {
    let confirmedChunks = 0, confirmedBytes = 0, sentBytes = 0, inFlight = 0;
    for (const plan of this.#plans) {
      const status = this.#statuses[plan.index];
      if (status === "confirmed") { confirmedChunks += 1; confirmedBytes += plan.size; }
      if (status === "inflight") inFlight += 1;
      sentBytes += this.#sent[plan.index] ?? 0;
    }
    const ratio = (bytes: number): number =>
      this.#totalBytes > 0 ? bytes / this.#totalBytes : this.#started ? 1 : 0;
    return {
      sessionId: this.#sessionId,
      totalBytes: this.#totalBytes,
      totalChunks: this.#plans.length,
      confirmedChunks,
      confirmedBytes,
      sentBytes,
      inFlight,
      paused: this.#paused,
      wireRatio: ratio(sentBytes),
      confirmedRatio: ratio(confirmedBytes),
      done: this.#started && !this.#failed && confirmedChunks === this.#plans.length,
    };
  }

  #launch(plan: ChunkPlan): void {
    this.#statuses[plan.index] = "inflight";
    this.#sent[plan.index] = 0;
    const sending = this.#transport
      .send({ sessionId: this.#sessionId, plan, onProgress: (bytes) => this.#onChunkProgress(plan, bytes) })
      .then(() => this.#onChunkConfirmed(plan), (error: unknown) => this.#onChunkFailed(plan, error));
    this.#inFlight.set(plan.index, sending);
    this.#emit();
  }

  #onChunkProgress(plan: ChunkPlan, bytes: number): void {
    if (this.#settled || this.#statuses[plan.index] !== "inflight") return;
    const clamped = Math.min(plan.size, Math.max(0, Math.floor(bytes)));
    if (clamped === this.#sent[plan.index]) return;
    this.#sent[plan.index] = clamped;
    this.#emit();
  }

  #onChunkConfirmed(plan: ChunkPlan): void {
    this.#inFlight.delete(plan.index);
    if (this.#settled) return;
    this.#statuses[plan.index] = "confirmed";
    this.#sent[plan.index] = plan.size;
    this.#emit();
    this.#pump();
  }

  #onChunkFailed(plan: ChunkPlan, error: unknown): void {
    this.#inFlight.delete(plan.index);
    if (this.#settled) return;
    this.#statuses[plan.index] = "failed";
    this.#sent[plan.index] = 0;
    this.#failed = true;
    this.#settled = true;
    this.#emit(true);
    this.#reject(new ChunkUploadError(plan.index, error));
  }

  #pump(): void {
    if (this.#paused || this.#settled) return;
    while (this.#inFlight.size < this.#concurrency && this.#cursor < this.#plans.length) {
      const plan = this.#plans[this.#cursor];
      this.#cursor += 1;
      this.#launch(plan);
    }
    if (this.#statuses.every((status) => status === "confirmed")) {
      this.#settled = true;
      this.#emit(true);
      this.#resolve({ sessionId: this.#sessionId, totalBytes: this.#totalBytes, confirmedChunks: this.#plans.length });
    }
  }

  #emit(force = false): void {
    const listener = this.#onProgress;
    if (!listener) return;
    const now = this.#clock.now();
    if (!force && now - this.#lastEmit < this.#throttleMs) return;
    this.#lastEmit = now;
    listener(this.progress());
  }
}

逐段解释

分片计划是纯函数

planChunks 把 [0, totalBytes) 切成左闭右开区间,末片截断,0 字节返回空数组。独立出来是因为服务端合并、断点续传、测试断言都要用同一套边界;让编排器自己算,恢复时就容易出现端上按 4MB、服务端按 5MB 的错位。

并发靠游标补位

#cursor 指向下一个未启动的分片,#inFlight 记录在途。每次确认后 #pump 填满空位。这里不用 Promise.all 分批,原因见上一节的长尾。

完成只看确认清单

#pump 的结束条件是所有状态为 confirmed,不是「最后一个分片返回」,也不是 wireRatio === 1。服务端可以乱序确认,也可能合并失败。0 字节文件是空数组,every 对空数组为真,所以 start() 后立即完成,调用方不必为 0 字节写特例。

暂停只停调度

pause() 置位后 #pump 直接返回,在途分片继续跑完。不 abort 是因为半片数据会让服务端多做一次清理;代价是暂停后仍可能有几秒流量,按钮文案应写「正在暂停」而不是「已暂停」。

失败即终止

#onChunkFailed 把分片标为 failed、清掉已发字节、reject 整次上传。重试不在调度器里:它需要错误类型、退避次数、幂等约定,属于 4.2。调度器只保证失败后不再启动新分片,也不会误判完成。

进度回调要节流

传输层可能几十毫秒回调一次,直接驱动 UI 会每帧多次 setState。#emit 用最小间隔合并事件,但完成与失败强制发一次,否则用户会看到进度停在 97% 然后突然成功。时钟从构造参数注入,测试才能在没有真实等待的情况下验证节流。

测试与真实输出

$ node --test --test-concurrency=1 test/upload/orchestrator.test.ts
✔ 分片计划覆盖完整文件,末片截断,0 字节没有分片 (0.994875ms)
✔ 并发窗口只启动 concurrency 个分片 (0.321208ms)
✔ 乱序完成不会提前结束,全部分片确认后才 resolve (0.203209ms)
✔ 进度同时暴露在途字节与已确认字节 (0.181125ms)
✔ 分片失败丢弃在途字节:wireRatio 回退,confirmedRatio 不动 (0.25925ms)
✔ smoothProgress 只前进,未完成时封顶 0.99 (0.091334ms)
✔ 暂停只停新分片,在途分片照常完成,恢复后继续 (0.15125ms)
✔ 进度回调按最小间隔节流,完成时强制发出最后一次 (0.163708ms)
✔ 0 字节文件 start 后立即完成,重复 start 抛错 (0.128792ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 147.426333

九条用例覆盖编排层最容易退化的行为:边界切分、并发窗口、乱序完成、两种进度、失败回退、展示曲线、暂停、节流、0 字节与重复启动。它们不碰真实网络与服务端合并,那是 L3 与集成测试的范围。

复盘:进度 100% 却上传失败

第一版把「最后一个分片的 load 事件」当成上传成功,理由是「分片都传完了,当然成功了」。上线后遇到两类失败:

  1. 服务端合并超时。分片都在,合并任务失败,客户端已经跳到成功页,用户刷新才发现内容不存在。
  2. 分片大小校验不过。客户端按 4MB 切,服务端上一个版本按 5MB 限制,最后一片被拒,但 load 仍然触发——HTTP 200 带着业务错误码。

修复分三层:协议上增加 commit 接口,只有它返回 asset id 才算成功;端上完成判定改为「全部分片 confirmed 且 commit 成功」;进度条在 commit 前封顶 99%。另一个隐藏 bug 是 uploadedBytes += event.loaded 在重试时重复累加,进度一度显示 137%,这正是「用累加器记进度」的典型后果。

小结

上传编排要定义四件事:分片边界、并发窗口、进度的两个口径、完成判定。分片大小是会话属性,并发要按空位补,进度要用分片记账而不是全局累加,完成只认服务端确认与 commit。把这些写进可测试的逻辑,弱网下的表现才有讨论的基础。

下一节处理本节刻意留下的部分:进程被杀、网络切换、ack 丢失之后,如何从上次的位置继续。

阅读导航:上一节:3.3 媒体懒加载与占位:CLS 的端上来源 · 下一节:4.2 断点续传与失败恢复 。

常见错误与边界

  • 用全局 uploadedBytes += loaded 记进度。重试会重复累加,进度超过 100% 或提前完成。
  • 把最后一个分片的网络回调当成上传成功。服务端合并、校验、转码都可能在此之后失败。
  • 进度条未确认就显示 100%。用户会认为已经发布,实际还在合并。
  • 并发开得越大越好。共享带宽下总时长不变,失败面变大,限流更容易触发。
  • 暂停时 abort 在途请求。服务端留下半片数据,恢复时还要额外清理。
  • 恢复时按当前策略重新分片。旧会话的边界与清单全部对不上。
  • 忽略 0 字节文件。空数组上 every 为真,不处理会永远不完成。
  • 把调度器写成重试器。错误分类、退避、幂等属于恢复层,混在一起后两边都难测。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查