浏览器端大文件上传的工程实践笔记:分片、断点续传与并发控制

举报
yd_227879331 发表于 2026/11/09 00:24:10 2026/11/09
【摘要】 从分片切分、文件指纹、并发上限到断点续传与服务端合并,梳理大文件上传链路里的关键取舍,并给出一份可复用的验收清单。

背景

几百 KB 的图片用一个 FormData 直接 POST 就够了,但遇到几百 MB 的设计稿、录像或数据集,一次性上传几乎必然踩坑:请求超时、连接中断后从头再来、页面卡顿、失败原因说不清。把大文件拆成小片分别上传,本质上是把一次不可控的长请求,换成一批可重试、可追踪、可恢复的短请求。这篇把这条链路上几条容易忽略的规则整理一遍。

1. 分片大小怎么定

固定大小切片是最简单也最稳妥的策略,常见取 1 MB 到 5 MB。需要注意 Blob.prototype.slice 返回的是对原文件的一段视图,并不会把内容复制进内存,所以切成几百片也只是多了几百个引用,而不需要把整个文件读进来。切片时每片必须带上 index、total 和一个全局的 uploadId,服务端才能判断它属于哪次上传。

const CHUNK = 4 * 1024 * 1024;
const total = Math.ceil(file.size / CHUNK);
for (let i = 0; i < total; i++) {
  const blob = file.slice(i * CHUNK, Math.min(file.size, (i + 1) * CHUNK));
  // 每片携带 uploadId / index / total
}

分片太小会让请求数量暴涨、服务端元数据变多,握手与鉴权开销被放大;分片太大则单片重试的代价变高,弱网下更容易反复失败。移动网络或跨境链路可以取偏小的值。最后一片自然小于 CHUNK,不要为了对齐补空字节,否则合并后的文件会被多出来的零撑大。

2. 用文件指纹而不是文件名做身份

同名文件不代表内容相同,「文件名 + 大小」也不够:两个内容不同的文件完全可能大小一致。稳妥做法是每次上传由服务端分配 uploadId 作为本次任务的主键,再用内容哈希作为跨次去重(秒传)的依据。全文件 SHA-256 需要顺序读完整个文件,放在主线程算会明显卡住交互,可以移进 Web Worker 增量计算;若对秒传精度要求没那么极端,也可用「采样哈希 + 大小 + 最后修改时间」组成候选键,先快速筛选,再在服务端合并前做一次完整校验,避免误判。

3. 断点续传:先问服务端再传

续传的关键不是「本地记住传到第几片」,而是恢复时向服务端查询已确认的分片列表。客户端本地进度只能当缓存,不能当事实:请求可能已经到达服务端但响应在回程丢失,也可能用户清了本地存储换了浏览器。以服务端状态为准,两端的记录才不会分叉。

GET  /upload/status?uploadId=U123      -> { uploaded: [0,1,2,5,6], chunkSize: 4194304 }
POST /upload/chunk   { uploadId, index, blob, chunkMd5 }
POST /upload/complete{ uploadId, total, fileSha256 }

服务端写分片时用 (uploadId, index) 作为唯一键,重复提交同一片直接返回首次结果,天然幂等;这样超时重试既不会产生重复数据,也不需要客户端先做一次删除。complete 之前再做一次 status 检查,比在客户端维护复杂的状态机简单得多。

4. 并发不是越多越好

浏览器对同一域名的并发连接数有上限,一次性把几百个 fetch 全发出去,只会让请求在队列里排队、超时变多、内存里堆满待发送的引用。用一个固定大小的工作池(3 到 6 并发)通常就能吃满可用带宽。

async function runPool(items, limit, worker) {
  let cursor = 0;
  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (cursor < items.length) {
      const i = cursor++;
      await worker(items[i], i);
    }
  });
  await Promise.all(runners);
}

池子里每完成一片才取下一片,天然形成背压,失败片重新入队时要有次数上限,避免某一片持续失败把整个任务卡死。若同一页面可能同时上传多个文件,还应在全局层面再限一次总并发,否则两个文件各自开 6 个连接,效果等同于不限流。

5. 错误要分类,重试才不会帮倒忙

网络断开、408/429/5xx 属于暂时性失败,适合重试,并且要按指数退避加少量随机抖动,避免恢复瞬间大家一起重发把服务端打满。而 400/401/403、分片校验失败、uploadId 已过期属于确定性失败,重试多少次结果都一样,应当立刻停下并把原因明确交给用户。重试时必须复用同一个 uploadId 和同一个 index,重新生成任务只会留下更多孤儿分片。

6. 别把整个文件读进内存

一个常见误区是用 FileReader.readAsArrayBuffer(file) 一次读全文件再手动切分,几百 MB 的文件会直接占掉同等量级的堆内存,移动端还可能被系统回收页面。正确做法是始终用 file.slice,把 Blob 直接 append 进 FormData,由浏览器流式发送;需要摘要时对单片做增量计算并放进 Worker。上传队列里只保存「index + 状态」这类轻量元数据即可,分片 Blob 在请求完成后就应释放,不必长期持有全部引用。

7. 服务端合并与清理

分片可能乱序到达,因此 complete 接口必须先核对 0 到 total-1 每一片都存在且长度符合预期(只有最后一片允许小于 CHUNK),再按序拼接。合并前后各校验一次整体哈希,可以发现中间某片被替换或写坏的情况。临时分片要有 TTL 清理任务,否则用户中途关掉页面留下的碎片会一直占存储;合并成功后把该 uploadId 下的临时对象一并清理,并与最终对象的落库放在同一个事务语义里,避免出现「最终文件在、碎片也在」或「碎片删了、文件没生成」的中间态。

8. 进度与交互

进度条应该按「服务端已确认的字节数」计算,而不是「已发出的请求数」;后者会让进度条先跑满再卡住,用户无法判断到底传完没有。暂停的实现是停止从队列取新任务,已发出的请求让它自然结束;恢复时重新查一次 status 再补齐缺口。页面刷新后靠 uploadId 加文件指纹找回未完成任务,比依赖 localStorage 里的一份进度快照可靠。

9. 一份最小验收清单

上传中途断网再恢复,不会重复上传已确认分片;两个标签页同时上传同一份文件,秒传只产生一份最终对象;故意丢掉某一片的响应,客户端重试后服务端不产生重复分片;并发从 1 调到 6,总耗时下降且内存占用不随文件大小线性增长;上传一个恰好等于分片整数倍的文件,最后一片处理正确;中途取消后临时分片能被 TTL 回收;合并校验失败时能明确报错并保留可重试状态;上传大文件期间页面滚动、输入等交互不出现明显卡顿。

小结

分片上传的难点并不在「怎么切」,而在状态归属:哪些事实以服务端为准、哪些操作必须幂等、失败时该重试还是该停。把 uploadId、分片唯一键、已确认列表这三者定义清楚,剩下的并发控制、退避策略和进度展示都变成常规工程问题,后续换端侧框架、换对象存储或换传输协议,都不必推翻重来。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。