为什么需要分片上传? 对于后端来说,如果文件上传的时候采用一整个上传的方式,那么后端需要在内存中留出很大一部分空间来进行一个中转。如果文件过大,那么可能会造成内存空间的拥挤,频繁的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 方法,可以将一个大文件按指定大小切成多个小块:
function createChunks (file, chunkSize = 5 * 1024 * 1024 ) { const chunks = []; let cur = 0 ; let index = 0 ; while (cur < file.size ) { chunks.push ({ file : file.slice (cur, cur + chunkSize), index : index++, }); cur += chunkSize; } return chunks; }
计算文件Hash(唯一标识) 使用 spark-md5 库对文件内容进行MD5计算。为了避免阻塞主线程,通常放在 Web Worker 中执行:
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 < chunks.length ) { loadNext (); } else { self.postMessage ({ type : 'done' , hash : spark.end () }); } }; reader.readAsArrayBuffer (chunks[count].file ); } loadNext (); };
主线程调用:
function calculateHash (chunks ) { return new Promise ((resolve ) => { 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 将每个分片连同元信息一起发送:
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 比较合理。
请求合并 所有分片上传完成后,通知后端进行合并:
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为例) 接收分片 @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("分片上传成功" ); }
合并分片 @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
平衡了请求数和传输效率
—
一种动态策略是根据文件大小自适应:
function getChunkSize (fileSize ) { if (fileSize < 100 * 1024 * 1024 ) { return 2 * 1024 * 1024 ; } else if (fileSize < 1024 * 1024 * 1024 ) { return 5 * 1024 * 1024 ; } else { return 10 * 1024 * 1024 ; } }
进阶优化 确保文件的完整性 问题 :网络传输过程中,分片数据可能因为网络抖动、丢包等原因发生损坏。
方案 :前后端约定使用相同的加密算法,对分片的文件计算一个MD5值,前端传输增加MD5值,后端拿到文件之后也进行相同的计算,然后对比是否一致。
flowchart LR
A[前端:chunk数据] --> B[计算MD5]
B --> C[和chunk一起发送]
C --> D[后端:接收chunk]
D --> E[计算MD5]
E --> F{MD5对比}
F -->|一致| G[保存分片]
F -->|不一致| H[丢弃,通知前端重传]
前端计算单个分片MD5:
async function calculateChunkMd5 (chunkBlob ) { const buffer = await chunkBlob.arrayBuffer (); const spark = new SparkMD5 .ArrayBuffer (); spark.append (buffer); return spark.end (); }
断点续传 场景 :用户上传一个2GB的文件,传到60%时网络断了。如果没有断点续传,下次需要从头开始,体验非常差。
原理 :上传前先向后端询问”这个文件已经上传了哪些分片”,跳过已上传的部分,只传剩余分片。
前端实现 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 (); }
后端实现 @GetMapping("/upload/check") public Result checkFile (@RequestParam("hash") String fileHash) { Map<String, Object> 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<Integer> 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 实现手动暂停:
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
@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