简介:这份资源面向需要在 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% 以上,客服工单里再也看不到「传了半天白传了」的抱怨。希望帮到你。
本文还有配套的精品资源,点击获取