一句话:把 GB 级文件拆成 2MB 小片上传,合并时用递归流而非并行 pipe,避免 Node 监听器爆炸。
浏览器直传大文件常遇到:
client_max_body_size 限制本博客在 C 端工具页 /tool/upload-slice 与 Nest FileModule 实现完整链路:切片 → 并发上传 → 断点续传 → 服务端合并入库。
前置阅读:Nuxt 3 + NestJS 实现无感刷新 Token(同系列,JWT 鉴权上传接口)
在线体验:分片上传 Demo
flowchart LR
A[选择 File] --> B[Worker 切片 + MD5]
B --> C[checkFile 查已传片]
C --> D[并发 uploadBigFile]
D --> E[mergeFile 流式合并]
E --> F[resourcesService 入库]
| 步骤 | 接口 | 说明 |
|---|---|---|
| 1 | GET /file/uploadBigFile/checkFile?hash= |
查已上传分片 index 或文件是否已存在 |
| 2 | POST /file/uploadBigFile?hash=&index= |
multipart 上传单个分片 |
| 3 | POST /file/uploadBigFile/merge |
校验片数后合并为完整文件 |
三个接口均需 JWT 登录(JwtAuthGuard)。
FileReader.readAsArrayBuffer 逐片读取并累加 SparkMD5,计算量大。放在 Worker 里不阻塞 Nuxt 页面渲染。
关键常量(pages/tool/upload-slice/index.vue):
2097152(2MB)3(最大并发上传数)Worker 逻辑(worker.js):
chunkSize 循环 File.slicechunkList,同时 spark.append 计算 hashpostMessage({ chunkList, hash })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:
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)个 workershift 下一片stopUpload这与无感刷新里的「单飞 refresh + 队列」是同一类模式:限制并行度,保护客户端与服务端。
file.module.ts 用 diskStorage 动态目录:
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 则拒绝,防止缺片合并出损坏文件hash-0、hash-1…forEach + pipe 并行: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 目录| 问题 | 处理 |
|---|---|
| 主线程卡顿 | Worker 算 hash + 切片 |
| 并发过高 | MaxRequest = 3,可按压测调整 |
| 合并 OOM / 监听器溢出 | 递归单流合并,禁止并行 pipe |
| 重复上传 | hash 查库 + 分片 index 跳过 |
| nginx 413 | 单片 2MB,远低于默认限制 |
| 鉴权 | 三接口均 @UseGuards(JwtAuthGuard) |
blog-home-nuxt/pages/tool/upload-slice/、blog-server/src/modules/features/file/