《互联网产品的客户端架构·内容、社交与实时互动》4.2 断点续传与失败恢复

上传到 87% 时进程被系统回收,回来后是从头开始还是接着传?本节把上传会话拆成会话 id、文件指纹、已确认分片清单与过期时间,给出恢复决策表、以服务端为准的对账规则和按错误类型分流的重试策略,并复盘一次本地清单比服务端多、最终拼出缺片文件的事故。

本节不做什么

本节只回答「断了之后怎么办」:会话怎么建、恢复时信谁、重试等多久、跨进程要落盘什么。分片与并发的编排在 4.1,图片格式在 4.3;对象存储的合并实现属于服务端,不在客户端架构的讨论范围内。

上传到 87%,进程没了

用户在地铁里传一段 200MB 的视频,切到相机拍封面,回来时 App 已经被系统回收。这时有两种常见的错误做法:

  1. 从头传。 用户已经付出的流量和时间翻倍,弱网下可能永远传不完,最后放弃发布。
  2. 只把百分比存本地。 恢复后从 87% 继续,但服务端实际只收到 60% 的分片,最终文件缺了一段,播放到中途花屏。

断点续传的本质不是「记住进度百分比」,而是「记住服务端已经收下哪些分片」。百分比是给人看的,分片清单才是给程序用的。

会话里到底要存什么

一个可恢复的上传会话只需要七类字段:

字段作用不存的后果
sessionId服务端会话标识恢复时找不到临时对象,只能重传
fingerprint文件名、大小、修改时间无法判断当前选中的是不是同一个文件
chunkSize该会话的分片边界按新策略重切,边界与服务端不一致
totalBytes总大小,用于算分片数与进度无法校验清单完整性
confirmed服务端已确认的分片索引不知道从哪一片继续
updatedAt最后一次确认时间无法排查「为什么没恢复」
expiresAt服务端会话的过期时刻会拿一个已被回收的会话去续传

同样重要的是不存什么:

  • File 句柄、AbortController、进行中的 Promise:不可序列化,进程重启即失效。
  • 片内已发字节:恢复后要重新对齐传输层,存了也没有意义。
  • 当前并发数:它取决于恢复时的网络状况,不应该被旧会话冻结。

判断标准很简单:这个字段能不能 JSON.stringify,以及丢了它用户是否要重做一次高成本操作。两条都满足,才值得落盘。

恢复决策:四种结果

恢复不是「有快照就续传」,而是一个四分支决策:

结果触发条件端上动作
resume快照存在、未过期、指纹一致取服务端清单,从缺失分片继续
expiredexpiresAt <= now丢弃快照,新建会话,从头传
mismatch名称、大小、修改时间任一不同丢弃快照,按新文件重建
restart没有快照或快照损坏新建会话

指纹用「名称 + 大小 + 修改时间」而不是内容哈希,是一个明确的取舍:对 200MB 文件算一次 SHA-256 要读完整个文件,恢复流程等不起。代价是可能误判——同名、同大小、同修改时间的两个不同文件会被当成同一个。所以指纹只用于快速排除,最终的完整性由服务端的 commit 校验负责。

谁是权威:服务端清单

本地清单和服务端清单可能不一致,两个方向都会发生:

  • 本地少记:服务端已经落盘并返回 200,客户端在写本地存储前被系统杀掉。下次恢复会重传已经传过的分片,浪费流量但结果正确。
  • 本地多记:客户端收到了 200,但服务端合并任务失败并回滚了部分分片;或者本地存储写成功、请求其实超时未达。下次恢复会跳过服务端没有的分片,结果是一个缺片文件。

两种错误的代价不对称:多传一片只是浪费,少传一片无法修复。所以规则必须是恢复时以服务端清单为准:

  1. 先 GET /upload/sessions/{id} 拿服务端 confirmed。
  2. 本地有、服务端没有的分片,从本地清单里删掉并重传。
  3. 服务端有、本地没记的分片,补记,不再重传。

