2026/8/29 8:07:40

大文件上传:分片、断点续传与秒传的原理与Spring Boot实现

大文件上传:分片、断点续传与秒传的原理与Spring Boot实现 很多候选人面到这个题第一反应是抛概念分片、断点续传、秒传关键词一甩感觉稳了。结果面试官补一句“100 个分片传上去其中有 3 个失败了你怎么处理”再补一句“用户断网两小时回来之后怎么让他不重新传一遍”再补一句“10 个人同时传同一个文件你的服务端会不会崩”场面基本就安静了。先说我的判断大文件上传的上限不在“分片”这个思路而在于分片之后的一堆工程细节——顺序、并发、合并、校验、清理、去重、内存、幂等。面试官真正想听的不是你能说出“分片上传”四个字而是你有没有在真实项目里被这些细节教训过。这篇文章不聊虚的。我们从面试题出发把分片上传、断点续传、秒传三件事的原理讲透给你一份可以直接贴进项目的前端 Spring Boot 完整示例再列一组真正常见的坑。读完你可以直接拿去回答面试也可以照着改造自己的上传模块。1. 面试官到底在问什么“讲讲大文件上传与断点续传有哪些坑”这个题表面考的是技术方案实际考的是三层东西。第一层知不知道有分片这个东西。大部分人都会答。File 太大一次 POST 传不动所以用Blob.slice()切成小块一块一块传最后在后端合并。这属于及格线。第二层知不知道分片之后还有协议设计。分片上传的接口长什么样每个分片如何标识服务端怎么知道哪些分片已经到了合并之前怎么校验分片完整上传中断后客户端怎么知道从哪里续这些问题才是面试官拉开差距的地方。第三层有没有踩过生产环境的坑。同一个文件被多人同时上传怎么处理临时分片会不会把磁盘塞满后端用byte[]接收分片会不会 OOMNginx 默认限制 1MB 请求体你怎么调合并的时候线程安全吗这些问题如果没有真实项目经验很难从原理推出答案。这篇文章就按这三层展开。先讲概念和关系再给完整代码最后集中排坑。2. 分片上传、断点续传、秒传到底有什么区别很多人把这三个词混在一起其实它们是三个不同层面的东西。先看一张对比表。名称解决的核心问题关键机制是否需要服务端配合分片上传单次请求传不完大文件文件切片分片传输后端合并必须断点续传传输中断后不想从头再来记录已上传分片跳过已传分片必须秒传相同文件重复上传浪费流量文件指纹去重不传文件内容必须2.1 分片上传分片上传解决的是“一个请求装不下一个文件”的问题。HTTP 请求体和响应体都有大小限制。Nginx 默认的client_max_body_size是 1MBTomcat 的maxPostSize默认通常也只有 2MB。就算你把这两个限制全部调大几十 GB 的文件一次读进 JVM 堆内存GC 压力也吃不消。分片上传的思路是把一个大文件切成若干个小块每个小块用独立的 HTTP 请求上传。后端先落盘最后再按顺序合并成完整文件。2.2 断点续传断点续传解决的是“传了一半断了怎么办”。这里的“断”有很多情况用户点了暂停、网络闪断、浏览器崩溃、服务器重启。断点续传的核心是让客户端知道“我已经传了哪些分片”下次继续时跳过这些分片只传缺失的部分。注意断点续传的“断点”不能只存在浏览器端。用户清一下浏览器缓存或者换台电脑断点就丢了。所以服务端必须能查询某个文件已经收到哪些分片。这是面试中很容易被追问到的点。2.3 秒传秒传解决的是“同一个文件被反复上传”的问题。很多系统里同一个安装包、同一份资料会被无数用户上传。每次上传都传一遍完整内容流量和存储都浪费。秒传的思路是上传前先计算文件指纹通常是 MD5 或 SHA-1服务端发现这个指纹已经存在就不再接收文件内容而是直接把新记录关联到已经存在的文件上。秒传本质不是传输技术是索引技术。前端算出文件指纹服务端命中已有文件数据没有在网络中流动。秒传就是上传环节的“缓存命中”。2.4 三者的组合关系分片上传是基础能力。断点续传和秒传都是在这个基础上的应用层优化。断点续传解决“传了一半”秒传解决“压根不用传”分片上传解决“一次传不完”。一个完整的商业级上传方案通常是三者组合先做秒传判断命中则直接返回成功未命中则分片上传上传过程中支持断点续传所有分片传完后合并再校验文件完整性。3. 完整实现前端分片 Spring Boot 后端接收下面给一个最小可跑通的示例。技术栈是前端原生 HTML/JavaScript后端 Spring Boot。我们用 Java 17 Spring Boot 3 演示低版本项目把HashMap替换Map.of相关写法即可。3.1 前端切片前端核心 API 是File.prototype.slice()。它在老 IE 里叫File.prototype.slice或mozSlice现代浏览器统一用slice。!-- 文件路径src/main/resources/static/upload.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 title大文件分片上传示例/title /head body input typefile idfileInput / button iduploadBtn开始上传/button button idpauseBtn暂停/button progress idprogress value0 max100/progress div idstatus/div script const CHUNK_SIZE 5 * 1024 * 1024; // 每个分片 5MB const CONCURRENCY 3; // 同时上传 3 个分片 let file null; let chunks []; let uploadedChunks new Set(); let paused false; const fileInput document.getElementById(fileInput); const uploadBtn document.getElementById(uploadBtn); const pauseBtn document.getElementById(pauseBtn); const progress document.getElementById(progress); const status document.getElementById(status); // 用文件名 大小 最后修改时间作为文件 ID function genFileId(f) { return ${f.name}-${f.size}-${f.lastModified}; } // 切片 function createChunks(f) { const list []; let start 0; let index 0; while (start f.size) { const end Math.min(start CHUNK_SIZE, f.size); list.push({ index, blob: f.slice(start, end) }); start end; index; } return list; } // 查询服务端已上传的分片 async function loadUploadedChunks() { const fileId genFileId(file); const res await fetch(/api/upload/chunks?identifier${encodeURIComponent(fileId)}); const data await res.json(); if (data.success) { uploadedChunks new Set(data.uploadedChunks.map(Number)); updateProgress(); } } // 上传单个分片 function uploadChunk(chunk) { return new Promise((resolve, reject) { if (paused || uploadedChunks.has(chunk.index)) { resolve(); return; } const fd new FormData(); fd.append(file, chunk.blob, ${chunk.index}.part); fd.append(identifier, genFileId(file)); fd.append(chunkIndex, chunk.index); const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload/chunk); xhr.onload () { if (xhr.status 200) { uploadedChunks.add(chunk.index); updateProgress(); resolve(); } else { reject(new Error(分片 ${chunk.index} 上传失败${xhr.status})); } }; xhr.onerror () reject(new Error(分片 ${chunk.index} 网络错误)); xhr.send(fd); }); } // 固定并发数的上传队列 async function uploadAllChunks() { let index 0; const workers []; for (let i 0; i CONCURRENCY; i) { workers.push(runWorker()); } await Promise.all(workers); async function runWorker() { while (!paused) { const chunk chunks[index]; if (!chunk) break; await uploadChunk(chunk); } } } // 请求后端合并 async function merge() { const fd new FormData(); fd.append(identifier, genFileId(file)); fd.append(fileName, file.name); fd.append(totalChunks, chunks.length); const res await fetch(/api/upload/merge, { method: POST, body: fd }); const data await res.json(); if (data.success) { status.innerHTML 上传完成 data.filePath; } else { status.innerHTML 合并失败 data.message; } }