资讯中心

Spring Boot实现大文件分片上传与断点续传方案

📅 2026/7/5 11:02:57
Spring Boot实现大文件分片上传与断点续传方案
1. 大文件上传的挑战与解决方案在Web应用开发中文件上传是个常见需求但当文件体积达到GB级别时传统的表单上传方式就会暴露出诸多问题。我曾在实际项目中遇到过用户上传2GB视频文件失败的情况这促使我深入研究了大文件上传的完整解决方案。传统上传方式的主要痛点在于网络不稳定导致上传中断后需要重头开始大文件上传耗时过长用户体验差服务器内存压力大容易OOM无法识别重复文件造成存储浪费针对这些问题业界形成了三个核心解决方案文件分片上传 - 将大文件切割成小块逐个上传断点续传 - 记录上传进度中断后可继续秒传机制 - 通过文件指纹识别重复内容2. 技术方案设计与核心组件2.1 整体架构设计我们的解决方案基于Spring Boot构建整体流程如下[客户端] → 文件分片 → MD5计算 → 上传分片 → 合并请求 → [服务端] → 分片接收 → 临时存储 → 合并分片 → 永久存储关键组件包括前端WebUploader或自定义分片逻辑后端Spring MVC 文件处理工具类存储本地磁盘或云存储服务数据库记录上传状态和文件元信息2.2 分片上传原理分片上传的核心思想是将大文件分割成固定大小如5MB的块然后并行或串行上传这些块。这样做的好处是降低单次请求失败的影响范围可以利用多线程加速上传减轻服务器内存压力分片大小的选择需要考虑网络环境移动端建议1-2MBPC端可用5-10MB服务器配置内存和临时存储空间业务需求是否需要支持暂停/继续2.3 断点续传实现断点续传需要三个关键机制分片标识为每个分片生成唯一ID进度记录客户端和服务端同步上传状态校验机制确保分片完整性我们使用Redis来记录上传状态数据结构如下{ fileId: 唯一文件标识, totalSize: 文件总大小, chunkSize: 分片大小, uploaded: [已上传分片列表], md5: 完整文件MD5 }2.4 秒传机制实现秒传基于文件内容指纹实现流程如下客户端计算完整文件MD5发送MD5到服务端查询服务端检查文件库是否存在相同MD5若存在则直接创建引用无需重复上传MD5计算的优化技巧使用SparkMD5等库实现增量计算Web Worker中执行计算避免界面卡顿对大文件采样计算而非全量计算3. Spring Boot后端实现详解3.1 环境准备与依赖首先创建Spring Boot项目添加必要依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcommons-io/groupId artifactIdcommons-io/artifactId version2.11.0/version /dependency /dependencies配置文件上传限制# application.properties spring.servlet.multipart.max-file-size10GB spring.servlet.multipart.max-request-size10GB3.2 核心API设计3.2.1 检查接口秒传实现PostMapping(/check) public ResponseEntityCheckResult checkFile( RequestParam(md5) String md5, RequestParam(filename) String filename) { // 检查文件是否已存在 FileRecord record fileService.findByMd5(md5); if (record ! null) { return ResponseEntity.ok(new CheckResult(true, true)); } // 检查是否有未完成的上传 UploadProgress progress redisService.getProgress(md5); if (progress ! null) { return ResponseEntity.ok(new CheckResult(false, true)); } return ResponseEntity.ok(new CheckResult(false, false)); }3.2.2 分片上传接口PostMapping(/upload) public ResponseEntityString uploadChunk( RequestParam(file) MultipartFile file, RequestParam(chunkNumber) int chunkNumber, RequestParam(chunkSize) int chunkSize, RequestParam(totalChunks) int totalChunks, RequestParam(identifier) String identifier, RequestParam(filename) String filename) { // 存储分片到临时目录 String chunkFilename getChunkFilename(filename, chunkNumber); Path chunkPath Paths.get(tempDir, chunkFilename); try { Files.write(chunkPath, file.getBytes()); // 更新上传进度 redisService.updateProgress(identifier, chunkNumber); return ResponseEntity.ok(Chunk uploaded); } catch (IOException e) { return ResponseEntity.status(500).body(Upload failed); } }3.2.3 合并接口PostMapping(/merge) public ResponseEntityString mergeFile( RequestParam(filename) String filename, RequestParam(identifier) String identifier, RequestParam(totalSize) long totalSize) { // 验证所有分片是否完整 if (!redisService.isUploadComplete(identifier)) { return ResponseEntity.badRequest().body(Missing chunks); } // 合并文件 try { Path destPath Paths.get(uploadDir, filename); FileUtils.mergeFiles(tempDir, destPath, identifier); // 计算完整MD5并保存记录 String md5 DigestUtils.md5Hex(new FileInputStream(destPath.toFile())); fileService.saveRecord(filename, md5, totalSize); // 清理临时文件 FileUtils.cleanTempFiles(tempDir, identifier); return ResponseEntity.ok(Merge completed); } catch (IOException e) { return ResponseEntity.status(500).body(Merge failed); } }3.3 文件工具类实现文件操作的工具方法封装public class FileUtils { // 合并分片文件 public static void mergeFiles(String tempDir, Path destPath, String identifier) throws IOException { try (OutputStream output Files.newOutputStream(destPath, CREATE, APPEND)) { int chunkNumber 1; while (true) { Path chunkPath Paths.get(tempDir, getChunkFilename(identifier, chunkNumber)); if (!Files.exists(chunkPath)) break; Files.copy(chunkPath, output); chunkNumber; } } } // 生成分片文件名 private static String getChunkFilename(String identifier, int chunkNumber) { return identifier . chunkNumber; } // 清理临时文件 public static void cleanTempFiles(String tempDir, String identifier) { // 实现略... } }4. 前端实现关键点4.1 文件分片处理使用File API进行文件分片function createFileChunks(file, chunkSize) { const chunks []; let start 0; let index 0; while (start file.size) { const end Math.min(start chunkSize, file.size); const chunk file.slice(start, end); chunks.push({ chunk, index, start, end }); start end; index; } return chunks; }4.2 MD5计算优化使用Web Worker计算MD5避免阻塞UI// md5-worker.js self.importScripts(spark-md5.min.js); self.onmessage function(e) { const file e.data; const chunkSize 2 * 1024 * 1024; // 2MB const chunks Math.ceil(file.size / chunkSize); const spark new SparkMD5.ArrayBuffer(); const fileReader new FileReader(); let currentChunk 0; fileReader.onload function(e) { spark.append(e.target.result); currentChunk; if (currentChunk chunks) { loadNext(); } else { postMessage(spark.end()); } }; function loadNext() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); fileReader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); };4.3 上传控制逻辑实现带并发控制的上传队列class Uploader { constructor(file, options) { this.file file; this.chunkSize options.chunkSize || 5 * 1024 * 1024; this.concurrent options.concurrent || 3; this.chunks createFileChunks(file, this.chunkSize); this.uploaded new Set(); this.queue []; this.active 0; } async start() { // 先检查文件状态 const { needUpload, exists } await this.checkFile(); if (exists) return 秒传成功; if (!needUpload) return 无需上传; // 恢复已上传的分片 this.loadProgress(); // 开始上传 this.processQueue(); } processQueue() { while (this.active this.concurrent this.queue.length) { const chunk this.queue.shift(); this.uploadChunk(chunk) .finally(() { this.active--; this.processQueue(); }); this.active; } } async uploadChunk(chunk) { if (this.uploaded.has(chunk.index)) return; const formData new FormData(); formData.append(file, chunk.chunk); formData.append(chunkNumber, chunk.index); formData.append(chunkSize, this.chunkSize); formData.append(totalChunks, this.chunks.length); formData.append(identifier, this.fileId); formData.append(filename, this.file.name); try { await axios.post(/upload, formData); this.uploaded.add(chunk.index); this.saveProgress(); } catch (error) { console.error(分片${chunk.index}上传失败, error); this.queue.unshift(chunk); // 重新加入队列 } } }5. 性能优化与问题排查5.1 服务器端优化技巧内存管理使用Stream API处理文件流避免内存中保存完整文件配置合适的JVM堆大小和GC策略对超大文件使用零拷贝技术存储优化临时目录和正式目录使用不同磁盘定期清理过期临时文件对频繁访问的文件启用缓存并发控制限制单个IP的上传并发数实现基于令牌桶的流量控制对重要接口添加限流保护5.2 常见问题与解决方案问题1分片上传后合并失败可能原因分片大小不一致分片顺序错乱磁盘空间不足解决方案合并前验证每个分片的MD5和大小按序号严格排序分片监控磁盘使用情况问题2MD5计算导致浏览器卡死优化方案使用Web Worker后台计算增量计算MD5对大文件使用抽样计算问题3Redis进度丢失容错方案实现双重存储Redis 数据库定时持久化进度信息客户端也缓存进度状态5.3 监控与日志建议添加的监控指标// 上传监控指标 Bean public MeterRegistryCustomizerMeterRegistry metrics() { return registry - { registry.gauge(upload.concurrent, activeUploads); registry.gauge(upload.queue.size, uploadQueueSize); registry.counter(upload.errors, type, io); }; }关键日志记录点分片上传开始/结束合并操作开始/结束异常情况磁盘满、校验失败等秒传触发记录6. 扩展与进阶方案6.1 云存储集成对接OSS/S3的方案调整直接上传分片到云存储使用云服务的分片上传API利用云服务的回调通知机制阿里云OSS示例// 初始化分片上传 InitiateMultipartUploadRequest request new InitiateMultipartUploadRequest(bucketName, objectName); InitiateMultipartUploadResult result ossClient.initiateMultipartUpload(request); String uploadId result.getUploadId(); // 上传分片 UploadPartRequest uploadPartRequest new UploadPartRequest(); uploadPartRequest.setBucketName(bucketName); uploadPartRequest.setKey(objectName); uploadPartRequest.setUploadId(uploadId); uploadPartRequest.setPartNumber(partNumber); uploadPartRequest.setInputStream(partInputStream); UploadPartResult uploadPartResult ossClient.uploadPart(uploadPartRequest); partETags.add(uploadPartResult.getPartETag()); // 完成上传 CompleteMultipartUploadRequest completeRequest new CompleteMultipartUploadRequest( bucketName, objectName, uploadId, partETags); ossClient.completeMultipartUpload(completeRequest);6.2 分布式方案当单机存储不够时可以考虑使用分布式文件系统HDFS、Ceph实现基于一致性哈希的分片存储引入消息队列解耦上传和处理6.3 安全增强内容安全病毒扫描集成敏感内容检测文件类型校验权限控制签名URL上传时效性令牌细粒度ACL数据安全上传加密存储加密传输加密在实际项目中我遇到过一个典型的性能问题当同时有数百个用户上传大文件时服务器磁盘IO成为瓶颈。通过将临时目录挂载到RAM磁盘并将最终存储改为分布式文件系统性能提升了3倍以上。另一个经验是MD5计算可能成为性能热点特别是当客户端上传大量小文件时后来我们改为只在文件大于10MB时才计算完整MD5小文件则使用更轻量的校验方式。