这条规则还有一个副产品:恢复流程不能只读本地存储就直接开始传,必须先有一次网络往返。离线时无法安全恢复,只能等网络回来。

幂等:重复上传一片为什么无害

断点续传能成立,前提是「同一分片传两次」不会产生副作用。需要三处幂等:

  • 分片写入以 (sessionId, index) 为键,服务端按固定偏移覆盖写。重复 PUT 覆盖同一段字节,结果一致。
  • 不要用「先 DELETE 再 PUT」清理重试。DELETE 会把已确认状态打回,正好制造缺片窗口。
  • commit 也要幂等。同一个会话重复 commit 返回同一个 asset id;否则客户端超时重试会生成两份资源,一份被引用、一份成为垃圾。

分片级校验和是这套机制的安全网:每片带 CRC32 或短哈希,服务端校验不过返回 422。客户端把它当作可重试错误,重传该片;连续失败则说明文件在端上已经损坏,应停止并提示重新选择。

重试:哪些错误值得等

不是所有失败都值得重试。按错误类型分流:

类型例子策略
network连接重置、DNS 失败指数退避重试
timeout请求超时指数退避重试
server5xx指数退避重试
rate-limit429优先遵守 Retry-After
checksum422 校验失败重传该片,连续失败则终止
auth401/403本层不重试,交给令牌刷新流程
quota空间不足、超出配额不重试,提示用户

退避要加抖动,否则一批同时失败的分片会在同一时刻再次涌向服务端。上限既要有次数(maxAttempts),也要有单次等待上限(maxDelayMs);429 的 Retry-After 优先级高于本地计算,因为服务端更清楚自己什么时候能缓过来。

还有一条经验:恢复后不要立刻重试。 进程刚回来时网络栈可能还没就绪,第一次请求的失败率显著偏高。先做一次轻量探测(例如拉会话元数据),成功后再开始传分片。

恢复层实现

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

export interface FileFingerprint { name: string; size: number; lastModified: number }

export interface UploadSession {
  sessionId: string;
  fingerprint: FileFingerprint;
  chunkSize: number;
  totalBytes: number;
  confirmed: number[];
  updatedAt: number;
  expiresAt: number;
}

export type RestoreAction = "resume" | "restart" | "expired" | "mismatch";

export interface RestoreDecision {
  action: RestoreAction;
  session: UploadSession | null;
  confirmedChunks: number;
  missingChunks: number[];
  reason: string;
}

export type UploadErrorKind =
  | "network" | "timeout" | "server" | "rate-limit" | "auth" | "quota" | "checksum";

export interface RetryPolicy { baseDelayMs: number; maxDelayMs: number; maxAttempts: number }
export interface RetryDecision { retry: boolean; delayMs: number; reason: string }

const RETRYABLE = new Set<UploadErrorKind>(["network", "timeout", "server", "rate-limit", "checksum"]);

export function createSession(input: {
  sessionId: string;
  file: FileFingerprint;
  chunkSize: number;
  now: number;
  ttlMs: number;
}): UploadSession {
  if (!Number.isSafeInteger(input.file.size) || input.file.size < 0) {
    throw new RangeError("文件大小必须是非负安全整数");
  }
  if (!Number.isSafeInteger(input.chunkSize) || input.chunkSize <= 0) {
    throw new RangeError("chunkSize 必须是正安全整数");
  }
  return {
    sessionId: input.sessionId,
    fingerprint: { ...input.file },
    chunkSize: input.chunkSize,
    totalBytes: input.file.size,
    confirmed: [],
    updatedAt: input.now,
    expiresAt: input.now + input.ttlMs,
  };
}

export function totalChunks(session: UploadSession): number {
  return Math.ceil(session.totalBytes / session.chunkSize);
}

