news 2026/10/23 1:37:37

SpringBoot大文件上传实战:分片上传与断点续传机制详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot大文件上传实战:分片上传与断点续传机制详解

简介:这份资源面向需要在 Spring Boot 项目中实现大文件上传的 Java 开发者,聚焦断点续传与分片上传两大核心场景,帮助解决网络中断重传、大文件传输效率低等实际问题。压缩包共 114 个文件,以 51 个 java 源码与 45 个 class 编译文件为主体,辅以 11 个 xml 配置、2 个 sql 脚本、2 个 yml 配置文件及 js、ftl 等前端资源,整体约 112KB,结构紧凑,便于直接导入工程对照调试。资源围绕文件上传大小限制配置、Controller 接口编写、已上传字节数记录与续传定位、分片接收与合并、上传状态管理及错误重试、文件类型校验与权限控制等环节展开,覆盖从配置到落地的完整链路。目前已有 1718 人学习下载,适合希望快速掌握 Spring Boot 大文件上传方案、需要可运行示例代码与排错思路的中级开发者参考。

1. 分片上传与断点续传:为什么你的文件传到 99% 就翻车

做过文件上传的工程师大概率都遇到过这种场景:用户传一个 2GB 的视频,进度条走到 99% 卡住,刷新页面后一切归零,只能从头再来。更糟的是,弱网环境下大文件上传成功率低得离谱,客服工单里全是「传了半天白传了」。这不是前端进度条写得不好,而是整个上传链路缺少两个关键能力——分片上传和断点续传。

分片上传解决的是「大文件怎么传」的问题:把一个大文件切成固定大小的块,逐块上传,服务端按序合并。断点续传解决的是「传了一半断了怎么办」的问题:客户端记录已上传分片,重新连接时先问服务端「哪些块你已经有了」,只补缺失的部分。两者配合,才能让大文件上传从「碰运气」变成「可预期」。

这套方案适合谁?凡是涉及视频、设计稿、数据集、安装包等大文件上传的 SpringBoot 项目,都值得把这套机制做进去。下面从存储选型、分片协议、合并策略到前端配合,一步步拆开讲。

2. 分片上传的服务端设计:从接口定义到存储选型

2.1 为什么不能只靠 MultipartFile 一把梭

SpringBoot 默认的MultipartFile上传方式,本质上是把整个文件流一次性读进内存或临时磁盘,再交给业务代码处理。文件小的时候没问题,一旦超过几百 MB,JVM 堆内存压力陡增,Tomcat 的maxSwallowSize和maxFileSize限制也会直接抛异常。更致命的是,这种方式没有「续传」的概念——连接一断,服务端拿到的就是残缺数据,无法判断缺了哪一段。

分片上传的核心思路是:客户端把文件切成 N 个块,每块单独发一个 HTTP 请求,服务端每收到一块就落盘或存对象存储,并记录「文件标识 + 分片序号 + 分片哈希」的映射关系。所有分片到齐后,再触发合并操作。这样单次请求体小、超时风险低,而且天然支持并行上传和断点续传。

常见做法是引入对象存储(如 MinIO、阿里云 OSS、腾讯云 COS)来存分片,因为对象存储本身支持分片上传接口,服务端只需要做协调和元数据管理。如果项目规模不大,也可以直接落本地磁盘,用目录区分不同上传任务。

2.2 分片接口的四个核心端点

一个完整的分片上传服务端,至少需要暴露四个接口:初始化上传任务、上传单个分片、查询已上传分片、合并分片。下面用 SpringBoot 写一个最小可用的控制器骨架,存储层先用本地磁盘模拟,方便你直接跑通。

