2 GB 视频传到一半断网:分片上传、断点续传与秒传
单请求上传在内存、超时、请求体上限、断点四件事上同时不合格。拆成 5 MB 分片、三并发、抽样哈希秒传,再把合并与清理的坑逐个填上。
后台上传课程视频的功能上线两周,客诉集中在同一件事:传大文件必失败。
2 GB 的视频,进度条走到 30% 到 50% 之间的某个位置就停了,等一分钟弹「上传失败」。重试要从 0 开始,网速一般的话一轮二十分钟。有位老师传了四次都没成功,最后用网盘发给了运营。
我先在自己机器上复现了一遍,失败点不固定,但每次都能复现。打开 Network 面板看,一次请求发了二十分钟,最后返回 504;看 Memory 面板,标签页内存涨到 1.8 GB。这已经不是「偶尔失败」,是设计上的必然。
一、单请求上传为什么必然失败
最初实现是最朴素的那种:<input type="file"> 拿到 File,塞进 FormData,axios.post 一次性发出去。小文件没问题,2 GB 就同时踩了四个坑。
第一是内存。浏览器把整个 File 读进请求体,虽然底层可能走流式,但构造 FormData 和进度统计都会让它驻留一大块内存。标签页涨到 1.8 GB 之后,移动端直接崩,桌面端开始 swap。
第二是网关超时。请求发出去二十分钟没有任何响应字节,链路上的负载均衡、API 网关、Nginx 三层里任何一层的读超时(我们这里是 60 秒)到了就断开,客户端拿到 504。
第三是请求体上限。Nginx 的 client_max_body_size 默认 1 MB,业务网关另外还有一层 100 MB 的限制。即使超时改大,2 GB 也过不去。
第四是没有断点。一次失败,之前传的 1.2 GB 全部作废。
所以思路很明确:把大文件切成小块,每块一次请求;块之间可以并发、可以重试、可以续传。这不是优化,是这类功能的唯一正确实现。
二、切片与并发:把 2 GB 拆成 400 份
分片大小是个权衡。太小请求数爆炸,太大单请求还是容易超时。我们的选择是 5 MB:
| 文件大小 | 分片大小 | 分片数 | 单请求耗时(10 Mbps 上行) |
|---|---|---|---|
| 20 MB | 5 MB | 4 | 4 秒 |
| 200 MB | 5 MB | 40 | 4 秒 |
| 2 GB | 5 MB | 400 | 4 秒 |
| 2 GB | 20 MB | 100 | 16 秒 |
5 MB 在 10 Mbps 上行下大约 4 秒传完,远低于 60 秒超时;400 个请求对服务端来说是可控的。如果网络更差,切到 2 MB 更稳,代价是请求数翻倍。
核心逻辑就三步:切片、并发池、按片重试。
const CHUNK = 5 * 1024 * 1024;
const CONCURRENCY = 3;
async function upload(file, { fileId, onProgress }) {
const chunks = Math.ceil(file.size / CHUNK);
const done = new Array(chunks).fill(0); // 每片已上传字节
let cursor = 0;
const worker = async () => {
while (cursor < chunks) {
const index = cursor++;
const start = index * CHUNK;
const blob = file.slice(start, Math.min(start + CHUNK, file.size));
await retry(() => putChunk(fileId, index, blob), 3);
done[index] = blob.size;
onProgress(done.reduce((a, b) => a + b, 0) / file.size);
}
};
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
return mergeChunks(fileId);
}
并发数不是越大越好。我们试过 8 并发,结果中转层开始返 502,因为每个请求都在写临时文件,磁盘 IO 打满。3 到 4 是这类小服务的常见甜点。
retry 要带退避,不能立刻重试——网络抖动时立刻重试基本等于再失败一次:
const retry = async (fn, times) => {
for (let i = 0; ; i++) {
try { return await fn(); }
catch (e) {
if (i >= times - 1) throw e;
await new Promise(r => setTimeout(r, 500 * 2 ** i));
}
}
};
三、断点续传:先问服务端「你有哪几片」
分片上传天然支持断点续传,前提是服务端能告诉你「已经收到了哪些片」。
流程是三步:先调 init 拿到 fileId 和已上传分片列表,前端跳过这些索引,只传缺的。刷新页面、关掉浏览器、换台电脑,只要 fileId 还在(存在 localStorage 或由文件哈希推导),就能接着传。
const { fileId, uploaded } = await initUpload({ name: file.name, size: file.size, hash });
const todo = [...Array(chunks).keys()].filter(i => !uploaded.includes(i));
这里有两个容易忽略的点。
一是 uploaded 里可能有「写了半个」的分片。服务端在接收时应该先写临时文件再原子 rename,只有 rename 成功的才算完整分片。否则续传时会拿到一个长度不对的分片,最终合并出来的文件校验不过。
二是 init 要幂等。用户手快点了两次上传,会并发发起两个 init,如果服务端每次都生成新 fileId,就会重复上传两遍。用「文件哈希 + 大小」作为幂等键,命中已有记录就返回原来的 fileId 和进度。
四、秒传与哈希:全文件算 hash 太慢
说到哈希,就有个绕不开的问题:算 2 GB 的 MD5 在浏览器里要多久?
实测下来,主线程用 spark-md5 逐块读,2 GB 大概 18 到 25 秒,期间页面完全卡死。放进 Web Worker 里不卡了,但用户仍然要等 20 秒才开始传,体验很差。
我们的做法是「抽样哈希」:读首 2 MB、尾 2 MB,中间按固定步长再取 8 段,拼起来算 MD5,加上文件大小一起作为指纹。2 GB 文件的指纹计算时间从 20 秒降到 300ms 以内。
async function sampleHash(file) {
const SAMPLE = 2 * 1024 * 1024, parts = [];
const offsets = [0, file.size - SAMPLE];
for (let i = 1; i <= 8; i++) offsets.push(Math.floor(file.size * i / 9));
for (const off of offsets.sort((a, b) => a - b)) {
parts.push(await file.slice(off, off + SAMPLE).arrayBuffer());
}
const md5 = new SparkMD5.ArrayBuffer();
parts.forEach(p => md5.append(p));
return md5.end() + '-' + file.size;
}
抽样哈希只用来「猜」是不是同一个文件,不能当作完整性证明。真秒传的时候,服务端要拿已有文件的完整哈希和大小再确认一次;抽样碰撞的概率虽然低,但一旦撞上就是给用户返回了别人的文件,这个后果比多传一次严重得多。
五、服务端的三个接口与合并陷阱
前端再稳,服务端接口设计不对也会崩。最终定下来三个接口:
POST /upload/init:入参 name、size、hash,返回fileId和已上传分片索引。PUT /upload/chunk:入参fileId、index、二进制体,写临时文件后 rename。POST /upload/merge:按索引顺序合并,校验总大小与完整哈希,落到正式目录。
合并这一步的坑最多。第一,必须按 index 排序后再拼,不能依赖文件系统 readdir 的顺序,那个顺序是不保证的。第二,合并前要校验分片数量与总字节数,缺片就明确返回缺哪几片,而不是拼出一个截断的文件。第三,合并完立刻清理临时目录,否则几次失败上传就能塞满磁盘——我们就是因为这个把一台机器的 /data 写满过一次。
Nginx 这一侧也要配合,把单请求体上限放开到分片大小以上:
location /upload/chunk {
client_max_body_size 8m;
client_body_timeout 30s;
proxy_request_buffering off;
proxy_pass http://upload_svc;
}
proxy_request_buffering off 让 Nginx 不把整个请求体缓冲到磁盘再转发,大分片下能省一次磁盘写。
六、踩过的坑
上线后又踩了几个,都不大但很影响观感。
标签页切到后台,Chrome 会把定时器和网络请求降频,上传速度肉眼可见地掉。用 document.visibilitychange 提示用户「请不要切走」,比什么都不说好。
进度条按「已上传字节 / 总字节」算,但并发上传时完成顺序是乱的,进度会跳。用每片的字节数累加、只统计成功片,进度就是单调的。
断网重连不会自动继续。监听 window.addEventListener('online') 重新拉一次 init,把缺的片补上,比让用户手动点「重试」友好得多。
还有一个纯后端的坑:init 用的 fileId 如果直接用自增 ID,别人可以猜。我们改成 UUID 并在 chunk 和 merge 时校验 fileId 归属当前用户,否则就是任意文件覆盖漏洞。
最后是观测。上传这种长流程,出问题很难复现,必须埋点:每次 init、每片成功/失败、每次 merge 结果都打日志,带上 fileId、分片索引、耗时、重试次数。有了这些,客诉时能直接定位到是「第 187 片一直 502」还是「merge 校验失败」,不用猜。
小结
- 大文件必须分片。单请求上传在内存、超时、请求体上限、断点四件事上同时不合格。
- 分片大小按上行带宽和网关超时反推,5 MB 在 10 Mbps 上行下约 4 秒,是稳妥值;并发 3 到 4,别贪多。
- 断点续传靠
init返回已上传分片列表,init必须幂等,服务端分片写入要「临时文件 + 原子 rename」。 - 全文件哈希太慢就用抽样哈希做秒传判断,但服务端必须二次确认,不能拿抽样值当完整性证明。
- 合并要按索引排序、校验片数与总字节、及时清理临时目录,否则会拼出截断文件或者写满磁盘。
- 上传是长流程,必须有埋点,否则客诉时只能靠猜。
正在加载评论…