export function missingChunks(session: UploadSession): number[] {
  const confirmed = new Set(session.confirmed);
  const missing: number[] = [];
  for (let index = 0; index < totalChunks(session); index += 1) {
    if (!confirmed.has(index)) missing.push(index);
  }
  return missing;
}

/** 确认是幂等的:重复 ack 不改变清单,乱序 ack 只按索引记账。 */
export function applyAck(
  session: UploadSession,
  ack: { index: number; now: number },
): { session: UploadSession; added: boolean } {
  const total = totalChunks(session);
  if (!Number.isSafeInteger(ack.index) || ack.index < 0 || ack.index >= total) {
    throw new RangeError(`分片索引 ${ack.index} 超出 0..${total - 1}`);
  }
  if (session.confirmed.includes(ack.index)) return { session, added: false };
  const confirmed = [...session.confirmed, ack.index].sort((a, b) => a - b);
  return { session: { ...session, confirmed, updatedAt: ack.now }, added: true };
}

export function restoreSession(input: {
  snapshot: UploadSession | null;
  file: FileFingerprint;
  now: number;
}): RestoreDecision {
  const { snapshot } = input;
  if (snapshot === null) {
    return { action: "restart", session: null, confirmedChunks: 0, missingChunks: [], reason: "本地没有可恢复的会话" };
  }
  if (snapshot.expiresAt <= input.now) {
    return {
      action: "expired", session: null, confirmedChunks: snapshot.confirmed.length,
      missingChunks: [], reason: "服务端会话已过期,本地清单不再可信",
    };
  }
  const sameFile = snapshot.fingerprint.name === input.file.name
    && snapshot.fingerprint.size === input.file.size
    && snapshot.fingerprint.lastModified === input.file.lastModified;
  if (!sameFile) {
    return {
      action: "mismatch", session: null, confirmedChunks: snapshot.confirmed.length,
      missingChunks: [], reason: "文件已变化,旧分片不能拼到新文件上",
    };
  }
  const missing = missingChunks(snapshot);
  return {
    action: "resume",
    session: snapshot,
    confirmedChunks: snapshot.confirmed.length,
    missingChunks: missing,
    reason: missing.length === 0 ? "分片已全部确认,直接提交" : `从第 ${missing[0]} 片继续`,
  };
}

/** 恢复时以服务端清单为准:本地记了但服务端没有的分片必须重传。 */
export function reconcileWithServer(
  session: UploadSession,
  serverConfirmed: number[],
  now: number,
): { session: UploadSession; dropped: number[]; added: number[] } {
  const total = totalChunks(session);
  const server = new Set(
    serverConfirmed.filter((index) => Number.isSafeInteger(index) && index >= 0 && index < total),
  );
  const local = new Set(session.confirmed);
  const valid = [...server].sort((a, b) => a - b);
  return {
    session: { ...session, confirmed: valid, updatedAt: now },
    dropped: session.confirmed.filter((index) => !server.has(index)),
    added: valid.filter((index) => !local.has(index)),
  };
}

export function decideRetry(input: {
  kind: UploadErrorKind;
  attempt: number;
  policy: RetryPolicy;
  random?: () => number;
  retryAfterMs?: number;
}): RetryDecision {
  const { kind, attempt, policy } = input;
  if (!RETRYABLE.has(kind)) {
    return { retry: false, delayMs: 0, reason: `${kind} 不是暂时性错误,重试不会改变结果` };
  }
  if (attempt >= policy.maxAttempts) {
    return { retry: false, delayMs: 0, reason: `已连续失败 ${attempt} 次,达到上限` };
  }
  if (kind === "rate-limit" && (input.retryAfterMs ?? 0) > 0) {
    return { retry: true, delayMs: input.retryAfterMs as number, reason: "遵守服务端 Retry-After" };
  }
  const exponential = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
  const random = input.random ?? Math.random;
  const jitter = Math.min(1, Math.max(0, random()));
  const delayMs = Math.min(policy.maxDelayMs, Math.round(exponential * (0.5 + 0.5 * jitter)));
  return { retry: true, delayMs, reason: `第 ${attempt} 次失败,${delayMs}ms 后重试` };
}

