本节不做什么
本节只回答「断了之后怎么办」:会话怎么建、恢复时信谁、重试等多久、跨进程要落盘什么。分片与并发的编排在 4.1,图片格式在 4.3;对象存储的合并实现属于服务端,不在客户端架构的讨论范围内。
上传到 87%,进程没了
用户在地铁里传一段 200MB 的视频,切到相机拍封面,回来时 App 已经被系统回收。这时有两种常见的错误做法:
- 从头传。 用户已经付出的流量和时间翻倍,弱网下可能永远传不完,最后放弃发布。
- 只把百分比存本地。 恢复后从 87% 继续,但服务端实际只收到 60% 的分片,最终文件缺了一段,播放到中途花屏。
断点续传的本质不是「记住进度百分比」,而是「记住服务端已经收下哪些分片」。百分比是给人看的,分片清单才是给程序用的。
会话里到底要存什么
一个可恢复的上传会话只需要七类字段:
| 字段 | 作用 | 不存的后果 |
|---|---|---|
sessionId | 服务端会话标识 | 恢复时找不到临时对象,只能重传 |
fingerprint | 文件名、大小、修改时间 | 无法判断当前选中的是不是同一个文件 |
chunkSize | 该会话的分片边界 | 按新策略重切,边界与服务端不一致 |
totalBytes | 总大小,用于算分片数与进度 | 无法校验清单完整性 |
confirmed | 服务端已确认的分片索引 | 不知道从哪一片继续 |
updatedAt | 最后一次确认时间 | 无法排查「为什么没恢复」 |
expiresAt | 服务端会话的过期时刻 | 会拿一个已被回收的会话去续传 |
同样重要的是不存什么:
File句柄、AbortController、进行中的Promise:不可序列化,进程重启即失效。- 片内已发字节:恢复后要重新对齐传输层,存了也没有意义。
- 当前并发数:它取决于恢复时的网络状况,不应该被旧会话冻结。
判断标准很简单:这个字段能不能 JSON.stringify,以及丢了它用户是否要重做一次高成本操作。两条都满足,才值得落盘。
恢复决策:四种结果
恢复不是「有快照就续传」,而是一个四分支决策:
| 结果 | 触发条件 | 端上动作 |
|---|---|---|
resume | 快照存在、未过期、指纹一致 | 取服务端清单,从缺失分片继续 |
expired | expiresAt <= now | 丢弃快照,新建会话,从头传 |
mismatch | 名称、大小、修改时间任一不同 | 丢弃快照,按新文件重建 |
restart | 没有快照或快照损坏 | 新建会话 |
指纹用「名称 + 大小 + 修改时间」而不是内容哈希,是一个明确的取舍:对 200MB 文件算一次 SHA-256 要读完整个文件,恢复流程等不起。代价是可能误判——同名、同大小、同修改时间的两个不同文件会被当成同一个。所以指纹只用于快速排除,最终的完整性由服务端的 commit 校验负责。
谁是权威:服务端清单
本地清单和服务端清单可能不一致,两个方向都会发生:
- 本地少记:服务端已经落盘并返回 200,客户端在写本地存储前被系统杀掉。下次恢复会重传已经传过的分片,浪费流量但结果正确。
- 本地多记:客户端收到了 200,但服务端合并任务失败并回滚了部分分片;或者本地存储写成功、请求其实超时未达。下次恢复会跳过服务端没有的分片,结果是一个缺片文件。
两种错误的代价不对称:多传一片只是浪费,少传一片无法修复。所以规则必须是恢复时以服务端清单为准:
- 先
GET /upload/sessions/{id}拿服务端confirmed。 - 本地有、服务端没有的分片,从本地清单里删掉并重传。
- 服务端有、本地没记的分片,补记,不再重传。
这条规则还有一个副产品:恢复流程不能只读本地存储就直接开始传,必须先有一次网络往返。离线时无法安全恢复,只能等网络回来。
幂等:重复上传一片为什么无害
断点续传能成立,前提是「同一分片传两次」不会产生副作用。需要三处幂等:
- 分片写入以
(sessionId, index)为键,服务端按固定偏移覆盖写。重复 PUT 覆盖同一段字节,结果一致。 - 不要用「先 DELETE 再 PUT」清理重试。DELETE 会把已确认状态打回,正好制造缺片窗口。
- commit 也要幂等。同一个会话重复 commit 返回同一个 asset id;否则客户端超时重试会生成两份资源,一份被引用、一份成为垃圾。
分片级校验和是这套机制的安全网:每片带 CRC32 或短哈希,服务端校验不过返回 422。客户端把它当作可重试错误,重传该片;连续失败则说明文件在端上已经损坏,应停止并提示重新选择。
重试:哪些错误值得等
不是所有失败都值得重试。按错误类型分流:
| 类型 | 例子 | 策略 |
|---|---|---|
network | 连接重置、DNS 失败 | 指数退避重试 |
timeout | 请求超时 | 指数退避重试 |
server | 5xx | 指数退避重试 |
rate-limit | 429 | 优先遵守 Retry-After |
checksum | 422 校验失败 | 重传该片,连续失败则终止 |
auth | 401/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 清理失败分片。删除会把已确认状态打回,制造缺片窗口。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。