网页端做文件夹上传,表面看是个“选个目录、把文件传上去”的小功能,真正落地才发现要处理三层事:一是把目录结构原样还原,二是大文件拆成小块逐个传,三是断点续传和失败重试。尤其用Java做后端时,稍不注意就会在请求体大小、临时文件管理、合并校验这些地方翻车。这篇文章就把我在实际项目里从零实现文件夹分块上传的全过程拆开讲,从方案设计、前端目录遍历、分块策略,到Spring Boot后端的分块接收、合并还原、断点续传和并发一致性,都过一遍。
1. 文件夹上传的真正难点:不是“上传”,而是“还原”和“限流”
1.1 文件夹上传不等于多个文件上传的简单叠加
很多人一听到“文件夹上传”,第一反应是前端把目录里所有文件拿出来,用Input的多文件选择一次性Post出去。这个思路一半对,一半错。文件夹上传最核心的隐藏需求是目录层级不能丢。用户选中一个多级嵌套的文件夹,传到服务器之后,我希望在服务端看到的还是同样的目录树,而不是几百个文件平铺在同一个目录里。
HTML的<input type="file">默认只能选单个文件,加上multiple只能多选但选不了文件夹。要让用户直接选整个文件夹,需要用到webkitdirectory属性,或者浏览器较新的File System Access API。前者兼容性好,Chrome、Edge、Firefox都支持,返回的文件列表里每个File对象都有一个webkitRelativePath属性,它会告诉你这个文件相对于所选目录的完整路径,比如docs/guide/chapter1.md。这个相对路径就是还原目录结构的关键。
所以先纠正一个认知:文件夹上传的本质,是把一棵目录树“压平”成带相对路径信息的文件列表,传输到服务端后再按相对路径“还原”这棵树。HTTP协议本身没有目录树概念,靠的就是路径字符串。
1.2 为什么非得分块:请求体限制、内存与失败重试
既然要上传文件夹,里面很可能有大文件,比如视频、压缩包、数据集。这时候如果每个文件还是整体一次性上传,会撞上几个硬问题。
第一是服务器和网关的请求体大小限制。传统multipart/form-data上传时,Tomcat默认限制请求体大小(maxPostSize),Nginx的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 type="file" webkitdirectory id="folderInput" />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触发合并。
| 字段名 | 类型 | 说明 |
|---|---|---|
| fileMd5 | String | 整个文件的MD5 |
| relativePath | String | 文件在原始文件夹中的相对路径 |
| chunkIndex | Integer | 当前分块序号,从0开始 |
| totalChunks | Integer | 总分块数 |
| chunkSize | Integer | 理论分块大小(字节) |
| fileSize | Long | 整个文件大小 |
| totalSize | Long | 这个文件所在的整个任务的总大小(可选) |
| file | MultipartFile | 分块二进制内容 |
这里我建议把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. 校验分块数量 List<Integer> 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); List<Integer> uploadedChunks = new ArrayList<>(); if (Files.exists(tmpDir)) { try (Stream<Path> 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 ConcurrentHashMap<String, 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%”比光秃秃一个总进度条踏实很多。这也是产品体验层面很容易被忽略但值得做的一点。