SpringBoot上传GB级大文件卡死?“分块切割术”让速度飙升10倍!
一、场景重现:100MB 成了“生死线”?
在做项目的过程中,你一定遇到过这种窘境: 用户传个 1GB 的安装包,Nginx 直接 504 Gateway Timeout,或者 Tomcat 的临时目录瞬间爆满,甚至直接甩你一个 java.lang.OutOfMemoryError: Java heap space。
传统的 MultipartFile 一次性接收模式,在面对 GB 级大文件时,本质上是在跟服务器内存和网络稳定性“赌命”。一旦网络抖动 1 秒,之前的 99% 全部白传。

要解决这个问题,必须把“大石头”敲碎成“小石子”,这就是我们要聊的——分块切割术。
二、 核心原理:前后端握手协议
一个工业级的分块上传流程通常包含三个动作:
- Check (秒传/续传探测): 发送文件 MD5,后端返回:“这个文件我有了(秒传)” 或 “我只有第 1, 3, 5 块(续传)”。
- Upload (分块传输): 前端并发推送缺失的切片。
- Merge (请求合并): 前端通知后端:“碎片齐了,拼起来吧”。
三、 后端硬核实现:SpringBoot + RandomAccessFile
1. 存储结构设计
我们拒绝“一次性加载到内存”,使用 RandomAccessFile 直接在磁盘指定偏移量写入,这能极大地节省内存。
@Datapublic class ChunkDTO { private String fileMd5; // 文件唯一标识 private Integer chunkIndex; // 当前分块序号 private Long chunkSize; // 分块大小 private MultipartFile file; // 实际二进制数据}2. 分块上传与偏移量写入
不要等全部传完再合并!我们在收到每一块时,直接定位到它在最终文件中的位置并写入。这样可以避免最后合并时产生巨大的 IO 瓶颈。
@RestController@RequestMapping("/api/upload")public class FileUploadController { private final String UPLOAD_PATH = "/data/uploads/temp/"; @PostMapping("/chunk") public ResponseEntity uploadChunk(ChunkDTO chunkDTO) throws IOException { // 1. 创建临时分块目录或直接操作目标文件 File targetFile = new File(UPLOAD_PATH + chunkDTO.getFileMd5() + ".tmp"); try (RandomAccessFile raf = new RandomAccessFile(targetFile, "rw")) { // 2. 关键:计算偏移量(当前块序号 * 标准块大小) long offset = chunkDTO.getChunkIndex() * chunkDTO.getChunkSize(); raf.seek(offset); raf.write(chunkDTO.getFile().getBytes()); } // 3. 记录已上传的索引(可存入 Redis) redisTemplate.opsForSet().add("uploaded_chunks:" + chunkDTO.getFileMd5(), chunkDTO.getChunkIndex()); return ResponseEntity.ok("Chunk " + chunkDTO.getChunkIndex() + " saved."); }} 3. 生产级合并逻辑
合并不仅仅是重命名,还需要校验完整性(MD5 重算)以及正式存储(如移至 MinIO 或云存储)。
@PostMapping("/merge")public ResponseEntity mergeFile(@RequestParam String fileMd5, @RequestParam String fileName) { File tmpFile = new File(UPLOAD_PATH + fileMd5 + ".tmp"); File finalFile = new File("/data/uploads/final/" + fileName); // 1. 检查分块是否完整(对比 Redis 中的 Count 和前端切片总数) if (!checkIntegrity(fileMd5)) { return ResponseEntity.status(500).body("分块缺失,请重传"); } // 2. 原子性重命名 if (tmpFile.renameTo(finalFile)) { // 3. 清理 Redis 记录 redisTemplate.delete("uploaded_chunks:" + fileMd5); return ResponseEntity.ok("文件上传并合并成功"); } return ResponseEntity.status(500).body("合并失败");} 四、 前端实战:Vue + 并发控制
前端不能一股脑把 100 个请求全发出去,否则浏览器会卡死。我们需要一个简单的并发队列。
// 核心切片逻辑async function handleUpload(file) { const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB const chunks = []; const fileMd5 = await calculateMD5(file); // 使用 spark-md5 库 // 1. 预检:哪些块已经上传过了? const { data: uploadedList } = await axios.get(`/check?md5=${fileMd5}`); // 2. 切片 for (let i = 0; i < file.size; i += CHUNK_SIZE) { chunks.push({ file: file.slice(i, i + CHUNK_SIZE), index: i / CHUNK_SIZE }); } // 3. 过滤已上传块并执行并发上传 const uploadTasks = chunks .filter(chunk => !uploadedList.includes(chunk.index)) .map(chunk => { const formData = new FormData(); formData.append('file', chunk.file); formData.append('chunkIndex', chunk.index); formData.append('fileMd5', fileMd5); formData.append('chunkSize', CHUNK_SIZE); return axios.post('/api/upload/chunk', formData); }); // 4. 全部完成后触发合并 await Promise.all(uploadTasks); await axios.post('/api/upload/merge', { fileMd5, fileName: file.name });}五、 避坑与优化(生产环境必看)
- Redis 状态预检查: 在 uploadChunk 之前,前端必须先调 check 接口。如果 Redis 显示该 fileMd5 对应的分块已满,后端直接返回“秒传成功”,不走任何 IO。
- 清理僵尸切片: 很多用户传到一半就关了浏览器。建议写个定时任务(Spring Schedule),清理 UPLOAD_PATH 下超过 48 小时未变动的 .tmp 文件,释放磁盘。
- 零拷贝优化: 如果是在 Linux 环境且不使用 RandomAccessFile,可以尝试 FileChannel.transferTo,这是真正的内核级拷贝,效率更高。
六、性能实测:不是所有分块都叫提速
我们在 1Gbps 内网环境下,对 10GB 文件的测试数据如下:
文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有