Java Spring Boot实现文件夹分块上传:目录还原与断点续传全解析
发布时间:2026/10/1 12:44:26来源:尧图网络
网页端做文件夹上传表面看是个“选个目录、把文件传上去”的小功能真正落地才发现要处理三层事一是把目录结构原样还原二是大文件拆成小块逐个传三是断点续传和失败重试。尤其用Java做后端时稍不注意就会在请求体大小、临时文件管理、合并校验这些地方翻车。这篇文章就把我在实际项目里从零实现文件夹分块上传的全过程拆开讲从方案设计、前端目录遍历、分块策略到Spring Boot后端的分块接收、合并还原、断点续传和并发一致性都过一遍。1. 文件夹上传的真正难点不是“上传”而是“还原”和“限流”1.1 文件夹上传不等于多个文件上传的简单叠加很多人一听到“文件夹上传”第一反应是前端把目录里所有文件拿出来用Input的多文件选择一次性Post出去。这个思路一半对一半错。文件夹上传最核心的隐藏需求是目录层级不能丢。用户选中一个多级嵌套的文件夹传到服务器之后我希望在服务端看到的还是同样的目录树而不是几百个文件平铺在同一个目录里。HTML的input typefile默认只能选单个文件加上multiple只能多选但选不了文件夹。要让用户直接选整个文件夹需要用到webkitdirectory属性或者浏览器较新的File System Access API。前者兼容性好Chrome、Edge、Firefox都支持返回的文件列表里每个File对象都有一个webkitRelativePath属性它会告诉你这个文件相对于所选目录的完整路径比如docs/guide/chapter1.md。这个相对路径就是还原目录结构的关键。所以先纠正一个认知文件夹上传的本质是把一棵目录树“压平”成带相对路径信息的文件列表传输到服务端后再按相对路径“还原”这棵树。HTTP协议本身没有目录树概念靠的就是路径字符串。1.2 为什么非得分块请求体限制、内存与失败重试既然要上传文件夹里面很可能有大文件比如视频、压缩包、数据集。这时候如果每个文件还是整体一次性上传会撞上几个硬问题。第一是服务器和网关的请求体大小限制。传统multipart/form-data上传时Tomcat默认限制请求体大小maxPostSizeNginx的client_max_body_size默认只有1MB左右就算调大单个大文件请求一旦失败整包数据全要重传代价非常大。第二是内存问题。Spring Boot接收MultipartFile时超出阈值默认约2MB会把临时文件写到磁盘但如果并发上传的大文件太多会产生大量临时文件和频繁的IO抖动。而前端如果用一个FormData把所有文件一次性放进请求体浏览器会先把整个请求体构建出来内存开销完全失控几GB的文件夹直接卡死页面。第三是断点续传和进度展示。文件大、网络不稳的情况下全量上传失败一次就得从头再来用户体验极差。分块之后每块1~10MB大小独立上传哪块失败重传哪块进度也能按“已传字节数/总字节数”精确计算。所以结论很明确文件夹上传 目录结构保留 文件粒度分块 分块级并发控制 服务端分块合并校验。下面按链路一步步拆。2. 前端侧目录遍历与分块策略把树压平成“相对路径分片”2.1 选目录的两种方式对比目前浏览器里选文件夹有两种主流做法input[webkitdirectory]兼容性最好Chrome、Edge、Firefox都支持。用户选完目录后通过input.files拿到所有文件的FileList每个文件带webkitRelativePath。缺点是拿不到不含文件的空目录因为浏览器只返回实际文件的File对象不过绝大多数场景能接受。window.showDirectoryPicker()File System Access API代码更现代返回的是一个FileSystemDirectoryHandle可以递归遍历目录树。它能感知目录结构、支持权限请求但兼容性不如webkitdirectory而且需要用户手势触发。我在实际项目里倾向用webkitdirectory做基础版本结构简单、风险小。input typefile webkitdirectory idfolderInput /document.getElementById(folderInput).addEventListener(change, (e) { const files Array.from(e.target.files); for (const file of files) { console.log(file.webkitRelativePath); // 例如: assets/images/logo.png console.log(file.size); } });2.2 元数据结构设计相对路径是核心字段前端拿到文件列表后需要为每个文件补齐元数据。传给后端的结构大致长这样interface UploadFileMeta { // 相对路径目录还原的唯一依据 relativePath: string; // assets/images/logo.png // 文件唯一标识建议用整个文件的MD5 fileMd5: string; // 文件总大小 totalSize: number; // 当前分块信息 chunkIndex: number; // 第几块从0开始 totalChunks: number; // 总块数 chunkSize: number; // 每块大小字节 // 当前块数据 chunkData: Blob; // 业务字段按需扩展 taskId?: string; }这里最关键的是relativePath。后端合并时只需要根据它来创建父目录就能把目录树还原出来。fileMd5建议用整个文件算而不是每个分块单独算因为整个文件的MD5既用于校验文件完整性也用于做全局秒传和分块归属标识。计算MD5时用spark-md5这类增量库可以边读文件边算避免把一个大文件整体读进内存。2.3 分块切割Blob.slice 的正确用法浏览器端的Blob.slice(start, end)可以直接切出文件的某一段返回一个新的Blob对象。这一步不需要把数据读成ArrayBuffer再切slice底层是懒拷贝内存占用很小。const CHUNK_SIZE 5 * 1024 * 1024; // 5MB const totalChunks Math.ceil(file.size / CHUNK_SIZE); for (let i 0; i totalChunks; i) { const start i * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); const chunkBlob file.slice(start, end); // 把chunkBlob塞进FormData通过XMLHttpRequest或fetch上传 }切块之前优先用Web Worker计算整个文件的MD5避免主线程卡顿。计算完MD5后把formData里的字段配好逐块上传即可。注意一个容易被忽略的细节最后一块大小几乎必然小于CHUNK_SIZE后端在保存分块时不要用固定大小校验要用“当前块实际字节数”校验否则最后一块永远会失败。2.4 并发控制与失败重试别让所有分块同时冲上去文件夹里可能有几十个文件每个文件又被切成若干分块。如果不做并发控制浏览器会一次性发起几百个请求服务端线程池直接被打满连接也扛不住。实际做法是全局维护一个并发队列限制同时进行的上传数在3~5个左右。我这里用了一个简化但够用的方案定义一个“任务池”每次从队列里取一个分块任务执行执行完再取下一个始终保持N个并发在途。class LimitedUploader { constructor(limit 3) { this.limit limit; this.active 0; this.queue []; } add(task) { return new Promise((resolve, reject) { this.queue.push({ task, resolve, reject }); this._next(); }); } _next() { while (this.active this.limit this.queue.length) { const { task, resolve, reject } this.queue.shift(); this.active; task() .then(resolve) .catch(reject) .finally(() { this.active--; this._next(); }); } } } // 使用示例 const uploader new LimitedUploader(3); for (const chunkInfo of allChunks) { uploader.add(() uploadSingleChunk(chunkInfo)); }重试逻辑也很简单单块失败最多重试3次每次间隔指数退避比如1秒、2秒、4秒重试仍失败则终止该文件的上传标记为失败并返回可重入状态让用户点“重试”时从失败块继续。3. 后端Spring Boot接口设计分块接收、临时存储与安全校验3.1 分块上传的统一协议定义后端这边我一般设计三个接口POST /upload/chunk接收单个分块、GET /upload/check查询已上传分块、POST /upload/merge触发合并。字段名类型说明fileMd5String整个文件的MD5relativePathString文件在原始文件夹中的相对路径chunkIndexInteger当前分块序号从0开始totalChunksInteger总分块数chunkSizeInteger理论分块大小字节fileSizeLong整个文件大小totalSizeLong这个文件所在的整个任务的总大小可选fileMultipartFile分块二进制内容这里我建议把relativePath放进每一个分块请求里而不是只在merge时传一次。原因很简单分块上传过程是无状态的服务端可能在任何时候重启临时文件只依赖fileMd5存放每次分块带上relativePath可以顺便在服务端冗余一份元数据排查问题时很有用。3.2 分块存储的临时目录规划服务端收到分块后不能直接把块写成一个文件而是统一放到一个临时目录下。我的推荐结构是/upload-tmp/{fileMd5}/{chunkIndex}以文件MD5作为一级目录好处非常直观不同请求上传同一个文件时分块天然落在同一个目录里方便合并。断点续传时扫目录就能知道哪些块已就位。秒传时直接判断整个fileMd5目录是否存在或者记录表里是否有完成标记。保存分块的Controller逻辑大致如下PostMapping(/upload/chunk) public ResponseEntity? uploadChunk( RequestParam(fileMd5) String fileMd5, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(totalChunks) Integer totalChunks, RequestParam(relativePath) String relativePath, RequestParam(file) MultipartFile file) throws IOException { // 1. 参数校验 if (chunkIndex 0 || chunkIndex totalChunks) { return ResponseEntity.badRequest().body(分块序号越界); } // 2. 临时目录tmp/{fileMd5}/ Path tmpDir Paths.get(uploadTmpRoot, fileMd5); Files.createDirectories(tmpDir); // 3. 写入分块文件 Path chunkFile tmpDir.resolve(chunk_ chunkIndex); file.transferTo(chunkFile.toFile()); // 4. 记录元数据方便后续合并 saveChunkMeta(fileMd5, chunkIndex, relativePath, totalChunks, file.getSize()); return ResponseEntity.ok().body(Map.of(success, true)); }MultipartFile.transferTo()方法是Spring封装好的内部会处理临时文件生命周期比自己写FileOutputStream要省心一些但要注意目标文件如果已存在需要决定是覆盖还是幂等跳过。断点续传场景下建议已存在直接返回成功不做覆盖避免浪费IO。3.3 合并与目录结构还原所有分块上传完后前端调用POST /upload/merge。后端要做的事检查分块是否齐全数量够不够、大小是否匹配。按chunkIndex升序遍历把每个分块的数据依次写入目标文件。根据relativePath解析父目录并确保目录不存在路径穿越等安全问题。合并完成后重算文件MD5与前端传来的一致一致性校验通过才算真正完成。合并的核心代码PostMapping(/upload/merge) public ResponseEntity? merge(RequestBody MergeRequest req) throws IOException { String fileMd5 req.getFileMd5(); Path tmpDir Paths.get(uploadTmpRoot, fileMd5); // 1. 校验分块数量 ListInteger chunks listChunkIndexes(tmpDir); if (chunks.size() ! req.getTotalChunks()) { return ResponseEntity.status(500) .body(Map.of(message, 分块不完整缺少: findMissingChunks(chunks, req.getTotalChunks()))); } // 2. 安全解析目标相对路径 Path safeRelative sanitizeRelativePath(req.getRelativePath()); Path targetRoot Paths.get(uploadRoot); // 业务根目录 Path targetFile targetRoot.resolve(safeRelative).normalize(); // 3. 确保父目录存在 Files.createDirectories(targetFile.getParent()); // 4. 按顺序合并 try (BufferedOutputStream bos new BufferedOutputStream(Files.newOutputStream(targetFile))) { for (int i 0; i req.getTotalChunks(); i) { try (InputStream is Files.newInputStream(tmpDir.resolve(chunk_ i))) { is.transferTo(bos); } } } // 5. 合并后的整体校验 String mergedMd5 computeFileMd5(targetFile); if (!fileMd5.equalsIgnoreCase(mergedMd5)) { Files.deleteIfExists(targetFile); return ResponseEntity.status(500).body(Map.of(message, 合并后文件MD5不匹配)); } // 6. 清除临时目录 deleteDirectory(tmpDir); return ResponseEntity.ok().body(Map.of(success, true, path, safeRelative.toString())); }有几个细节容易踩坑。第一分块文件必须是按chunkIndex顺序写的不能依赖上传到达顺序所以合并时老老实实按索引遍历读取。第二transferTo一次性把InputStream写到OutputStream是Java 9之后的InputStream方法逻辑清晰底层用缓冲性能足够。第三合并完成后必须清临时目录否则磁盘会被垃圾占满。3.4 秒传与断点续传的实现思路几乎每次聊分块上传都逃不开这两个概念它们其实是两层东西断点续传一个文件传了一半网络断了或者用户关闭了页面下次继续从没传完的块开始。秒传这个文件在服务端已经有了别人传过或者同一个人传过根本不需要重新传分块直接返回“已完成”。GET /upload/check接口就是为了支持这两点而设计的。前端在上传前先带fileMd5请求一次后端返回{ exists: false, uploadedChunks: [0, 1, 2, 5], totalChunks: 10 }前端拿到uploadedChunks把已上传的块跳过只传剩下的。如果exists为true直接跳过整个文件。这比对每个文件重新挨个上传省的时间不是一点半点尤其文件夹里可能有几十个相同依赖包的情况。GetMapping(/upload/check) public ResponseEntity? check(RequestParam(fileMd5) String fileMd5, RequestParam(totalChunks) Integer totalChunks) { Path tmpDir Paths.get(uploadTmpRoot, fileMd5); boolean exists checkIfFileAlreadyUploaded(fileMd5); ListInteger uploadedChunks new ArrayList(); if (Files.exists(tmpDir)) { try (StreamPath walk Files.list(tmpDir)) { uploadedChunks walk .map(p - p.getFileName().toString().replace(chunk_, )) .map(Integer::parseInt) .sorted() .collect(Collectors.toList()); } } return ResponseEntity.ok().body(Map.of( exists, exists, uploadedChunks, uploadedChunks, totalChunks, totalChunks )); }4. 合并过程中的并发陷阱与数据一致性不要等丢块了才后悔4.1 路径穿越与服务端目录白名单relativePath如果直接拿来拼路径很容被刁钻的请求利用比如传../../etc/passwd或者Windows风格的..\..\。后端必须对相对路径做安全解析。最稳妥的做法是先把路径规范化再判断它是否仍然位于允许的根目录之内private Path sanitizeRelativePath(String relativePath) throws IOException { Path base Paths.get(uploadRoot).toAbsolutePath().normalize(); Path resolved base.resolve(relativePath).normalize(); if (!resolved.startsWith(base)) { throw new SecurityException(非法路径: relativePath); } return base.relativize(resolved); }另外Windows和Linux的文件名称合法性不同建议把文件名里的/\:*?|统一替换成下划线避免某些用户从Windows上传的文件在Linux服务端创建目录失败。4.2 同一个文件的并发合并请求怎么防真实场景中前端可能因为网络重试、用户双击“重试按钮”等原因同时发出两个merge请求。如果不做控制两个请求同时读临时分块、同时写目标文件轻则文件内容错乱重则抛FileAlreadyExistsException。单机部署时最简单的方案是用ConcurrentHashMap维护一个fileMd5 - Lock的锁映射private final ConcurrentHashMapString, Object mergeLocks new ConcurrentHashMap(); PostMapping(/upload/merge) public ResponseEntity? merge(RequestBody MergeRequest req) throws IOException { Object lock mergeLocks.computeIfAbsent(req.getFileMd5(), k - new Object()); synchronized (lock) { // 合并逻辑 } }如果是多实例部署本地锁就不够了得用Redis分布式锁或者在数据库里做唯一约束。我在项目里习惯用upload_task表给file_md5加唯一索引合并完成后更新状态字段秒传直接查状态既支持幂等又天然防并发比单纯加锁更可控。4.3 分块完整性校验与异常兜底合并之前必须校验分块是否完整。这里有两个层面数量完整实际存在的分块个数等于totalChunks。大小完整分块大小之和等于fileSize。只检查数量不查大小可能遇到前端代码bug导致某些块长度为0也保存成功了。所以我保存分块时会记录真实字节数合并前把所有分块大小加起来与fileSize比对不一致直接返回“分块损坏”。合并是IO密集操作中途如果进程崩溃会留下一个写了一半的目标文件。解决思路是目标文件先写到tmp目录合并成功后再原子移动到最终路径Files.move(..., ATOMIC_MOVE)这样最终目录里永远只出现完整文件。移动前顺手把临时块目录删掉避免垃圾堆积。4.4 实际生产中常见的三个“闷亏”这里说几个我在真实环境里踩过的坑都是文档里不会写的。第一个是分块磁盘占用。一个10GB的文件夹断点续传传了一半/upload-tmp/{fileMd5}/下可能积累好几GB的临时块。如果没有定时清理任务比如用户传了一半就放弃这个目录永远不会自己消失。最好加一个每日定时任务清理超过48小时未更新的分块目录。第二个是前端超时时间。很多人用axios上传大文件时会遇到“上传到一半就报网络异常”原因是默认超时时间太短。分块上传虽然单块小但网络波动时队列里可能堆积等待整个调度过程不能设置应用层超时。上传请求建议设置timeout: 0不超时由前端业务重试机制来控制。第三个是GC暂停和内存抖动。虽然分块用Blob.slice是懒拷贝但FormData发送时浏览器仍可能为每块创建独立的内存缓冲区并发数开得太大比如10个并发同时上传浏览器内存会明显上涨。对小内存机器来说并发3和并发5体验差距很大这个值最好在真机上测而不是拍脑袋定。5. 参数怎么定更合适分块大小、并发数、超时与网络场景5.1 分块大小太小和太大都是坑分块大小的选择是一场权衡。块太小比如512KB单块失败重传成本低但请求数量指数级上升HTTP握手、服务端解析multipart的开销会被放大服务端QPS压力大。块太大比如50MB每块失败重传的代价又太高弱网下成功率直线下降。我常用的区间是2MB~10MB。在一个普通内网环境下5MB是比较稳的起点。如果是公网弱网环境比如用户用4G访问建议降到2MB。如果是内网千兆10MB也可以但要注意Nginx的client_max_body_size如果设置的是默认1MB请求体是会被直接拒绝的。分块大小典型场景优势劣势512KB~1MB弱网、移动端单块重传成本极低请求数量多服务端压力大2MB~5MB常规公网/混合网络平衡性好成功率较高无明显短板10MB~20MB内网、高速光纤请求数少合并快弱网重传成本高5.2 并发数不是越大越快前端并发数我建议控制在3~5个。浏览器的HTTP/1.1对同一域名的并发连接数本身就有限制Chrome一般是6个左右就算你开10个并发底层依然会被排队。更重要的是服务端的线程池和数据库连接数有限文件夹上传通常伴随大量小文件单用户并发太高会影响其他用户的正常访问。另外如果要做“动态并发”也不是不行比如根据当前网速自动调整并发数但复杂度上升明显个人建议MVP阶段固定3个并发性能已经足够。真正卡脖子的大概率不是并发数而是MD5计算和磁盘IO。5.3 临时目录与磁盘清理策略临时目录不能放在系统盘最好独立挂载一个数据盘并且每天凌晨跑一次清理任务分块目录创建时间超过24小时且文件未完成合并直接删除。合并成功但目录未删干净的兜底删除。定期统计临时目录总大小超过阈值弹告警。我习惯用Spring的Scheduled定时任务做这个清理配合日志记录删除量磁盘异常可以第一时间发现。不要觉得这是小事文件夹上传功能上线后临时目录吞掉几十GB磁盘是很容易发生的事。6. 面试官视角分块上传项目在简历和面试中怎么讲6.1 分块上传的核心考点这个功能在简历上算是个不错的亮点但面试官通常会围绕几个点深挖你要能讲清楚原理而不是报菜名。第一个必问为什么用分块而不是整体上传回答核心是突破请求体大小限制、降低内存占用、支持断点续传三个点缺一不可。第二个必问断点续传和秒传的区别秒传基于整个文件MD5服务端已经有完整文件就跳过断点续传基于分块索引只重传缺失的块。第三个必问如何保证合并后的文件跟源文件一致回答链路是前端整文件MD5 分块大小总和校验 服务端合并后重算MD5比对。这三步都过了一致性问题基本锁死。第四个必问多个用户同时传同一个文件、同一文件并发合并怎么办用数据库唯一索引做幂等用Redis分布式锁做互斥不能只靠同步关键字。6.2 一个更完整的高可用方案怎么设计如果项目是分布式部署临时目录就不能只存在单机磁盘上否则合并请求落地到另一台机器就找不到分块了。思路有两种把临时分块目录放到共享存储上比如NAS、MinIO、NFS所有实例访问同一个路径。或者分块上传时不落本地磁盘直接传对象存储比如MinIO的分段上传接口由对象存储负责分段重组服务端只做元数据管理。第二种更省事但脱离了我们自己实现分块合并的核心逻辑。如果面试官问“多节点怎么办”说清楚共享临时目录 Redis锁 任务表状态就已经能体现设计能力了。6.3 任务表的设计可以顺带讲一下给上传任务建一张表字段大致是task_id、file_md5、file_name、relative_path、total_chunks、uploaded_chunks、total_size、status、create_time、update_time。通过这张表可以很方便地支持前端秒传判断查status是否为DONE。断点续传查询查uploaded_chunks集合。定时清理垃圾任务update_time超过N小时且status非完成。统计上传量、成功率给产品看数据。面试讲到这一步比单讲接口要立体很多。7. 最后再分享几个实操小技巧这套方案我在项目里跑了大半年整体稳定但也有一些细节只有真正用起来才会注意到。上传大文件夹时前端MD5计算是个瓶颈。文件夹里如果有几十个GB级别的大文件Spark MD5在主线程跑会卡死页面务必用Web Worker。而且MD5计算是IO密集型的一个个文件串行算会很慢可以用Worker池并行算但注意控制并发Worker数量太多反而会拖慢整体。合并时不要用Files.copy复制每个分块到临时文件再合并直接按索引流式拼接效率最高。实测下来5MB分块合并1GB文件用BufferedOutputStream加InputStream.transferTo耗时比逐块Files.copy少一半左右。关于前后端的协议字段一定要在一开始就定清楚尤其是chunkIndex从0开始还是从1开始、totalChunks是分块总数还是最大索引这类边界问题最容易出bug。我见过团队里两套代码一个从0一个从1结果最后一块总是传不上去排查了整整一个下午。最后关于进度展示文件夹上传和单文件上传的进度逻辑不一样。文件夹维度建议按文件维度展示已完成文件数/总文件数 当前文件的分块进度而不只是一个总百分比。用户看到“正在上传第12/48个文件当前文件45%”比光秃秃一个总进度条踏实很多。这也是产品体验层面很容易被忽略但值得做的一点。
网站建设高端定制企业官网