export function canComplete(session: UploadSession): boolean {
  return missingChunks(session).length === 0;
}

export function snapshotOf(session: UploadSession): string {
  return JSON.stringify(session);
}

export function readSnapshot(raw: string | null): UploadSession | null {
  if (raw === null || raw.trim() === "") return null;
  let parsed: unknown;
  try {
    parsed = JSON.parse(raw);
  } catch {
    return null;
  }
  if (typeof parsed !== "object" || parsed === null) return null;
  const candidate = parsed as Partial<UploadSession>;
  const file = candidate.fingerprint as Partial<FileFingerprint> | undefined;
  if (typeof candidate.sessionId !== "string" || candidate.sessionId === "") return null;
  if (file === undefined || typeof file.name !== "string") return null;
  if (!Number.isSafeInteger(file.size) || !Number.isSafeInteger(file.lastModified)) return null;
  if (!Number.isSafeInteger(candidate.chunkSize) || (candidate.chunkSize ?? 0) <= 0) return null;
  if (!Number.isSafeInteger(candidate.totalBytes) || (candidate.totalBytes ?? -1) < 0) return null;
  if (candidate.totalBytes !== file.size) return null;
  if (!Array.isArray(candidate.confirmed)) return null;
  if (!candidate.confirmed.every((index) => Number.isSafeInteger(index))) return null;
  if (!Number.isSafeInteger(candidate.updatedAt) || !Number.isSafeInteger(candidate.expiresAt)) return null;
  const total = Math.ceil((candidate.totalBytes as number) / (candidate.chunkSize as number));
  const confirmed = [...new Set(candidate.confirmed as number[])]
    .filter((index) => index >= 0 && index < total)
    .sort((a, b) => a - b);
  return {
    sessionId: candidate.sessionId,
    fingerprint: { name: file.name, size: file.size as number, lastModified: file.lastModified as number },
    chunkSize: candidate.chunkSize as number,
    totalBytes: candidate.totalBytes as number,
    confirmed,
    updatedAt: candidate.updatedAt as number,
    expiresAt: candidate.expiresAt as number,
  };
}

逐段解释

快照只放可 JSON 化的字段

UploadSession 里的每个字段都是数字、字符串或数组,snapshotOf 直接 JSON.stringify。这不是偷懒,而是把「跨进程」变成编译期就能检查的约束:任何不可序列化的东西都进不来。readSnapshot 反向做结构校验,坏数据一律返回 null,让上层走 restart,而不是带着半个对象继续跑。

恢复不看当前策略

restoreSession 只比较指纹与过期时间,从不拿当前的 chunkSize 去重算。原因在 4.1 讲过:分片边界是会话属性。哪怕产品把默认分片从 4MB 改成 2MB,旧会话仍然按 4MB 恢复。

对账以服务端为准

reconcileWithServer 返回 dropped 与 added 两个数组,而不是静默合并。调用方需要把 dropped 写进日志:它代表「本地以为传完了、其实没有」的分片数量。这个数字持续大于零,说明 ack 的持久化或服务端的确认语义有问题,是比单次上传失败更值得排查的信号。

确认是幂等的纯函数

applyAck 不原地修改,返回新对象;重复 ack 返回同一个引用并把 added 置为 false。调用方每次拿到新对象都必须落盘,这样「收到确认」与「记住确认」之间没有窗口。乱序 ack 只按索引插入并排序,恢复时不需要关心到达顺序。

退避与 Retry-After

decideRetry 把错误类型、尝试次数、抖动因子拆成参数,随机源也注入,测试才能断言精确的毫秒数。429 分支直接返回服务端给的时间,不再叠加本地抖动——那条响应头本来就是服务端的调度意图。

