对于后端来说,如果文件上传的时候采用一整个上传的方式,那么后端需要在内存中留出很大一部分空间来进行一个中转。如果文件过大,那么可能会造成内存空间的拥挤,频繁的IO造成服务器性能的下降,在web这个多并发的情况下,性能的下降会导致响应时间的增加,严重甚至会导致服务的崩溃。所以我们要对大文件的上传进行专门的优化。
同时,过大的文件传输也会长时间的占用网络信道,会导致网络的拥塞,其余的服务响应时间也会增加。
总结来说,传统整文件上传面临三个核心问题:
内存压力 — 服务端需要一次性将整个文件加载到内存中,上百MB甚至GB级的文件很容易导致OOM
网络不稳定 — 传输过程中一旦网络中断,整个文件需要重新上传,用户体验极差
上传时间过长 — 单连接串行传输,无法充分利用带宽,用户等待时间长
整体方案:文件分片 核心思路是:将大文件切分成多个小片段,逐片上传,最后在服务端合并还原 。
我们和前端约定,在上传文件的时候,将文件切片处理,传输的时候附带有文件的唯一标识,以及文件切片数,切片标识。
在后端时,我们创建临时文件夹来存储临时切片,在前端传入的标识和切片总数达标的时候,开始在后端合并文件成整个文件,并删除临时文件夹。
整体流程
sequenceDiagram
participant 前端
participant 后端
前端->>前端: 1. 选择文件
前端->>前端: 2. 计算文件MD5(唯一标识)
前端->>前端: 3. 将文件切片(Blob.slice)
前端->>后端: 4. 检查文件是否已存在(秒传)
后端-->>前端: 返回:已存在 / 未存在 / 部分存在
前端->>前端: 5. 根据返回跳过已上传的分片
loop 逐片上传
前端->>后端: 上传分片(携带hash、index、total)
后端->>后端: 接收分片,校验MD5,存入临时目录
后端-->>前端: 分片上传结果
end
前端->>后端: 6. 请求合并文件
后端->>后端: 7. 检查分片完整性,按顺序合并
后端->>后端: 8. 删除临时分片
后端-->>前端: 返回:上传成功,文件URL
前端实现 文件切片 利用 Blob.prototype.slice 方法,可以将一个大文件按指定大小切成多个小块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 function createChunks (file, chunkSize = 5 * 1024 * 1024 ) { ; const chunks = []; let cur = 0 ; let index = 0 ; while (cur spark-md5 库对文件内容进行MD5 计算。为了避免阻塞主线程,通常放在 **Web Worker ** 中执行: `` `javascript // worker.js — 在Web Worker中计算文件MD5 self.importScripts('spark-md5.min.js'); self.onmessage = function (e) { const { chunks } = e.data; const spark = new self.SparkMD5.ArrayBuffer(); let count = 0; function loadNext() { const reader = new FileReader(); reader.onload = function (event) { spark.append(event.target.result); count++; // 上报进度 self.postMessage({ type: 'progress', progress: Math.round((count / chunks.length) * 100) }); if (count { const worker = new Worker('/worker.js'); worker.postMessage({ chunks }); worker.onmessage = (e) => { if (e.data.type === 'done') { resolve(e.data.hash); worker.terminate(); } }; }); }
为什么用 Web Worker? 对于GB级文件,MD5计算可能需要数秒甚至数十秒。在主线程中执行会导致页面卡死,用户无法操作。放在 Worker 中可以保持页面响应。
上传分片 使用 FormData 将每个分片连同元信息一起发送:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 async function uploadChunks (chunks, fileHash, fileName ) { ; const requests = chunks.map ((chunk, index ) => { ; const formData = new FormData (); formData.append ('chunk' , chunk.file ); formData.append ('hash' , fileHash); formData.append ('chunkHash' , `${fileHash}-${index}` ); formData.append ('index' , index); formData.append ('total' , chunks.length ); formData.append ('fileName' , fileName); return formData; } ;); await concurrentUpload (requests, 3 ); } ; async function concurrentUpload (requests, max = 3 ) { ; const pool = []; const results = []; for (const formData of requests) { ; const task = fetch ('/api/upload/chunk' , { ; method : 'POST' , body : formData, } ;).then ((res ) => { ; pool.splice (pool.indexOf (task), 1 ); return res.json (); } ;); pool.push (task); results.push (task); if (pool.length >= max) { ; await Promise .race (pool); } ; } ; return Promise .all (results); } ;
为什么要控制并发数? 浏览器对同一域名的并发连接数有限制(Chrome一般是6个)。如果一次性发出几百个请求,反而会造成请求排队,甚至触发浏览器或服务器的限流策略。通常并发数设为 3~6 比较合理。
请求合并 所有分片上传完成后,通知后端进行合并:
1 2 3 4 5 6 7 8 9 10 11 12 async function mergeRequest (fileHash, fileName, chunkTotal ) { ; const response = await fetch ('/api/upload/merge' , { ; method : 'POST' , headers : { ; 'Content-Type' : 'application/json' } ;, body : JSON .stringify ({ ; hash : fileHash, fileName : fileName, total : chunkTotal, } ;), } ;); return response.json (); } ;
后端实现(以Java/Spring Boot为例) 接收分片 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @PostMapping("/upload/chunk") public Result uploadChunk ( @RequestParam("chunk") MultipartFile chunk, @RequestParam("hash") String fileHash, @RequestParam("chunkHash") String chunkHash, @RequestParam("index") Integer index, @RequestParam("total") Integer total, @RequestParam("fileName") String fileName) { ; String tempDir = uploadPath + File.separator + fileHash; File dir = new File (tempDir); if (!dir.exists()) { ; dir.mkdirs(); } ; File chunkFile = new File (tempDir + File.separator + index); chunk.transferTo(chunkFile); String actualMd5 = DigestUtils.md5DigestAsHex(new FileInputStream (chunkFile)); if (!actualMd5.equals(chunkHash)) { ; chunkFile.delete(); return Result.fail("分片校验失败,请重新上传" ); } ; return Result.success("分片上传成功" ); } ;
合并分片 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @PostMapping("/upload/merge") public Result mergeChunks (@RequestBody MergeRequest request) { ; String tempDir = uploadPath + File.separator + request.getHash(); File dir = new File (tempDir); File[] chunks = dir.listFiles(); if (chunks == null || chunks.length != request.getTotal()) { ; return Result.fail("分片数量不完整" ); } ; Arrays.sort(chunks, Comparator.comparingInt(f -> Integer.parseInt(f.getName()))); String suffix = request.getFileName().substring(request.getFileName().lastIndexOf("." )); File targetFile = new File (uploadPath + File.separator + request.getHash() + suffix); try (BufferedOutputStream bos = new BufferedOutputStream (new FileOutputStream (targetFile))) { ; for (File chunk : chunks) { ; byte [] bytes = Files.readAllBytes(chunk.toPath()); bos.write(bytes); } ; bos.flush(); } ; for (File chunk : chunks) { ; chunk.delete(); } ; dir.delete(); return Result.success("合并成功" , targetFile.getName()); } ;
确定切片大小 切片大小的选择需要 平衡多个因素 :
切片大小
优点
缺点
过小(<1MB)
单片失败重传代价小
HTTP请求数量多,频繁IO,服务器压力大
过大(>20MB)
请求数少,效率高
分片优势不明显,单片传输失败代价大
推荐 2MB~10MB
平衡了请求数和传输效率
—
一种动态策略是根据文件大小自适应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 function getChunkSize (fileSize ) { ; if (fileSize 进阶优化 ## 确保文件的完整性 **问题**:网络传输过程中,分片数据可能因为网络抖动、丢包等原因发生损坏。 **方案**:前后端约定使用相同的加密算法,对分片的文件计算一个MD5 值,前端传输增加MD5 值,后端拿到文件之后也进行相同的计算,然后对比是否一致。 {% mermaid %} flowchart LR A[前端:chunk数据] --> B[计算MD5 ] B --> C[和chunk一起发送] C --> D[后端:接收chunk] D --> E[计算MD5 ] E --> F{ ;MD5 对比} ; F -->|一致| G[保存分片] F -->|不一致| H[丢弃,通知前端重传] {% endmermaid %} 前端计算单个分片MD5 : `` `javascript async function calculateChunkMd5(chunkBlob) { const buffer = await chunkBlob.arrayBuffer(); const spark = new SparkMD5.ArrayBuffer(); spark.append(buffer); return spark.end(); }
断点续传 场景 :用户上传一个2GB的文件,传到60%时网络断了。如果没有断点续传,下次需要从头开始,体验非常差。
原理 :上传前先向后端询问”这个文件已经上传了哪些分片”,跳过已上传的部分,只传剩余分片。
前端实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 async function uploadWithResume (file ) { ; const chunks = createChunks (file); const fileHash = await calculateHash (chunks); const { ; uploadedChunks, fileExists } ; = await checkFileStatus (fileHash); if (fileExists) { ; console .log ('文件已存在,秒传成功!' ); return ; } ; const chunksToUpload = chunks.filter ( (_, index ) => !uploadedChunks.includes (index) ); console .log (`总共${chunks.length}片,已上传${uploadedChunks.length}片,还需上传${chunksToUpload.length}片` ); await uploadChunks (chunksToUpload, fileHash, file.name ); await mergeRequest (fileHash, file.name , chunks.length ); } ; async function checkFileStatus (fileHash ) { ; const res = await fetch (`/api/upload/check?hash=${fileHash}` ); return res.json (); } ;
后端实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @GetMapping("/upload/check") public Result checkFile (@RequestParam("hash") String fileHash) { ; Map result = new HashMap <>(); File targetFile = findFileByHash(fileHash); if (targetFile != null && targetFile.exists()) { ; result.put("fileExists" , true ); result.put("uploadedChunks" , Collections.emptyList()); return Result.success(result); } ; File tempDir = new File (uploadPath + File.separator + fileHash); List uploadedChunks = new ArrayList <>(); if (tempDir.exists()) { ; for (File chunk : tempDir.listFiles()) { ; uploadedChunks.add(Integer.parseInt(chunk.getName())); } ; } ; result.put("fileExists" , false ); result.put("uploadedChunks" , uploadedChunks); return Result.success(result); } ;
暂停与恢复 前端可以通过 AbortController 实现手动暂停:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 let controller = null ;function pauseUpload ( ) { ; if (controller) { ; controller.abort (); } ; } ; function resumeUpload ( ) { ; uploadWithResume (currentFile); } ; async function uploadChunkWithAbort (formData ) { ; controller = new AbortController (); return fetch ('/api/upload/chunk' , { ; method : 'POST' , body : formData, signal : controller.signal , } ;); } ;
秒传 场景 :用户上传了一个视频文件,另一个用户再次上传同一个文件时,服务端已经有了,就没有必要真正传输。
原理 :在上传之前,先计算整个文件的MD5值,发送给后端检查是否已存在。如果存在就直接返回成功,一个字节都不用传 。
flowchart TD
A[用户选择文件] --> B[计算文件整体MD5]
B --> C[发送MD5给后端查询]
C --> D{文件是否已存在?}
D -->|已存在| E[直接返回URL(秒传完成)]
D -->|不存在| F[走正常分片上传流程]
E --> G[展示结果]
F --> G
1 2 3 4 5 6 7 8 9 10 11 @GetMapping("/upload/check") public Result checkFile (@RequestParam("hash") String fileHash) { ; FileRecord record = fileService.findByHash(fileHash); if (record != null ) { ; return Result.success("秒传成功" , record.getUrl()); } ; return Result.success("文件不存在,请上传" ); } ;
注意 :MD5存在极低概率的碰撞(不同文件产生相同的MD5值)。对于安全性要求高的场景,可以:
使用 SHA-256 替代 MD5,碰撞概率更低
在MD5相同的基础上,再比较文件大小
双Hash校验:同时比较 MD5 + CRC32