尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot 大文件分片上传与断点续传实战:从原理到避坑

Spring Boot 大文件分片上传与断点续传实战:从原理到避坑 简介本资源面向Spring Boot开发者聚焦大文件上传场景下的断点续传与分片上传两项关键技术适合具备一定Java Web基础、需要提升文件传输效率与可靠性的中高级开发者参考。资源包共114个文件以51个java源码、45个class编译文件、11个xml配置及2个sql、2个yml为主另含少量js、ftl模板文件压缩包约112KB涵盖上传服务实现、实体类、响应结果封装与数据访问等模块结构紧凑便于快速定位核心逻辑。已有1717人学习下载说明该主题在实际开发中关注度较高。通过阅读源码可掌握断点续传中已上传字节数的记录与续传位置定位、分片上传中分片接收存储与合并策略以及上传状态管理、错误重试与文件类型校验等安全处理思路为构建高效可靠的大文件上传功能提供可复用的实现参考。1. Spring Boot 断点续传与分片上传大文件上传为什么总在 99% 翻车做过文件上传的工程师大概率都遇到过这种场景用户传一个 2GB 的视频进度条走到 99% 卡住刷新页面后一切归零只能从头再来。更糟的是服务器日志里只留下一句ClientAbortException没有任何可追溯的上下文。这不是玄学是典型的「单次 HTTP 请求承载了整个文件生命周期」导致的必然结果——网络抖动、网关超时、浏览器内存溢出任何一个环节出问题整个上传就废了。Spring Boot 断点续传或分片上传要解决的核心问题就一个把「一次性赌上全部」变成「可中断、可恢复、可校验」的工程流程。分片上传负责把大文件切成小块并行或串行传输断点续传负责在中断后从已完成的分片继续而不是重头再来。这套方案适合所有涉及大文件视频、镜像、备份包、数据集的 Spring Boot 项目尤其是前后端分离架构下用 Vue 或 React 做上传组件的场景。接下来我会按「原理选型 → 后端实现 → 前端配合 → 避坑 → 进阶优化」的顺序把这条链路讲透代码可以直接抄。2. 分片上传与断点续传的核心机制从 HTTP 协议到存储层怎么配合2.1 分片上传的本质把文件切成可独立传输的块分片上传的核心思想很简单前端用File.slice()把文件切成固定大小的块通常 1MB 到 5MB每块作为一个独立的 HTTP 请求发给后端后端收到后先存到临时目录等所有分片到齐再合并成完整文件。这样做的好处是单个请求体积小超时概率低而且可以并发发送多个分片来提升速度。但这里有个关键设计决策分片大小怎么定太小会导致请求数暴增HTTP 头部开销占比过高太大则失去分片的意义单次超时风险回升。我一般会按文件大小动态调整——小于 100MB 用 1MB 分片100MB 到 1GB 用 2MB超过 1GB 用 5MB。这个策略在代码里用一个简单的函数就能实现。另一个必须提前想清楚的问题是分片上传到后端后临时文件存哪里常见做法有三种本地磁盘临时目录、对象存储如 MinIO、分布式文件系统。本地磁盘最简单但多实例部署时会出问题——分片可能落到不同节点上。如果项目已经用了 MinIO直接让前端把分片传到 MinIO 的临时桶里是更稳妥的选择后端只负责记录分片状态和触发合并。2.2 断点续传的关键上传前先问「传到哪了」断点续传不是「断了自动续」而是「断了之后能知道从哪继续」。实现上依赖一个前置查询接口前端在上传开始前先带着文件唯一标识通常是 MD5 或 SHA-256问后端「这个文件传过没有传了多少分片」后端返回已上传的分片序号列表前端跳过这些分片只传缺失的。文件唯一标识的生成方式直接影响体验。用 MD5 全量计算大文件会卡住浏览器主线程常见优化是「抽样 MD5」——取文件头、尾、中间各 2MB 计算哈希碰撞概率极低但速度快很多。如果对唯一性要求极高比如金融场景可以用 Web Worker 在后台线程算完整 SHA-256不阻塞 UI。后端记录分片状态需要一张表或一个 Redis 结构。表结构至少包含文件标识、分片序号、分片临时路径、上传状态、创建时间。Redis 更适合做临时状态存储用 Hash 结构key 是文件标识field 是分片序号value 是状态标记设置合理的过期时间比如 24 小时自动清理未完成的上传。2.3 合并策略与校验最后一步最容易出问题所有分片到齐后后端需要按序号顺序合并。合并时有两个坑一是分片到达顺序不保证必须按序号排序后再合并二是合并过程中如果服务重启已经合并的部分可能损坏。稳妥的做法是合并到一个新文件合并完成后计算最终文件的 MD5和前端传来的原始 MD5 比对一致才认为上传成功然后把临时分片删掉。校验这一步不能省。我见过太多案例分片合并后文件大小对但内容损坏原因可能是某个分片在传输中被截断但 HTTP 状态码仍是 200。加上最终 MD5 校验这类问题会在上传阶段暴露而不是等到用户下载时才发现。2.4 最小可运行的后端接口设计下面是一个基于 Spring Boot 的分片上传核心接口包含「检查上传状态」「上传分片」「合并分片」三个端点。代码用本地磁盘存储演示生产环境替换成 MinIO 即可。RestController RequestMapping(/api/upload) public class ChunkUploadController { // 临时分片存储根目录生产环境建议配置化 private static final String CHUNK_DIR /data/upload/chunks/; private static final String MERGED_DIR /data/upload/merged/; Autowired private RedisTemplateString, Object redisTemplate; /** * 检查文件上传状态返回已上传的分片序号 * 前端拿到后跳过这些分片实现断点续传 */ GetMapping(/check) public ResultListInteger checkUpload(RequestParam String fileMd5, RequestParam String fileName) { String key upload:chunks: fileMd5; // 从 Redis Hash 中取出所有已上传的分片序号 MapObject, Object entries redisTemplate.opsForHash().entries(key); ListInteger uploaded entries.keySet().stream() .map(o - Integer.parseInt(o.toString())) .sorted() .collect(Collectors.toList()); return Result.ok(uploaded); } /** * 上传单个分片 * 分片先写临时文件再在 Redis 中标记该分片已完成 */ PostMapping(/chunk) public ResultVoid uploadChunk(RequestParam(file) MultipartFile file, RequestParam String fileMd5, RequestParam int chunkIndex, RequestParam int totalChunks) throws IOException { // 每个文件一个临时目录避免不同文件分片混淆 File chunkDir new File(CHUNK_DIR fileMd5); if (!chunkDir.exists()) { chunkDir.mkdirs(); } // 分片文件名直接用序号合并时按序号排序 File chunkFile new File(chunkDir, String.valueOf(chunkIndex)); file.transferTo(chunkFile); // 在 Redis 中记录该分片已上传过期时间 24 小时 String key upload:chunks: fileMd5; redisTemplate.opsForHash().put(key, String.valueOf(chunkIndex), 1); redisTemplate.expire(key, 24, TimeUnit.HOURS); return Result.ok(); } /** * 合并所有分片 * 按序号顺序追加写入最终文件合并后做 MD5 校验 */ PostMapping(/merge) public ResultString mergeChunks(RequestParam String fileMd5, RequestParam String fileName, RequestParam int totalChunks) throws IOException { File chunkDir new File(CHUNK_DIR fileMd5); File mergedFile new File(MERGED_DIR fileName); try (FileOutputStream fos new FileOutputStream(mergedFile, true)) { for (int i 0; i totalChunks; i) { File chunk new File(chunkDir, String.valueOf(i)); if (!chunk.exists()) { return Result.fail(分片 i 缺失无法合并); } Files.copy(chunk.toPath(), fos); } } // 合并完成后校验 MD5确保文件完整性 String actualMd5 DigestUtils.md5Hex(new FileInputStream(mergedFile)); if (!actualMd5.equalsIgnoreCase(fileMd5)) { mergedFile.delete(); return Result.fail(文件校验失败请重新上传); } // 清理临时分片和 Redis 状态 FileUtils.deleteDirectory(chunkDir); redisTemplate.delete(upload:chunks: fileMd5); return Result.ok(上传成功); } }这段代码里几个参数需要根据实际情况调整。CHUNK_DIR和MERGED_DIR建议放在不同磁盘分区避免合并时 IO 竞争。Redis 的过期时间设为 24 小时是个折中值太短会导致用户隔天续传失败太长会占用内存。totalChunks由前端计算后传入后端在合并时用它做边界检查防止分片序号越界。提示file.transferTo()在 Spring Boot 中默认使用临时文件如果分片很大且并发高临时目录可能被写满。可以在application.yml中配置spring.servlet.multipart.location指定临时目录并确保磁盘有足够空间。3. 前端配合Vue/React 里怎么切分片、算 MD5、控制并发3.1 分片切割与 MD5 计算的浏览器端实现前端要做三件事算文件唯一标识、切分片、按序上传。算 MD5 用spark-md5库但不要直接全量读用抽样方式。下面是一个可复用的工具函数import SparkMD5 from spark-md5; /** * 抽样计算文件 MD5取头、中、尾各 2MB * 比全量计算快 10 倍以上碰撞概率极低 */ export function calcFileMd5(file) { return new Promise((resolve) { const chunkSize 2 * 1024 * 1024; const chunks []; const spark new SparkMD5.ArrayBuffer(); const fileReader new FileReader(); // 取头、中、尾三个采样点 const head file.slice(0, chunkSize); const middle file.slice( Math.floor(file.size / 2), Math.floor(file.size / 2) chunkSize ); const tail file.slice(file.size - chunkSize); chunks.push(head, middle, tail); let current 0; fileReader.onload (e) { spark.append(e.target.result); current; if (current chunks.length) { fileReader.readAsArrayBuffer(chunks[current]); } else { // 把文件大小也拼进去进一步降低碰撞概率 resolve(spark.end() _ file.size); } }; fileReader.readAsArrayBuffer(chunks[0]); }); } /** * 把文件切成固定大小的分片数组 */ export function sliceFile(file, chunkSize) { const chunks []; let start 0; while (start file.size) { chunks.push(file.slice(start, start chunkSize)); start chunkSize; } return chunks; }calcFileMd5返回的标识里拼了文件大小这样即使两个文件抽样 MD5 相同大小不同也能区分开。sliceFile的chunkSize建议按前面说的动态策略来定不要写死。3.2 并发上传与失败重试的控制逻辑分片上传不能无限制并发浏览器对同一域名的并发请求数有限制通常 6 个开太多反而会排队。我一般控制在 3 到 4 个并发每个分片失败后重试 2 次仍失败则暂停整个上传并提示用户。/** * 带并发控制和重试的分片上传 * concurrency 控制同时进行的请求数 * retries 是单个分片的最大重试次数 */ export async function uploadChunks(file, fileMd5, uploadedIndexes, options {}) { const { chunkSize 2 * 1024 * 1024, concurrency 3, retries 2 } options; const chunks sliceFile(file, chunkSize); const total chunks.length; // 过滤掉已上传的分片实现断点续传 const pending chunks .map((blob, index) ({ blob, index })) .filter((item) !uploadedIndexes.includes(item.index)); let cursor 0; const uploadOne async (item) { for (let attempt 0; attempt retries; attempt) { try { const formData new FormData(); formData.append(file, item.blob); formData.append(fileMd5, fileMd5); formData.append(chunkIndex, item.index); formData.append(totalChunks, total); await axios.post(/api/upload/chunk, formData); return; } catch (e) { if (attempt retries) throw new Error(分片 ${item.index} 上传失败); // 指数退避避免瞬间重试打爆后端 await new Promise((r) setTimeout(r, 500 * Math.pow(2, attempt))); } } }; // 并发池始终保持 concurrency 个任务在跑 const workers Array.from({ length: concurrency }, async () { while (cursor pending.length) { const item pending[cursor]; await uploadOne(item); } }); await Promise.all(workers); // 所有分片传完后调用合并接口 await axios.post(/api/upload/merge, { fileMd5, fileName: file.name, totalChunks: total }); }这段代码里concurrency设为 3 是保守值如果后端和网络都稳定可以调到 4 到 5。retries设为 2 意味着每个分片最多请求 3 次再失败就抛错。指数退避的基数 500ms 可以根据实际网络延迟调整内网环境可以降到 200ms。3.3 上传进度的真实计算方式进度条不能按「已发送请求数 / 总分片数」算因为分片大小可能不同最后一个分片通常更小而且重试会导致重复计数。准确的做法是累计已成功分片的字节数除以文件总大小let uploadedBytes uploadedIndexes.reduce((sum, idx) { const start idx * chunkSize; const end Math.min(start chunkSize, file.size); return sum (end - start); }, 0); // 每成功一个分片后更新 const onChunkSuccess (index) { const start index * chunkSize; const end Math.min(start chunkSize, file.size); uploadedBytes (end - start); const percent ((uploadedBytes / file.size) * 100).toFixed(1); updateProgress(percent); };这样算出来的进度和实际传输量一致用户看到 99% 卡住的情况会大幅减少——因为最后一个分片的大小是精确计算的不会出现「请求都发完了但进度条还差一点」的尴尬。4. 避坑指南分片上传最容易翻车的 5 个地方4.1 坑一分片序号从 0 还是从 1 开始前后端没对齐现象所有分片都上传成功合并时提示「分片 0 缺失」或合并后的文件头部损坏。原因前端slice循环从 0 开始但后端合并时从 1 开始遍历或者反过来。这种低级错误在联调时经常出现因为双方各自测试时都正常。解决在接口文档里明确写死「分片序号从 0 开始范围 0 到 totalChunks-1」前后端代码里都加一行注释。合并时用for (int i 0; i totalChunks; i)不要用。4.2 坑二Redis 过期时间到了分片文件还在但状态没了现象用户上传到一半去吃饭回来继续传前端查询已上传分片返回空列表所有分片重传。原因Redis 的expire设得太短或者每次上传分片时没有刷新过期时间。默认的 24 小时对大多数场景够用但如果用户传的是几十 GB 的文件可能需要更长。解决每次上传分片时都调用redisTemplate.expire(key, 24, TimeUnit.HOURS)刷新过期时间。同时加一个定时任务每天凌晨清理超过 48 小时未完成的上传删除对应的临时分片目录避免磁盘被占满。4.3 坑三Nginx 的client_max_body_size没改分片请求被 413 拦截现象本地测试正常部署到服务器后所有分片上传返回 413 Request Entity Too Large。原因Nginx 默认的请求体大小限制是 1MB而分片大小通常设的是 2MB 或 5MB直接超限。解决在 Nginx 配置的http、server或location块中加client_max_body_size 10m;值要大于最大分片大小。如果用了 Spring Cloud Gateway还要检查spring.codec.max-in-memory-size配置。4.4 坑四合并时用FileOutputStream追加写但没关流就校验 MD5现象合并后的文件 MD5 校验失败但文件大小正确用播放器打开视频能播但结尾花屏。原因FileOutputStream没有 flush 或 close 就去读文件算 MD5缓冲区里的数据还没落盘。解决用 try-with-resources 确保流自动关闭或者在算 MD5 之前显式调用fos.flush()和fos.close()。上面的示例代码用了 try-with-resources合并完成后流已关闭不会出这个问题。4.5 坑五前端用axios默认的Content-Type后端MultipartFile收不到现象后端接口返回 400日志显示Required request part file is not present。原因用FormData上传时axios 需要自动设置Content-Type: multipart/form-data; boundary...如果手动设置了Content-Type: application/json或没让 axios 自动处理后端就解析不了。解决不要手动设置Content-Type让 axios 根据FormData自动生成。如果用了拦截器统一设置Content-Type要在上传请求里覆盖掉。检查代码里有没有axios.defaults.headers.post[Content-Type] application/json这种全局配置。5. 进阶技巧用 MinIO 和秒传把上传体验再拉一个档次5.1 接入 MinIO 做分片存储摆脱本地磁盘限制本地磁盘存分片在单机场景够用但一旦上了多实例或容器化部署分片落到哪个节点就不确定了。MinIO 兼容 S3 协议Spring Boot 里用minio-java客户端可以把分片直接传到对象存储的临时桶里合并时用 MinIO 的composeObject接口在服务端完成合并不消耗应用服务器带宽。// 初始化 MinIO 客户端 MinioClient minioClient MinioClient.builder() .endpoint(http://minio:9000) .credentials(accessKey, secretKey) .build(); // 上传分片到临时桶 minioClient.putObject( PutObjectArgs.builder() .bucket(upload-temp) .object(fileMd5 / chunkIndex) .stream(file.getInputStream(), file.getSize(), -1) .build() ); // 合并分片MinIO 服务端完成不经过应用内存 ListComposeSource sources IntStream.range(0, totalChunks) .mapToObj(i - ComposeSource.builder() .bucket(upload-temp) .object(fileMd5 / i) .build()) .collect(Collectors.toList()); minioClient.composeObject( ComposeObjectArgs.builder() .bucket(upload-final) .object(fileName) .sources(sources) .build() );composeObject要求每个分片至少 5MB除最后一个所以如果分片大小设的是 1MB 或 2MBMinIO 会报错。解决办法是把分片大小调到 5MB 以上或者用 MinIO 的uploadObject分片上传 API 替代手动切分。这个限制在选型时要提前确认。5.2 秒传文件已存在时直接跳过上传秒传的逻辑很简单前端算完文件 MD5 后先调一个/api/upload/exists接口后端查数据库或 Redis 里有没有这个 MD5 对应的已完成文件。如果有直接返回文件访问地址前端显示「上传成功」实际一个字节都没传。实现秒传的关键是维护一张「已完成文件表」字段包括文件 MD5、文件名、存储路径、文件大小、上传时间。每次合并成功后插入一条记录。查询时用 MD5 做唯一索引命中即秒传。但秒传有个边界要注意如果两个用户上传了相同 MD5 但不同文件名的文件第二个用户应该看到自己的文件名而不是第一个用户的。所以存储路径可以按 MD5 组织但文件名和元数据要按用户维度记录。5.3 验证断点续传是否真的生效三个必测场景写完代码后用这三个场景验证场景操作预期结果中途刷新页面上传到 50% 时按 F5重新选择同一文件后从 50% 继续不重传已完成分片网络中断恢复上传中禁用网卡 10 秒再启用失败的分片自动重试最终上传成功服务重启上传到 80% 时重启 Spring Boot 应用Redis 状态保留重启后继续上传剩余分片第三个场景依赖 Redis 持久化如果 Redis 没开 AOF 或 RDB重启后状态会丢。生产环境建议 Redis 开启 AOF或者把分片状态同时写一份到数据库作为兜底。5.4 一个我踩过的坑分片大小和 MinIO 的 5MB 限制冲突我第一次把分片大小设成 2MB本地磁盘合并一切正常切到 MinIO 后composeObject直接报InvalidPart。查了半天文档才发现 MinIO 要求除最后一个分片外每个分片必须大于等于 5MB。后来把分片策略改成「小于 500MB 的文件用 5MB 分片大于 500MB 用 10MB」问题解决。这个限制在 AWS S3 上也一样选型时如果打算用对象存储分片大小别低于 5MB。希望帮到你。本文还有配套的精品资源点击获取
返回列表