损坏快照的防御

readSnapshot 会丢弃越界和重复的分片号,也会拒绝 totalBytes 与指纹 size 不一致的快照。本地存储可能被旧版本写坏、被其他账号复用、被手工改过;恢复流程宁可重传,也不能相信一份自相矛盾的清单。

测试与真实输出

$ node --test --test-concurrency=1 test/upload/resume.test.ts
✔ 会话快照可 JSON 往返,损坏或字段不一致的输入返回 null (1.670042ms)
✔ 名称、大小、修改时间任一变化都判定 mismatch (0.1645ms)
✔ 过期会话不再恢复,边界时刻按过期处理 (0.077292ms)
✔ 恢复沿用会话自己的 chunkSize,不被当前策略重算 (0.13ms)
✔ 确认幂等:重复 ack 不改状态,乱序 ack 只按索引记账 (0.291458ms)
✔ 恢复时以服务端清单为准,丢弃本地虚记并补记服务端确认 (0.152583ms)
✔ 可重试错误按指数退避,429 遵守 Retry-After (0.15975ms)
✔ 鉴权与配额错误不重试,超过次数上限停止,缺片不能提交 (0.116584ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 156.06425

八条用例对应恢复层的八个判断点:快照边界、指纹、过期、策略冻结、确认幂等、服务端对账、退避、不可重试错误。它们全部是进程内流程,没有真实端口,因此可以在任何 CI 里稳定重跑。

复盘:本地清单比服务端多

第一版为了省一次网络往返,恢复时直接读本地 confirmed 就继续传,跳过「已经完成」的分片。上线一周后收到一条反馈:一段 3 分钟的视频,前 40 秒正常,之后全是花屏。

排查发现:那次上传中服务端的合并任务失败,回滚了最后两个分片,但客户端在此之前已经收到了 200 并写入了本地清单。恢复时客户端相信本地清单,跳过了这两片,直接 commit。服务端按「缺失分片补零」的旧逻辑生成了文件——大小正确,内容损坏。

修复不是加一个重试,而是改权威来源:恢复必须先拉服务端清单,本地多出的分片一律重传;commit 响应返回最终文件的大小与哈希,客户端比对后才跳成功页。复盘时还发现另一个问题:applyAck 当初是原地修改数组的,落盘失败时内存里已经是「已确认」,重试路径会拿到一个与服务端不一致的状态。改成返回新对象后,「先落盘、再更新内存」的顺序才成为可能。

小结

断点续传的难点不在「续」,在「信谁」。会话要只存可序列化的七类字段,恢复要按指纹与过期时间做四分支决策,已确认清单要以服务端为准,确认写入要幂等且先落盘,重试要按错误类型分流并尊重 Retry-After。

下一节换一个方向:文件已经安全到达服务端之后,端上如何选择图片格式、尺寸与降级链,让同一张图在不同设备上既清晰又不过度消耗流量。

阅读导航:上一节:4.1 上传编排:分片、并发与进度语义 · 下一节:4.3 图片格式选择与端上降级 。

常见错误与边界

  • 只存进度百分比。百分比无法告诉恢复流程哪些分片已经被服务端收下。
  • 恢复时直接相信本地清单。本地可能多记,跳过服务端没有的分片会拼出缺片文件。
  • 用内容哈希做文件指纹。大文件启动恢复时读不完,恢复流程会被指纹计算拖死。
  • 恢复时按当前策略重新分片。旧会话的边界与服务端不一致,合并必然错位。
  • 重试前不区分错误类型。401 与配额不足重试一百次也不会成功。
  • 忽略 429 的 Retry-After。本地退避短于服务端要求,会被继续限流。
  • 先改内存再落盘。落盘失败时内存状态超前,下一次恢复读到的是幻觉。
  • 用 DELETE 清理失败分片。删除会把已确认状态打回,制造缺片窗口。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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