尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

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

发布时间:2026/9/26 5:30:20

资讯中心
01
ARTICLE

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

Spring Boot实现大文件分片上传与断点续传:原理与实战
大文件上传这个坑基本每个做后端的朋友都踩过。项目里要传几个G的压缩包、视频、数据文件直接用普通的表单提交要么超时、要么内存爆掉、要么传了一半断网白干。后来我把分片上传和断点续传整套流程在 Java 项目里落地了今天这篇就把原理和全过程拆开讲清楚希望能给你省点折腾的时间。这套方案不管你是做网盘、视频平台还是做数据采集系统都能直接参考着用。1. 整体方案设计与原理拆解1.1 为什么大文件必须做分片很多人第一反应是我直接把HTTP请求的超时时间调大不就行了。你要是传个几十MB的包确实可以但几百MB甚至几个G的文件这条路根本走不通。我说三个最现实的原因。首先是内存压力。普通的表单提交后端拿MultipartFile接文件时Tomcat 默认是先把整个请求体读进内存的大文件直接导致堆内存飙升甚至直接 OOM。就算你设了临时文件目录一个大请求长期占用一个工作线程并发一上来服务就瘫了。其次是网络不稳定性。一个 2G 的文件传了 80% 断网了TCP 层面只能重传尚未确认的数据服务端收到的不是完整文件又得从头来。移动网络、跨地域传输、代理服务器任何一个环节抖一下体验就崩了。最后是失败成本。大文件传输一旦失败之前传的数据全部作废没有中间态。所以分片的核心价值不是说把文件切碎了好传而是把失败的影响范围控制在一个很小的分片上某个分片失败只需要重传那一片而不是重传整个文件。用搬家来类比就很好理解你要搬一整个衣柜上楼电梯塞不下硬扛上十楼中途手滑就全摔了。你把衣柜拆成组件一个小推车一车一车运哪块掉了就回去取哪块中途还能休息这就是分片和断点的意义。1.2 断点续传的本质服务端必须记住进度断点续传这个词本身有点误导它不是网络层面的断点续传而是业务层面的续传。真正要做的事情是服务端知道自己已经收到了哪些分片客户端在重连之后问一下“我还差哪些分片”然后只传缺失的部分就行。所以断点续传的实现关键点有三个客户端能标识唯一文件。一般用文件内容算出来的 MD5而不是文件名。文件名可以改内容不会变而且用内容 MD5 还能顺带实现“秒传”。服务端能记录分片的上传状态。每个分片有独立的序号服务端要能查“某个文件标识下哪些序号已经上传成功了”。上传完成后能做分片合并并且能校验完整性。合并之后用总文件大小和 MD5 做一个基础核对防止分片数据缺失或者错序。一句话总结断点续传 客户端分片 服务端记录分片进度 合并时全局校验。1.3 技术选型和整体架构后端我用的是 Spring Boot Servlet 标准的MultipartFile接收分片没有引入额外的大型组件。存储初期直接落本地磁盘临时目录按文件 MD5 建目录、按分片序号命名文件合并后再把最终文件放到存储目录。要是你们线上用的阿里云 OSS 或者 MinIO思路完全一样只是把本地合并改成服务端分片上传或者流式拷贝这个后面我会提。前端用 JavaScript 的File.slice()切割文件配合XMLHttpRequest或fetch逐个上传分片。分片大小我推荐 1MB 到 5MB 之间太小了请求数量太多HTTP 握手开销大太大了又回到大请求的老路上失败重传成本增加。这个参数不是拍脑袋定的后面我会讲怎么根据实际场景选。整体链路是这样的前端读取文件 - 计算文件MD5 - 调预上传接口 - 若已存在则秒传若部分存在则返回缺失分片列表 - 按分片循环上传可并发 - 上传完通知服务端合并 - 服务端校验分片完整性 - 合并成完整文件 - 返回最终访问路径这个流程里最容易被忽略的是“预上传”这一步。它承担了两件事秒传判断和断点查询。没有预上传前端就只能盲目把所有分片传一遍断点续传就是空的。2. 核心接口设计与前后端交互细节2.1 三个核心接口缺一不可我按项目里实际使用的接口给你列出来这是整套方案的骨架。接口请求路径核心参数返回值预上传POST /upload/prefileMd5、fileName、totalSize、totalChunksuploadId、exists、missChunks分片上传POST /upload/chunkuploadId、fileMd5、chunkIndex、chunkSize、file分片内容成功/失败、已接收大小合并通知POST /upload/mergeuploadId、fileMd5、fileName、totalSize、totalChunks最终文件路径、是否秒传pre接口返回的missChunks列表很关键。服务端根据已有的分片记录把缺失的分片序号列表返回给前端前端拿着这个列表精确补传而不是全部重传。实际开发里有人把uploadId直接换成fileMd5也能跑通但用 uploadId 的话可以在服务端做额外的鉴权和业务绑定比如归属到某个用户、某个工单后续排查起来更清晰。我在项目里是用 uploadId 作为主键关联所有分片状态fileMd5 只作为文件唯一性判断。2.2 服务端存储结构临时文件与元数据分离分片文件不能直接和目标文件放一起。我建议目录结构长这样{存储根目录}/ tmp/ {uploadId}/ 0.part 1.part 2.part data/ {yyyyMMdd}/ {fileMd5}_{fileName}临时目录按 uploadId 隔离分片序号从 0 开始按顺序命名。合并完成之后把完整文件挪到 data 目录再把临时目录整个删掉。这样如果哪个用户传了一半不传了排查的时候一眼就能看到残留的临时目录清理起来也明确。分片的元数据我存在 ConcurrentHashMap 里做内存态因为早期项目并发量不高重启后用户重新上传就行了。如果你要支持“服务端重启后仍然可以续传”得上 Redis 或者数据库表。表结构最简单的设计是CREATE TABLE upload_chunk ( upload_id VARCHAR(64) NOT NULL, chunk_index INT NOT NULL, chunk_size BIGINT NOT NULL, is_uploaded TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (upload_id, chunk_index) );每个分片上传成功后更新is_uploaded 1预上传查询时WHERE upload_id ? AND is_uploaded 1拿到的就是 already 列表排除后就是 miss 列表。这个表简单直接没什么弯弯绕绕。2.3 分片大小怎么定才合理分片大小很多人随手填 5MB但这里是有讲究的。分片越大分片数量越少HTTP 请求次数越少但单个分片传输失败的代价越大。分片太小比如 512KB一个 1G 的文件要切 2048 片光 HTTP 握手算下来就有好几秒的纯开销更别说每个请求都要带一遍 form-data 的元信息。我实测下来局域网环境用 5MB 分片效率最高公网环境 1MB 到 2MB 更稳妥。原因在于公网传输丢包率偏高大分片一旦被 TCP 快速重传逻辑干扰整体速度反而下降。另外要考虑到前端切片是Blob.slice()不需要额外读取内容到内存所以小分片对前端内存影响不大。还有一个隐藏参数是并发数。我常用的是 3 个并发激进一点可以到 5。并发太高服务端多个请求同时写同一个临时目录的不同分片文件看似没问题但如果你的实现里对每个分片先检查“是否存在”再写并发时会有重复写和脏数据的问题。控制并发数也是给服务端减少压力。3. 后端核心实现与关键代码3.1 预上传接口秒传判断和断点查询的入口先看这个接口的代码。处理逻辑分三步算文件全局信息、查是否已存在完整文件、查已上传分片。RestController RequestMapping(/upload) public class UploadController { Autowired private UploadService uploadService; PostMapping(/pre) public ResultPreUploadVO preUpload(RequestBody PreUploadDTO dto) { PreUploadVO vo new PreUploadVO(); // 1. 如果目标文件已存在直接标记 exists前端不用再传 if (uploadService.isFileExists(dto.getFileMd5(), dto.getTotalSize())) { vo.setExists(true); return Result.ok(vo); } // 2. 查询该 uploadId 下已上传的分片序号集合 SetInteger uploadedChunks uploadService.listUploadedChunks(dto.getUploadId()); // 3. 计算缺失分片列表 totalChunks 是总分片数 ListInteger missChunks new ArrayList(); for (int i 0; i dto.getTotalChunks(); i) { if (!uploadedChunks.contains(i)) { missChunks.add(i); } } vo.setMissChunks(missChunks); return Result.ok(vo); } }isFileExists这一步实现的是秒传能力。只要文件 MD5 和大小都匹配说明之前有人传过完全一样的文件前端拿到existstrue直接跳过整个上传过程体验上的效果就是“一点就传完了”。这个逻辑在网盘系统里几乎是标配。3.2 分片上传接口把分片落盘并更新状态分片上传接口是实际接收文件的接口要注意的点有两个分片内容必须写独立文件分片写入成功后要立刻更新状态。PostMapping(/chunk) public ResultVoid uploadChunk(RequestParam(file) MultipartFile file, RequestParam(uploadId) String uploadId, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(fileMd5) String fileMd5, RequestParam(chunkSize) Long chunkSize) { // 校验分片大小防止恶意传超大文件 if (file.isEmpty() || file.getSize() chunkSize * 1.1) { return Result.fail(分片数据异常); } // 1. 创建 uploadId 对应的临时目录 Path chunkDir Paths.get(storageRoot, tmp, uploadId); Files.createDirectories(chunkDir); // 2. 分片以序号命名固定落在独立文件里 Path chunkFile chunkDir.resolve(chunkIndex .part); try (InputStream in file.getInputStream()) { Files.copy(in, chunkFile, StandardCopyOption.REPLACE_EXISTING); } // 3. 更新内存或数据库里的分片状态 uploadService.markChunkUploaded(uploadId, chunkIndex, file.getSize()); return Result.ok(); }这里有个容易被忽略的细节Files.copy在文件已存在时会抛异常所以必须带REPLACE_EXISTING。但这种直接覆盖也有风险比如前端因为重试机制并发上传了两次同一个分片第二个请求会覆盖第一个只要内容一致其实没有影响。真正要预防的是分片序号和分片数据不匹配。前端如果逻辑混乱把序号为 2 的块发到序号 3 的位置合并时文件就悄悄损坏了。所以我在分片上传时还会把内存里的chunkSize和实际收到的分片大小对比一下偏差太大直接拒绝从源头掐断脏数据。3.3 合并分片最后一步校验比想象中重要所有分片传完后前端调 merge 接口服务端把临时目录里的.part文件按序号拼接成完整文件。拼接本身不复杂注意顺序和缓冲就够了。PostMapping(/merge) public ResultString mergeChunks(RequestParam(uploadId) String uploadId, RequestParam(fileName) String fileName, RequestParam(fileMd5) String fileMd5, RequestParam(totalChunks) Integer totalChunks, RequestParam(totalSize) Long totalSize) throws IOException { Path chunkDir Paths.get(storageRoot, tmp, uploadId); Path targetDir Paths.get(storageRoot, data, LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)); Files.createDirectories(targetDir); // 文件名做安全处理防止路径穿越 String safeName fileName.replaceAll([\\\\/:*?\|], _); Path targetFile targetDir.resolve(fileMd5 _ safeName); // 1. 先校验分片数量是否齐全 long actualChunks Files.list(chunkDir).count(); if (actualChunks ! totalChunks) { throw new IllegalStateException(分片数量不完整: actualChunks / totalChunks); } // 2. 按序号逐个拼接 try (BufferedOutputStream bos new BufferedOutputStream(Files.newOutputStream(targetFile))) { for (int i 0; i totalChunks; i) { Path part chunkDir.resolve(i .part); if (!Files.exists(part)) { throw new IllegalStateException(缺失分片: i); } // 缓冲区设 8KB, 避免频繁 IO Files.copy(part, bos); } } // 3. 合并后校验总大小不一致说明有分片损坏或缺失 long targetSize Files.size(targetFile); if (targetSize ! totalSize) { Files.deleteIfExists(targetFile); throw new IllegalStateException(合并文件大小不一致: targetSize / totalSize); } // 4. 清理临时目录结束整个上传流程 // deleteRecursively 可以用 FileUtils也可以手动递归删除 uploadService.cleanup(uploadId); return Result.ok(targetFile.toString()); }合并的时候要特别注意不能直接把文件流一股脑写进目标文件然后指望校验 MD5 发现错误再重来。更好的做法是合并前先查分片数量、合并中遇到缺失立刻中断、合并后马上校验总大小。三步校验都过了文件的可靠性基本就有保障了。至于 MD5 级的完整校验代价高我一般只在金融、医疗这类强校验场景里做。3.4 状态存储的演进建议我上面写的版本是把状态存在内存里用ConcurrentHashMapString, SetInteger记录已上传的分片。这个方案在单机、低并发的场景下足够但有两个硬伤一是重启后所有上传进度全部丢失二是不好做多节点横向扩展。你如果一开始就考虑上生产我建议直接用 Redis。用SetInteger结构存储已上传分片序号key 就是 uploadId预上传时SMEMBERS uploadId拿全部分片分片上传成功SADD uploadId chunkIndex合并完成DEL uploadId。操作起来直观而且天然支持过期时间只要存一个 uploadId 和最后活跃时间的映射定期清理残留就行。4. 前端配合与上传细节4.1 前端切片和上传序列管理前端这一侧用浏览器原生能力就能实现不需要额外框架。核心就是File.slice()。const file document.getElementById(fileInput).files[0]; const chunkSize 2 * 1024 * 1024; // 2MB const totalChunks Math.ceil(file.size / chunkSize); let start 0; for (let i 0; i totalChunks; i) { const slice file.slice(start, start chunkSize); const formData new FormData(); formData.append(file, slice, file.name); formData.append(uploadId, uploadId); formData.append(chunkIndex, i); formData.append(fileMd5, fileMd5); // 这里把 slice 存到数组里配合后续并发控制 uploadTasks.push({ index: i, formData }); start chunkSize; }很多人会在这里踩一个坑File.slice()切出来的Blob在调用append(file, slice, fileName)时第三个参数一定要传文件名。因为有的浏览器不传就会把文件名变成blob后端MultipartFile.getOriginalFilename()就拿到脏数据还没上传就开始出幺蛾子。4.2 并发控制与进度计算的坑上传接口本身不复杂复杂的是并发控制和进度计算。你并发发 5 个分片请求每个请求的快慢不一样进度条不能用“已完成的请求数 / 总分片数”来算因为每个分片的大小理论一致但耗时差异会导致进度条跳来跳去。我常用的做法是记录每个分片上传的开始字节偏移进度 已完成分片数 / 总体分片数 的固定步长更新而不是“当前成功回调里用总字节数去除”。更直观一点保留一个uploadedChunks计数器每个分片成功回调里uploadedCount然后用(uploadedCount / totalChunks) * 100更新进度。虽然粗略但稳定不跳。并发控制也很简单我封装一个asyncPool函数限制最大并发数。核心逻辑就 20 行左右。async function asyncPool(poolLimit, tasks, iteratorFn) { const ret []; const executing new Set(); for (const task of tasks) { const p Promise.resolve().then(() iteratorFn(task)); ret.push(p); executing.add(p); const clean () executing.delete(p); p.then(clean); if (executing.size poolLimit) { await Promise.race(executing); } } return Promise.all(ret); }有了这个池子调用方式就是await asyncPool(3, uploadTasks, async (task) { await axios.post(/upload/chunk, task.formData, { timeout: 60 * 1000 }); });4.3 断点续传时的前端状态恢复真正的断点续传前端在页面刷新或者重新选择文件时要做两步操作第一步用文件内容算 MD5。这一步是前端性能瓶颈一个 2G 的文件在普通笔记本上算 MD5 可能要几秒到十几秒大一点的甚至到几十秒。优化方案有两个方向一是用 Web Worker 在后台线程计算不阻塞主线程界面不卡死二是用spark-md5库支持增量计算按分片循环读取文件内容累加哈希。第二步把 MD5 传给预上传接口拿到missChunks列表后只上传这些缺失分片。如果列表长度是 0说明之前已经传完了直接调 merge 接口拿最终文件即可。实际操作里有一个很隐蔽的问题前端刷新后uploadId如果沿用原来的还要保证后端临时目录没有被清理掉。所以我在每次预上传时会更新一下 uploadId 的最后活跃时间后端定期清理超过 24 小时的临时目录。这个时间窗口最好比用户心理预期的“中断后继续”的时间长我一般设 48 小时。4.4 用 Worker 还是不用 Worker热火词里有一条是“前端使用 worker 上传大文件”。Web Worker 有两个价值第一MD5 计算不卡 UI第二分片读取、上传调度可以放后台线程主线程只展示进度。我的建议是如果你的文件平均大小在 1G 以上或者要支持大并发上传直接用 Worker 加spark-md5的增量算法。如果只是普通场景分片上传本身没有大规模计算量主线程里用异步请求也不至于卡死用不用 Worker 对体验影响不大。Worker 的代价是代码复杂度上升postMessage 传递的数据结构要设计好。我的方式是 Worker 内部自己按分片发请求主线程只接收{ type: progress, percent }消息更新进度条这样主线程最轻松。4.5 失败重试的灵活策略单分片上传失败时不能直接全部重来也不能无限重试。我常用的策略是指数退避第一次失败等 1 秒第二次等 2 秒第三次等 4 秒最多重试 5 次。如果重试 5 次还是失败就把这个分片扔到任务队列末尾等所有其他分片都传完再单独补这个。如果整个页面刷新了或者用户主动取消就回到第一步重新预上传断点续传的闭环就成立了。这个机制在弱网环境下特别管用比一把梭地调大请求超时时间靠谱得多。5. 常见问题与排查技巧实录5.1 问题速查表现象可能原因解决方案合并后文件打不开分片序号错乱、或合并时按到达顺序拼接合并必须按 chunkIndex 升序读文件而不是按请求完成顺序上传一半页面刷新重新传时又从 0 开始前端没有先调预上传接口或 uploadId 没保存断点续传必须先调 pre 接口拿 missChunks再补传秒传功能不生效文件 MD5 计算范围与实际内容不一致比如包含文件名计算 MD5 时只读文件内容不拼接文件名保证内容一致性内存溢出关闭了 Servlet 的 multipart 临时文件支持或分片过大保证 Spring Boot 的 multipart 临时目录开启分片控制在 10MB 以下并发上传同一个分片导致文件损坏缺少分片序号和分片内容的一致性校验后台上传前校验分片大小合并前校验总大小和总分片数服务端重启后断点没了分片状态存在内存里没有持久化状态存 Redis或建表存 upload_chunk重启后仍可查询已传分片进度条乱跳并发请求完成顺序不是分片顺序改用已成功分片数量 / 总分片数量计算进度5.2 我实际踩过的三个坑先说第一个坑合并前校验分片数量用Files.list(chunkDir).count()是能数的但如果你为了性能把每个分片文件直接追加写入目标文件遇到中断就会损坏目标文件。所以我宁可先检查序号文件是否都存在再开始合并。第二个坑分片上传接口里如果不校验chunkSize参数恶意请求可以把大文件伪装成一个分片传上来。我在接口里限制单分片最大不能超过 10MB超过直接拒绝。第三个坑是前端的用axios上传分片时如果后端返回 4xx 或者 5xxaxios 会抛异常但我最初没有区分“网络异常”和“业务失败”导致同一个分片因为参数问题反复重试浪费了很多请求。后来我统一处理响应先判断 HTTP 状态再判断业务 code业务 code 非 200 的分片只记录错误不再盲目重试。5.3 磁盘清理与资源保护大文件上传最容易忽略的是服务端磁盘空间。一个用户传 2G 文件临时目录至少占 2G如果有很多用户同时传磁盘很快就满了。一定要加定时清理任务比如每天凌晨扫一遍tmp目录删除超过 24 小时没有活跃的 uploadId 目录。活跃判断可以靠 Redis 里存储的 uploadId 最后写入时间没有 Redis 的话也可以看目录的最后修改时间。文件的上传过程中分片文件是不断写入的所以最后修改时间会一直刷新不会误删活跃文件。生产环境我还会对单个用户的上传并发做限流比如同一个 uploadId 同时处理的分片请求数最多 5 个否则一个用户调试接口时就能把服务打满影响其他人。5.4 后续扩展方向我这个版本是本地磁盘存储如果你们用的是阿里云 OSS 或者 MinIO合并分片这步可以不落到本地。OSS 本身支持 Append Object 或者分片上传接口服务端收到分片后直接转存到 OSS然后调 OSS 的完成分片上传这样本地不占磁盘空间也天然支持分布式部署。原理和逻辑都相同只是把“临时文件”换成了“OSS 的分片对象”。另外一个扩展方向是整体 MD5 的强校验。有些文件对完整性要求极高可以在合并之后重新计算目标文件 MD5和前端传上来的 fileMd5 比对不一致就删除重传。这个校验我在金融、税务类系统里是必做的虽然合并时间要多花一点但值得。至于前端的大文件下载那是另一个话题这里不展开。你能把上传这条路走通下载的断点续传其实本质也是分块 Range 请求套路是相通的。6. 收个尾说点实际的分片上传和断点续传整套流程真做起来没有想象中那么神秘核心就三块前端把文件切碎、服务端把碎片管理好、合并时校验完整。难点不在单点技术而在细节分片大小怎么定、并发数怎么控、进度怎么算、失败怎么重试、临时文件怎么清理。我之前做项目时先用一个 100MB 的文件把整个链路跑通确认合并后的文件 MD5 一致才敢拿 2G 的真实数据测。这个过程里最容易翻车的就是前端并发上传导致服务端分片错乱但只要你后端按序号名存文件、合并时严格按序号拼接问题就少了一大半。还有一点文件的上传接口在你上线前一定要压测不是测并发量而是测连续传 10 个 2G 的文件看磁盘占用和 Tomcat 的线程数曲线。这一步能提前暴露绝大多数坑不然等项目验收时用户传个大文件就挂就很被动了。我这套实现用的是 Spring Boot 加本地存储代码逻辑可以直接抄但参数一定要结合你的实际网络场景调。希望这篇能帮你在自己项目里少加点班如果有细节没讲明白的欢迎在评论区交流。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。