一句话:把 GB 级文件拆成 2MB 小片上传,合并时用递归流而非并行 pipe,避免 Node 监听器爆炸。
写在前面
浏览器直传大文件常遇到:
- 单次请求超时或 nginx
client_max_body_size限制 - 网络抖动导致整文件重传
- 主线程算 MD5 + 切片会卡 UI
本博客在 C 端工具页 /tool/upload-slice 与 Nest FileModule 实现完整链路:切片 → 并发上传 → 断点续传 → 服务端合并入库。
前置阅读:Nuxt 3 + NestJS 实现无感刷新 Token(同系列,JWT 鉴权上传接口)
在线体验:分片上传 Demo
整体流程
| 步骤 | 接口 | 说明 |
|---|---|---|
| 1 | GET /file/uploadBigFile/checkFile?hash= |
查已上传分片 index 或文件是否已存在 |
| 2 | POST /file/uploadBigFile?hash=&index= |
multipart 上传单个分片 |
| 3 | POST /file/uploadBigFile/merge |
校验片数后合并为完整文件 |
三个接口均需 JWT 登录(JwtAuthGuard)。
前端:Worker 切片与 hash
为什么用 Web Worker?
FileReader.readAsArrayBuffer 逐片读取并累加 SparkMD5,计算量大。放在 Worker 里不阻塞 Nuxt 页面渲染。
关键常量(pages/tool/upload-slice/index.vue):
- ChunkSize:
2097152(2MB) - MaxRequest:
3(最大并发上传数)
Worker 逻辑(worker.js):
- 按
chunkSize循环File.slice - 每片 push 到
chunkList,同时spark.append计算 hash - 全部读完后
postMessage({ chunkList, hash })
javascript
const createChunksByWorker = (file) => {
return new Promise((resolve) => {
const myWorker = new Worker(new URL('./worker.js', import.meta.url).href);
myWorker.postMessage({ file, chunkSize: ChunkSize });
myWorker.onmessage = (e) => {
resolve(e.data);
myWorker.terminate();
};
});
};
前端:断点续传
上传前先调 checkFile:
javascript
const { chunkList, hash } = await createChunksByWorker(file);
const { isExist, chunks = [] } = await checkFile({ hash });
if (isExist) {
messageDanger('文件已存在');
return;
}
// 跳过已成功的分片 index
const filterChunkList = chunkList.filter(v => !chunks.includes(v.index));
await uploadChunks(filterChunkList);
await mergeFile({ chunks: chunkTotal.value, fileName, hash });
服务端把分片落在 tempFolder/{hash}/ 下,文件名 {hash}-{index},因此重启浏览器后仍可续传。
前端:并发队列 limitRequests2
不能 Promise.all(chunks.map(upload))——几百个分片会把连接打满。采用 固定并发池 + FIFO 队列:
- 初始启动
maxRequest(3)个 worker - 每完成一片,从队列
shift下一片 - 失败重试(最多 10 次),支持暂停
stopUpload
这与无感刷新里的「单飞 refresh + 队列」是同一类模式:限制并行度,保护客户端与服务端。
后端:Multer 分片落盘
file.module.ts 用 diskStorage 动态目录:
typescript
destination: (req, file, callback) => {
const { hash } = req.query;
const dir = `${Config.fileConfig.filePath}tempFolder/${hash}`;
fs.mkdir(dir, { recursive: true }, () => callback(null, dir));
},
filename: (req, file, cb) => {
const { hash, index } = req.query;
cb(null, `${hash}-${index}`);
},
每个分片独立文件,合并前不做内存聚合。
后端:流式递归合并(核心坑点)
FileService.mergeFile 要点:
- 片数校验:
chunks !== files.length则拒绝,防止缺片合并出损坏文件 - 按 index 排序:
hash-0、hash-1… - 递归 mergeChunk,而非
forEach + pipe并行:
typescript
const mergeChunk = (index: number) => {
if (index >= sortedFiles.length) {
writeStream.end();
return;
}
const readStream = fs.createReadStream(filePath);
readStream.pipe(writeStream, { end: false });
readStream.on('end', () => {
fs.unlinkSync(filePath); // 合并完即删分片
mergeChunk(index + 1); // 递归下一片
});
};
mergeChunk(0);
踩坑:若对所有分片同时 pipe 到同一 WriteStream,每个 readStream 都会注册 end 监听器。分片上千时 Node 报 MaxListenersExceededWarning 甚至异常。一次只合并一片即可。
finish事件:写入YYYY-MM/{hash}-{fileName},调用resourcesService.uploadFile入库,再deleteFolderRecursive清理 temp 目录- 磁盘保护:temp 总大小超 2GB 拒绝合并
踩坑与注意
| 问题 | 处理 |
|---|---|
| 主线程卡顿 | Worker 算 hash + 切片 |
| 并发过高 | MaxRequest = 3,可按压测调整 |
| 合并 OOM / 监听器溢出 | 递归单流合并,禁止并行 pipe |
| 重复上传 | hash 查库 + 分片 index 跳过 |
| nginx 413 | 单片 2MB,远低于默认限制 |
| 鉴权 | 三接口均 @UseGuards(JwtAuthGuard) |
小结
- 切片上传 = 前端 Worker + 并发池 + 后端分片目录
- 断点续传 = hash 作目录名 + checkFile 返回已传 index
- 合并 = 排序 + 递归 ReadStream → WriteStream,这是本篇最关键的实现细节
- 代码路径:
blog-home-nuxt/pages/tool/upload-slice/、blog-server/src/modules/features/file/

全部评论(0)