@RestController @RequestMapping("/api/upload") public class ChunkUploadController { @Autowired private ChunkUploadService chunkUploadService; // 1. 初始化上传任务,返回 uploadId @PostMapping("/init") public Result<String> initUpload(@RequestBody InitUploadRequest request) { // request 包含 fileName、fileSize、chunkSize、fileHash String uploadId = chunkUploadService.initUpload(request); return Result.ok(uploadId); } // 2. 上传单个分片 @PostMapping("/chunk") public Result<Void> uploadChunk(@RequestParam("uploadId") String uploadId, @RequestParam("chunkIndex") int chunkIndex, @RequestParam("chunkHash") String chunkHash, @RequestParam("file") MultipartFile file) { chunkUploadService.saveChunk(uploadId, chunkIndex, chunkHash, file); return Result.ok(); } // 3. 查询已上传的分片序号列表 @GetMapping("/chunks") public Result<List<Integer>> listUploadedChunks(@RequestParam("uploadId") String uploadId) { return Result.ok(chunkUploadService.listUploadedChunks(uploadId)); } // 4. 合并分片 @PostMapping("/merge") public Result<String> mergeChunks(@RequestParam("uploadId") String uploadId) { String fileUrl = chunkUploadService.mergeChunks(uploadId); return Result.ok(fileUrl); } }

这段代码里,initUpload负责生成全局唯一的uploadId,通常用 UUID 或「文件哈希 + 时间戳」组合,并把任务元数据写入数据库或 Redis。uploadChunk接收分片文件流,先校验分片哈希是否匹配,再写入临时目录。listUploadedChunks是断点续传的关键——前端重新连接时调用它,拿到已上传的序号列表,跳过这些分片。mergeChunks按序号顺序拼接所有分片,生成最终文件。

参数说明:chunkSize建议设为 5MB 到 10MB。太小会导致请求数暴增,太大则失去分片的意义。chunkHash用 MD5 或 SHA-256 都行,目的是防止网络传输过程中分片损坏。fileHash是整个文件的哈希,用来做秒传判断——如果服务端已经存在相同哈希的文件,直接返回已有地址,连分片都不用传。

2.3 存储层:本地磁盘 vs 对象存储的取舍

本地磁盘方案实现简单,适合单机部署或开发环境。目录结构可以设计为upload/{uploadId}/{chunkIndex}.part,合并时按序号读取并追加写入最终文件。但本地磁盘有两个硬伤:一是多实例部署时无法共享分片,二是磁盘容量和备份需要自己维护。

对象存储方案(MinIO、OSS、COS)更适合生产环境。以 MinIO 为例,它兼容 S3 协议,Java 端用minio-javaSDK 就能调用分片上传接口。服务端只需要把uploadId和 MinIO 的uploadId做映射,分片直接传给 MinIO,合并也由 MinIO 完成。这样服务端无状态,水平扩展容易,而且对象存储自带冗余和生命周期管理。

选型建议:如果项目已经用了云厂商的对象存储,直接走对象存储分片接口,省事且可靠。如果是私有化部署,MinIO 是首选。如果只是内部小工具,本地磁盘加定时清理任务也够用。关键是别在本地磁盘方案上硬扛多实例,否则分片对不上,合并出来的文件就是坏的。

3. 断点续传的校验逻辑:前端怎么问,后端怎么答

3.1 秒传、续传、重传的判定顺序

断点续传不是简单地「问一下缺哪些块」,而是一套判定流程。前端在选完文件后,先计算整个文件的哈希(大文件可以用抽样哈希或分片哈希聚合),然后按以下顺序询问服务端:

第一步,调/api/upload/check接口,传fileHash和fileName。服务端查数据库,如果该哈希对应的文件已经存在且状态为「已完成」,直接返回文件地址,前端跳过上传——这就是秒传。

第二步,如果文件不存在但存在相同哈希的「上传中」任务,返回该任务的uploadId和已上传的分片序号列表。前端拿到列表后,把本地分片序号与之对比,只上传缺失的部分。

第三步,如果连「上传中」任务都没有,服务端创建新任务,返回新的uploadId和空列表,前端从第一片开始传。

这个顺序不能乱。先判秒传可以省掉大量无效请求;再判续传可以避免重复上传;最后才走全新上传。很多实现把秒传和续传混在一起,导致已经传完的文件还被重新分片上传,浪费带宽。

3.2 分片校验:哈希对不上怎么办

每个分片上传时,前端会带上chunkHash。服务端收到后,先对分片文件流计算哈希,与传入值比对。如果不一致,说明传输过程中数据损坏,直接返回错误码,前端重传该分片。

这里有个容易翻车的点:分片哈希的计算方式前后端必须完全一致。比如前端用SparkMD5对ArrayBuffer计算,后端用DigestUtils.md5DigestAsHex对InputStream计算,只要字节序和读取方式一致,结果就相同。但如果前端在分片时用了slice方法,后端读取时又做了额外的编码转换,哈希就会对不上。血泪经验是:分片哈希只算原始字节,不要经过任何字符串编码。

另一个坑是分片序号从 0 还是从 1 开始。前端slice的索引从 0 开始,后端合并时如果按 1 开始排序,第一片就会丢失。建议统一用 0 基序号,并在数据库里存chunk_index字段时明确注释。

3.3 用 Redis 记录上传状态

上传任务的元数据(uploadId、fileName、fileSize、chunkSize、fileHash、已上传分片集合)需要持久化。用 MySQL 可以,但频繁读写分片状态对数据库压力不小。更常见的做法是用 Redis Hash 存任务信息,用 Redis Set 存已上传的分片序号。

@Service public class ChunkUploadServiceImpl implements ChunkUploadService { @Autowired private StringRedisTemplate redisTemplate; private static final String TASK_KEY_PREFIX = "upload:task:"; private static final String CHUNK_SET_PREFIX = "upload:chunks:"; @Override public String initUpload(InitUploadRequest request) { String uploadId = UUID.randomUUID().toString().replace("-", ""); String taskKey = TASK_KEY_PREFIX + uploadId; Map<String, String> taskInfo = new HashMap<>(); taskInfo.put("fileName", request.getFileName()); taskInfo.put("fileSize", String.valueOf(request.getFileSize())); taskInfo.put("chunkSize", String.valueOf(request.getChunkSize())); taskInfo.put("fileHash", request.getFileHash()); taskInfo.put("status", "UPLOADING"); redisTemplate.opsForHash().putAll(taskKey, taskInfo); // 设置 24 小时过期,避免僵尸任务占内存 redisTemplate.expire(taskKey, 24, TimeUnit.HOURS); return uploadId; } @Override public void saveChunk(String uploadId, int chunkIndex, String chunkHash, MultipartFile file) { // 校验分片哈希 String actualHash = DigestUtils.md5DigestAsHex(file.getInputStream()); if (!actualHash.equals(chunkHash)) { throw new BizException("分片哈希校验失败"); } // 写入临时目录 Path chunkPath = Paths.get("/data/upload", uploadId, chunkIndex + ".part"); Files.createDirectories(chunkPath.getParent()); file.transferTo(chunkPath.toFile()); // 记录已上传分片 redisTemplate.opsForSet().add(CHUNK_SET_PREFIX + uploadId, String.valueOf(chunkIndex)); } @Override public List<Integer> listUploadedChunks(String uploadId) { Set<String> members = redisTemplate.opsForSet().members(CHUNK_SET_PREFIX + uploadId); if (members == null) { return Collections.emptyList(); } return members.stream().map(Integer::parseInt).sorted().collect(Collectors.toList()); } }

这段代码里,initUpload把任务信息写入 Redis Hash,并设置 24 小时过期。saveChunk先做哈希校验,再把分片写到本地临时目录,最后把分片序号加入 Redis Set。listUploadedChunks直接读 Set 并排序返回。

参数说明:Redis 的过期时间要根据业务调整。如果用户可能隔天继续传,就设 48 小时或更长。临时目录的磁盘空间要监控,合并完成后及时删除分片文件,否则磁盘很快被撑满。另外,file.transferTo在 SpringBoot 中默认使用临时文件,如果分片很大,注意配置spring.servlet.multipart.location指定临时目录。

3.4 合并分片的顺序与校验

合并时,从 Redis Set 拿到所有分片序号,排序后逐个读取文件流,追加写入最终文件。合并完成后,计算最终文件的哈希,与fileHash比对。如果一致,更新任务状态为「已完成」,返回文件访问地址;如果不一致,说明分片有损坏或缺失,需要回滚并让前端重传。

@Override public String mergeChunks(String uploadId) { List<Integer> chunkIndexes = listUploadedChunks(uploadId); Map<Object, Object> taskInfo = redisTemplate.opsForHash().entries(TASK_KEY_PREFIX + uploadId); int totalChunks = (int) Math.ceil( Double.parseDouble((String) taskInfo.get("fileSize")) / Double.parseDouble((String) taskInfo.get("chunkSize")) ); if (chunkIndexes.size() != totalChunks) { throw new BizException("分片不完整,无法合并"); } String fileName = (String) taskInfo.get("fileName"); Path mergedPath = Paths.get("/data/upload/merged", fileName); try (FileOutputStream fos = new FileOutputStream(mergedPath.toFile())) { for (int i = 0; i < totalChunks; i++) { Path chunkPath = Paths.get("/data/upload", uploadId, i + ".part"); Files.copy(chunkPath, fos); } } // 校验最终文件哈希 String finalHash = DigestUtils.md5DigestAsHex(new FileInputStream(mergedPath.toFile())); if (!finalHash.equals(taskInfo.get("fileHash"))) { throw new BizException("文件哈希校验失败,请重传"); } redisTemplate.opsForHash().put(TASK_KEY_PREFIX + uploadId, "status", "COMPLETED"); return "/files/" + fileName; }

合并逻辑里,totalChunks是根据文件总大小和分片大小算出来的。如果已上传分片数不等于总片数,直接拒绝合并。合并完成后做一次全文件哈希校验,这是最后一道防线。注意Files.copy的用法,它按字节流追加,不会覆盖已有内容。如果分片文件很大,可以用BufferedOutputStream包一层提升性能。

合并完成后,记得异步删除临时分片目录,并清理 Redis 中的任务键。如果使用对象存储,合并操作由对象存储的completeMultipartUpload接口完成,服务端只需要调用即可。

4. 避坑与排查:分片上传最常见的五个翻车现场

4.1 分片序号错乱导致合并文件损坏

现象:合并后的文件能打开,但内容错乱,视频花屏,压缩包解压失败。

原因:前端分片时序号从 0 开始,后端合并时按数据库查询结果排序,但数据库默认排序可能不是按chunk_index升序,或者 Redis Set 返回的顺序是随机的。

解决:合并前强制对分片序号排序,用Collections.sort或 SQL 的ORDER BY chunk_index ASC。更稳妥的做法是,合并时按0到totalChunks-1遍历,直接按序号拼路径,而不是依赖集合的返回顺序。

4.2 分片哈希校验失败但文件明明没坏

现象:前端计算的分片哈希和后端算出来的不一致,但分片文件用工具校验又是好的。

原因:前端用SparkMD5对ArrayBuffer计算哈希,后端用DigestUtils.md5DigestAsHex对MultipartFile的InputStream计算。如果前端在分片时用了slice方法,但后端读取时InputStream被提前消费过(比如被日志拦截器读过),哈希就会变。

解决:确保分片文件流只被读取一次。可以在 Controller 方法参数上直接用MultipartFile,不要在拦截器或过滤器中读取请求体。如果必须读,用ContentCachingRequestWrapper缓存。

4.3 上传到 99% 后合并超时

现象:所有分片都传完了,调合并接口时超时,前端报错,但服务端日志显示合并还在跑。

原因:合并大文件时,逐个读取分片并写入最终文件,如果分片数量多(比如 1000 个 5MB 分片),IO 操作耗时可能超过网关或 Nginx 的超时时间。

解决:把合并操作改成异步任务。前端调合并接口后,服务端立即返回「合并中」状态,后台线程池执行合并,前端轮询查询合并进度。或者用对象存储的分片合并接口,由存储层完成合并,服务端只等待回调。

4.4 多实例部署时分片写到不同机器

现象:分片上传成功,但合并时报「分片不存在」。

原因:服务端部署了多个实例,没有做会话粘滞,分片被负载均衡分发到不同机器,本地磁盘上只有部分分片。

解决:分片存储必须共享。要么用对象存储,要么用 NFS 挂载共享目录,要么在 Nginx 层做ip_hash让同一客户端请求落到同一实例。最推荐的是对象存储方案,彻底无状态。

4.5 临时分片文件把磁盘撑满

现象:服务运行一段时间后,磁盘告警,发现/data/upload目录下全是.part文件。

原因:合并完成后没有删除临时分片,或者用户传了一半就放弃,任务过期后分片文件没有清理。

解决:合并成功后立即删除分片目录。同时加一个定时任务,扫描 Redis 中过期的上传任务,删除对应的分片目录。可以用@Scheduled注解,每天凌晨清理一次超过 48 小时未完成的任务。

5. 前端配合与进阶技巧:把上传成功率再提一档

5.1 前端分片与并发控制

前端用File.slice方法切分文件,每片大小与后端约定一致。并发上传时,建议控制在 3 到 5 个并发请求,太多会占满浏览器连接数,反而拖慢速度。可以用Promise池或async库的mapLimit来控制。

async function uploadFile(file, uploadId, chunkSize, uploadedChunks) { const totalChunks = Math.ceil(file.size / chunkSize); const concurrency = 3; const tasks = []; for (let i = 0; i < totalChunks; i++) { if (uploadedChunks.includes(i)) continue; // 跳过已上传分片 const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize); tasks.push({ index: i, chunk }); } // 简易并发控制 const pool = []; for (const task of tasks) { const p = uploadChunk(uploadId, task.index, task.chunk).then(() => { pool.splice(pool.indexOf(p), 1); }); pool.push(p); if (pool.length >= concurrency) { await Promise.race(pool); } } await Promise.all(pool); // 所有分片传完后调合并 await mergeChunks(uploadId); }

这段前端代码里,uploadedChunks是从服务端查到的已上传分片序号列表,循环时直接跳过。concurrency控制并发数,Promise.race保证同时最多只有 3 个请求在飞。分片哈希可以用SparkMD5.ArrayBuffer计算,注意要在slice之后立即计算,避免文件被修改。

5.2 用文件抽样哈希做秒传判断

对整个大文件计算 MD5 非常耗时,2GB 文件可能要几十秒。常见优化是抽样哈希:取文件头 2MB、中间 2MB、尾部 2MB,拼在一起算哈希。这样秒传判断的碰撞概率极低,但计算时间从几十秒降到几百毫秒。服务端存文件时也存这个抽样哈希,查询时直接比对。

5.3 上传进度与失败重试

前端进度条不能只按分片数量算,因为分片大小可能不同。更准确的方式是累加已上传分片的字节数,除以文件总大小。失败重试时,不要整个文件重传,而是重新调/api/upload/chunks查询已上传列表,只补缺失的分片。重试次数建议设 3 次,超过后提示用户检查网络。

我自己的习惯是,在分片上传的每个环节都打上日志:uploadId、分片序号、哈希值、耗时。这样出问题时,直接根据uploadId就能追踪到具体是哪一片、哪一步翻了车。这套机制上线后,我们平台的大文件上传成功率从 70% 出头拉到了 99% 以上,客服工单里再也看不到「传了半天白传了」的抱怨。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 4:09:34

Linux Swap在线扩容全攻略:文件、LVM与分区实操

凌晨两点被监控告警叫起来&#xff0c;登录服务器一看&#xff0c;Swap 使用率 98%&#xff0c;内存也快见底了。这种时候最怕的就是手里没有一套稳妥的“在线扩容”方案——不能重启&#xff0c;不能影响正在跑的业务&#xff0c;最好能在几分钟内把 Swap 空间撑大。这活儿我干…

作者头像 李华
网站建设 2026/10/16 13:48:29

用Python生成器与itertools搭建内存友好的量子计算模拟器

这期是系列里第30篇&#xff0c;聊一个看起来硬核、实际思路却很朴素的组合&#xff1a;用Python的itertools生成器体系&#xff0c;去搭一个量子计算模拟器。很多人学量子计算时第一个念头就是“自己写个模拟器玩玩”&#xff0c;结果一动手就卡在同一个地方——n个量子比特的…

作者头像 李华
网站建设 2026/10/16 16:34:38

计算机网络五层模型考点与学习路线全解析:从教材到DevOps实践

1. 先把教材和学习路线定下来&#xff0c;再谈怎么学 1.1 谢希仁、王道、自顶向下、湖科大教书匠&#xff0c;到底该选哪一套 搜“计算机网络课程”的人&#xff0c;基本分成三类&#xff1a;准备408考研的、期末考前极限突击的、工作之后回来补网络基础的&#xff08;尤其做D…

作者头像 李华
网站建设 2026/10/17 6:13:29

Django认证与权限:从用户模型到行级隔离的完整实践

这几年做 Django 项目&#xff0c;总能看到新人对着auth应用一头雾水&#xff1a;登录注册不是现成的吗&#xff1f;权限不也内置了吗&#xff1f;可真到了自己动手写一个带角色、带数据隔离的业务系统&#xff0c;才发现内置那套东西只是地基&#xff0c;上面该盖什么楼、怎么…

作者头像 李华
网站建设 2026/10/21 6:44:13

屏幕多播实战避坑指南:从pingmuguangbo.rar到稳定UDP多播链路

简介&#xff1a;本资源是一个基于C#开发的局域网屏幕多播与广播工具源码包&#xff0c;面向网络编程初学者、Windows桌面应用开发者及远程教学/协作场景的技术实践者&#xff0c;解决多终端同步接收屏幕图像的实际需求。压缩包共64个文件&#xff0c;含15个核心C#源码&#xf…

作者头像 李华