大文件上传
方案
普遍性方案是文件分片,把大文件打散成小片段,从而降低上传失败的风险。
细节
底层协议标准如何制定,协议标准决定了前后端如何交互,决定了前后端代码如何开发。
除了协议之外,还涉及前端如何控制并发、如何高效分片,涉及后端如何存储分片、如何高效合并分片,以及如何保证分片唯一性等。
市面上没有统一解决方案,虽然有公有云上的 OSS,但产品可能会部署到私有云,所以还是要学会自行实现。
方案(客户端分片与优化)
客户端分片,计算分片哈希和完整哈希,再使用哈希和服务器换取当前文件信息。
计算哈希是 CPU 密集型操作,会导致长时间 UI 阻塞。
虽然可以用 Web Worker 来加速,但经过我的测试,即便是使用了多线程,超大文件(10 GB 以上)的阻塞仍然超过 30 秒,这是不能接受的。
因此,我对上传流程做出优化:我假定大部分文件都是一个新文件,于是在流程上我允许用户在获得完整 hash 之前直接上传分片,这样一来几乎可以零延迟开始上传,等到整体 hash 计算出来之后再向服务器补充 hash 数据。
通信协议设计
四个通信协议:
创建文件协议:前端用 HEAD 请求,换取上传唯一 Token,后续请求必须携带该 Token。
哈希校验:前端把某个分片 hash 或整个文件 hash 发送给服务器,得到分片和文件状态。
分片上传协议:前端将分片的二进制数据发送到服务器存储。
分片合并协议:前端提示服务器可以完成分片合并。
存储
因为涉及 BFF 层,所以需要编写服务端代码。
最大的挑战是如何保证每个分片的唯一性。这种唯一性既包含了存储的唯一性,也包含传输的唯一性。存储的唯一性保证分片不会重复保存,避免数据冗余;传输的唯一性保证分片不会重复上传,避免通信冗余。
要保证分片不会重复保存,就必须让分片和文件解耦,没有从属关系。文件独立记录,按照顺序依次指向不同分片。
要保证分片不重复上传,就必须让分片永不删除。如果合并文件之后删除分片,就会导致下次重复上传时找不到对应分片,必须重复上传。
最后是合并分片逻辑。如果真要把分片合并成一个大文件,大文件的数据实际上是冗余的,整个过程也极其耗时。所以我做了这样的处理:当服务器收到合并请求时,服务器只需要做一些简单校验(文件大小、分片数量)就可以了,只需生成 URL 即可。当用户下载文件时,使用文件流依次读取分片数据,用流管道直接响应给客户端即可。
合并和文件访问效率都很高,服务器也不会有冗余存储。
流程
先告知服务器要传文件了,需要服务器返回唯一标识。
文件切片
const chunkSize = 5 * 1024 * 1024; // 5MB
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push(file.slice(cur, cur + chunkSize));
cur += chunkSize;
}
生成指纹。为了标识文件的唯一性(用于秒传和断点续传),需要计算文件的 MD5 或 SHA-256。
秒传校验。在正式上传前,先发一个请求给服务端,带上文件哈希值。
场景 A: 服务端已存在该文件 -> 直接返回“上传成功”(秒传)。
场景 B: 服务端已存在部分分片 -> 返回已收到的分片索引列表(断点续传)。
场景 C: 服务端不存在该文件 -> 开始完整上传。
并发上传。使用
FormData包装每个分片,并通过XMLHttpRequest或Fetch发送。
并发控制: 不要同时发起成百上千个请求。建议维护一个发送队列,将并发数控制在 3-6 个,以防占用过多浏览器资源导致页面卡顿。
重试机制: 单个分片上传失败时,应支持自动重试(如重试 3 次)。
合并请求。当所有分片上传完成后,前端发送一个“合并”指令给服务端。服务端根据分片索引将文件块合并成原始文件,并校验合并后的哈希值是否一致。