一句话:把 GB 级文件拆成 2MB 小片上传,合并时用递归流而非并行 pipe,避免 Node 监听器爆炸。

写在前面

浏览器直传大文件常遇到:

  • 单次请求超时或 nginx client_max_body_size 限制
  • 网络抖动导致整文件重传
  • 主线程算 MD5 + 切片会卡 UI

本博客在 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)。


前端:Worker 切片与 hash

为什么用 Web Worker?

FileReader.readAsArrayBuffer 逐片读取并累加 SparkMD5,计算量大。放在 Worker 里不阻塞 Nuxt 页面渲染。

关键常量(pages/tool/upload-slice/index.vue):

  • ChunkSize:2097152(2MB)
  • MaxRequest:3(最大并发上传数)

Worker 逻辑(worker.js):

  1. 按 chunkSize 循环 File.slice
  2. 每片 push 到 chunkList,同时 spark.append 计算 hash
  3. 全部读完后 postMessage({ 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)个 worker
  • 每完成一片,从队列 shift 下一片
  • 失败重试(最多 10 次),支持暂停 stopUpload

这与无感刷新里的「单飞 refresh + 队列」是同一类模式:限制并行度,保护客户端与服务端。


后端:Multer 分片落盘

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 要点:

  1. 片数校验:chunks !== files.length 则拒绝,防止缺片合并出损坏文件
  2. 按 index 排序:hash-0、hash-1…
  3. 递归 mergeChunk,而非 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 甚至异常。一次只合并一片即可。

  1. finish 事件:写入 YYYY-MM/{hash}-{fileName},调用 resourcesService.uploadFile 入库,再 deleteFolderRecursive 清理 temp 目录
  2. 磁盘保护: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/

延伸阅读