如何实现支持断点续传和秒传的大文件上传服务?
实现支持断点续传(Resumable Upload)和秒传(Instant Upload / Deduplication)的大文件上传服务,核心思想是将一个大文件切片(Chunking),并基于文件内容计算唯一标识(Hash)。
以下是完整的技术方案设计与实现步骤:
核心流程图解
- 计算 Hash:前端读取文件内容,计算唯一的 MD5 值(作为文件的指纹)。
- 秒传检查:前端将 Hash 发送给服务端,询问“这个文件你有了吗?”
- 有 → 服务端直接返回成功(秒传完成)。
- 无 → 进入下一步。
- 部分有 → 服务端返回已上传的切片列表(用于断点续传)。
- 文件切片:前端将文件按固定大小(如 5MB)切割成多个块。
- 并发上传:过滤掉服务端已有的切片,将剩余切片上传。
- 合并请求:所有切片上传完成后,前端发送“合并”指令,服务端将切片还原为文件。
一、 前端实现要点
1. 文件切片 (File Slicing)
利用 HTML5 的 File.prototype.slice 方法。File 对象继承自 Blob,可以直接切割。
javascript
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
function createFileChunks(file, size = CHUNK_SIZE) {
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push({ index: cur, file: file.slice(cur, cur + size) });
cur += size;
}
return chunks;
}
2. 计算文件 Hash (Web Worker + SparkMD5)
对于大文件,在主线程计算 Hash 会导致页面卡顿。必须使用 Web Worker 在后台线程计算,并配合 spark-md5 库进行增量计算。
javascript
// hash.worker.js (伪代码)
self.onmessage = e => {
const { fileChunkList } = e.data;
const spark = new SparkMD5.ArrayBuffer();
let percentage = 0;
let count = 0;
const loadNext = index => {
const reader = new FileReader();
reader.readAsArrayBuffer(fileChunkList[index].file);
reader.onload = e => {
count++;
spark.append(e.target.result); // 增量计算
if (count === fileChunkList.length) {
self.postMessage({ hash: spark.end() }); // 完成
} else {
loadNext(count);
}
};
};
loadNext(0);
};
3. 上传控制逻辑
前端需要维护一个请求队列,控制并发数(如最多同时上传 4 个切片),并处理网络错误重试。
- 检查接口:
POST /verify,参数{ fileHash, fileName }。返回{ shouldUpload, uploadedList }。 - 上传接口:
POST /upload,使用FormData传输切片文件、Hash、切片索引。
二、 后端实现要点 (以 Node.js/Java/Go 为例)
后端主要负责:接收切片、临时存储、合并切片、记录文件元数据。
1. 数据库设计
需要一张表来记录文件的状态,用于秒传判断。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| file_hash | string | 文件 MD5 (唯一索引) |
| file_path | string | 真实存储路径 (OSS地址或本地路径) |
| status | int | 0:上传中, 1:已完成 |
| created_at | datetime | 创建时间 |
2. 接口设计
A. 验证接口 (/verify)
- 根据
fileHash查询数据库。 - 如果记录存在且状态为“完成”,直接返回
shouldUpload: false(秒传成功)。 - 如果记录不存在或状态为“上传中”,检查服务端临时目录(如
/temp/{fileHash}/),读取已存在的所有切片索引,返回uploadedList: [0, 1, 5...]。
B. 切片上传接口 (/upload)
- 接收
chunk,hash,index。 - 将切片存储在临时目录:
/uploads/temp/{hash}/{index}。 - 注意:不需要每次上传都写数据库,直接利用文件系统判断切片是否存在即可。
C. 合并接口 (/merge)
- 接收
fileHash,fileName,chunkSize。 - 找到
/uploads/temp/{hash}/目录。 - 读取目录下所有切片,按索引排序(1, 2, 3...)。
- 流式读写:创建一个可写流指向最终文件路径,依次将切片读入并管道(pipe)写入,防止内存溢出。
- 写入完成后,删除临时目录及切片。
- 在数据库中写入/更新该文件的记录。
3. 合并代码逻辑示例 (Node.js)
javascript
const mergeFileChunk = async (filePath, fileHash, size) => {
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
const chunkPaths = await fse.readdir(chunkDir);
// 根据切片下标排序,否则合并后文件损坏
chunkPaths.sort((a, b) => a.split('-')[1] - b.split('-')[1]);
await Promise.all(
chunkPaths.map((chunkPath, index) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
// 指定位置写入,保证并发合并时的顺序(如果使用并发写入的话)
// 或者简单地串行 append
fse.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
})
)
)
);
// 删除切片目录
fse.rmdirSync(chunkDir);
};
三、 关键问题与优化方案
1. 计算 Hash 太慢怎么办?(抽样 Hash)
对于几 GB 的视频,全量计算 MD5 即使在 Worker 中也需要几十秒。
优化方案:抽样计算 (Sample Hash)。
- 取文件头 2MB。
- 取文件尾 2MB。
- 中间每隔 20MB 取前 2KB。
- 将这些片段拼接后计算 MD5。
- 代价:极小概率的 Hash 碰撞(不同文件被误判为相同),但在非金融级场景通常可接受。
2. 断点续传的清理机制 (Garbage Collection)
如果用户上传了一半就消失了,临时目录会堆积大量切片。
解决方案:
- 设置 Cron Job(定时任务),每天扫描临时目录。
- 如果某个文件夹的修改时间超过 24 小时,视为过期,直接删除。
3. 并发合并时的 IO 压力
如果多个大文件同时请求合并,服务器 IO 会瞬间飙升。
解决方案:
- 使用消息队列(RabbitMQ / Kafka)处理合并任务,削峰填谷。
- 上传完成后,前端只收到“已加入合并队列”的响应,通过 WebSocket 或轮询查询最终状态。
4. 存储选择
- 本地磁盘:适合小规模,需处理分布式部署时的文件共享问题(通常需要挂载 NFS 或使用粘性 Session,但不推荐)。
- 对象存储 (AWS S3 / 阿里云 OSS):推荐方案。
- 利用 OSS 提供的 Multipart Upload API。
- 后端只负责签发“上传凭证”,前端直接把切片上传到 OSS,最后调用 OSS 的合并接口。这样可以节省应用服务器的带宽和 CPU。
5. 安全性
- 文件类型检查:不要相信前端传来的
Content-Type或扩展名。在合并完成后,读取文件头(Magic Number)判断真实文件类型。 - 恶意覆盖:不同用户上传相同 Hash 的文件(秒传),A 用户删除了文件,不应影响 B 用户。数据库设计应为
UserFile关联表,物理文件只有一份,引用计数为 0 时才物理删除。
总结
实现一套健壮的大文件上传系统,核心在于:
- 前端:Web Worker 计算 Hash + 切片 + 并发控制。
- 后端:检查 Hash (秒传) + 接收切片 + 流式合并。
- 架构:利用对象存储的分片上传能力是生产环境的最优解。