1. 这需求一出现,先别急着写代码
我处理过很多次这个场景——手头一个开源 Java 上传插件(通常是个 Spring Boot 项目里的上传中间件,或者前端组件加后端示例代码的整合),业务那边扔过来一个需求:用户要在网页端批量上传 PDF 文件,这些 PDF 还得按部门、项目、日期之类的目录分类存放,文件还不小,动不动几十 MB 上百 MB,网络环境又没那么理想。
于是"分块秒传"这四个字就来了。
表面上需求很简单:大文件不卡死、传到一半断了能继续、重复文件不用重复传。但真下手你就会发现,这三个需求背后牵着一连串问题:浏览器怎么把文件切开?后端怎么知道哪些块已经到了?合并时顺序会不会乱?目录结构怎么保留才能让服务端重建出同样的文件夹?这些问题如果一开始没想清楚,写出来的代码大概率只能过 demo,一上生产就露馅。
这篇文章我会从需求拆解、方案选型、核心代码实现、踩坑记录这几个角度,把"在 Java 上传插件里扩展浏览器端 PDF 目录结构分块秒传"这件事完整过一遍。适合正在做文件上传模块、或者打算改造手头开源上传组件的同学参考。哪怕你不做 PDF,这套逻辑对大视频、大压缩包同样适用——PDF 在这里只是最常见的业务载体。
先提醒一句:下文所有方案都围绕"扩展已经有基础上传能力的开源 Java 插件"这个前提展开。如果你手头连个能跑通的 Servlet 或 Spring Boot 上传接口都没有,建议先把基础的单文件上传、保存、回显链路跑顺,再来看分块和秒传,否则一步跨太大,排查问题会非常痛苦。
2. 别被"分块秒传"四个字忽悠了,先拆需求
2.1 分块上传的本质:把"一次大请求"拆成"多次小请求"
分块上传,通俗点说就是把一个大文件用前端 JS 切成若干小块,每次只上传一小块,后端收到所有小块后,再按顺序拼成完整文件。
听起来简单,但这里有个非常关键的设计点:文件是被谁切开的?答案必须是浏览器端。后端只收到一个个小块,它并不知道完整文件长什么样。因此后端需要有一套"会话"机制来记录:哪个文件、总大小多少、分了多少块、现在收到了哪几块。
多数开源 Java 上传插件已经封装了基本的分块接口,但问题往往出在"顺序"上。浏览器并发上传多个块时,块到达后端的顺序是乱序的,如果插件按"先到先写"直接把块追加到文件尾部,最后合并出来的文件一定是坏的。正确做法是:每个块根据它在文件中的偏移量落盘到独立的位置,合并时再按偏移量排序拼接。这个偏移量,前端可以从File.slice(start, end)里算出来,后端则通过参数接收。
分块大小的选择也是个经验值。我见过很多人拍脑袋定 5 MB,其实没那么简单。分块越小,断点续传的粒度越细,但请求次数也越多,每块的网络往返开销越大;分块太大,断点重传时浪费的数据量也大。我个人的习惯是:默认 2 MB,特别差的网络环境可以调到 1 MB,内网环境用 5 MB 也没问题。更重要的是,这个值最好做成前端可配置、后端可校验,别写死在代码里。
2.2 秒传不是"秒传",是"不用传"
秒传这个叫法很容易让人误解。它并不是把文件传输速度变快了,而是利用指纹判断"我已经有这个文件了",然后直接跳过实际上传。原理一句话就能说清:前端计算文件的 MD5(或其他哈希),传给后端,后端正查数据库发现这个哈希已经有记录,说明文件已经在服务器上了,那就不传了,直接返回"上传成功"。
但这里有个容易踩的坑:整个文件算 MD5 很慢。一个几百 MB 的 PDF,在前端用纯 JS 算完整 MD5 可能要几十秒,那等于没秒。所以行业里常用的思路是"秒传判定用全量哈希,分块校验用增量哈希":
- 前端首次上传时花一点时间算全文件 MD5,作为秒传判定的主键。
- 分块上传时每块传完可以额外带一个块 MD5,后端校验块完整性;所有块合并成完整文件后,后端再算一次全量 MD5 对比,避免前面拼接出错。
也有人在用"先算文件大小 + 头部、尾部、中间几段的抽样哈希"来加速秒传判定,准确率在多数场景足够,但不是绝对可靠。个人建议:别在文件完整性的事上耍聪明,全量 MD5 虽然慢一点,但它是正经的兜底方案。你可以用 Web Worker 在后台线程算,页面也不会卡。
2.3 目录结构要保留到什么程度,必须提前问清楚
"上传一个 PDF 目录结构"这个描述其实有歧义。第一个歧义是:用户是想上传文件夹里的所有 PDF 文件,并保留文件夹层级,还是只是想把多个 PDF 文件打包成一个虚拟目录传上去,服务端按文件名平铺保存?第二个歧义是:如果是多级目录,哪些空文件夹需要保留?第三个歧义:同名文件在不同目录里怎么处理,允许还是覆盖?
这些细节必须在动手前和需求方确认清楚,否则代码写完会发现各种边界情况处理不过来。从技术角度看,浏览器端的webkitRelativePath属性,也就是用<input type="file" webkitdirectory>选择文件夹时,每个文件自带的相对路径,可以直接拿到"部门/项目/文件名.pdf"这样的字符串,传到后端当作落盘目录层级即可。但路径安全必须注意:绝不能直接用前端传的路径拼接文件系统路径,一定要做过滤,防止..之类的路径穿越。
对于空目录,浏览器端的文件列表天然不会包含目录本身,所以"保留空目录"需要额外用DataTransferItem或 File System Access API 才能遍历出来,兼容性和复杂度都上一个台阶。多数业务里空目录并不重要,我建议先明确"不保留空目录",把范围缩小到"文件所在的目录结构必须还原",这能大大降低实现成本。
2.4 浏览器端的"文件"和"目录"到底长什么样
把浏览器端的能力边界理清楚,后面写代码才不踩坑。浏览器网页里选择文件夹有三个常见入口:
<input type="file" multiple> <input type="file" webkitdirectory> <input type="file" webkitdirectory multiple>第三种配合multiple时,File对象身上会带webkitRelativePath属性,比如2025年归档/合同/采购合同A.pdf。这是目录结构还原的关键信息来源。
文件读取方面,File继承自Blob,所以可以直接用blob.slice(start, end)切出字节片段。这里有个容易被忽略的细节:slice()返回的是新 Blob,不会修改原始文件,所以切片是安全的。上传时用FormData把切片 Blob 传上去就行,也可以直接用XMLHttpRequest传 ArrayBuffer,但 FormData 对 Spring Boot 的MultipartFile支持最友好,推荐优先用 FormData。
另外要提醒的是,大文件算 MD5 的过程非常吃内存和时间,建议用 Web Worker 单独开线程处理,避免主线程卡顿,让用户以为页面死了。稍后代码部分我会给出一版可用的思路。
3. 方案选型:为什么要"扩展"而不是"重写"
3.1 开源 Java 插件一般长什么样
说到"开源 Java 插件",大家手头的东西其实五花八门,但大体可以分两类。一类是纯后端组件,比如基于 Servlet 3.0 / Spring Boot 的上传中间件,只负责接收MultipartFile,提供保存、限流、权限校验等能力;另一类是前后端一体化的上传组件,比如基于 WebUploader / Plupload 封装的前端控件,配上一套 Java 后端的官方示例模块。多数"扩展"需求发生在第二类——前端已经能选文件、能传了,但缺分块、缺秒传、缺目录结构还原。
所以第一步是搞清楚你手里的插件的扩展点到底在哪。我习惯先看几个关键地方:
- 后端有没有接收
chunkIndex、chunkTotal、fileMd5这类参数的接口,有的话说明它预留了分块的扩展位。 - 前端有没有暴露上传回调,比如
uploadBeforeSend、uploadSuccess、uploadError,没有的话你需要改它的源码,风险会大一些。 - 合并逻辑是"边收边写"还是"收完再合",后者更好做校验,前者性能更好但出错概率高。
3.2 前端选型的对比
如果插件里的前端不能直接复用,或者你决定自己搭前端部分,那么常见的几个方案我也列一下,大家按实际项目情况选:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 基于 WebUploader 二次开发 | 中文文档多、分块秒传概念都有现成回调 | 项目已停止更新多年,浏览器兼容遗留问题多 | 老项目、快速交付 |
| 基于 Plupload 二次开发 | 功能全,支持多运行时(HTML5/Flash) | 配置项多,上手成本偏高 | 需要兼容旧浏览器的企业项目 |
| 自研:原生 XHR + FormData | 完全可控,零依赖 | 所有分块秒传合并逻辑都得自己写 | 新项目、主流程不复杂 |
说句实在话,如果需求只是"PDF 目录结构 + 分块 + 秒传",而且你主浏览器是 Chrome / Edge 这类现代浏览器,我倾向于自研前端上传逻辑,配合手头的 Java 插件做后端扩展。原因很简单:WebUploader 这类库虽然好用,但它的目录处理和现代 File API 的契合度一般,二次开发往往还不如自己写两百行 JS 来得直接。
3.3 后端扩展选型
后端如果你用的是 Spring Boot,那插件或组件的扩展就方便很多。Spring 的MultipartFile本身就是对上传文件的封装,分块上传时每个小块都是独立的MultipartFile,只是文件名和业务参数需要我们自己定义。我推荐在整合点上下功夫:
- 写一个
UploadChunkController,负责接收分块请求并落盘到临时目录。 - 写一个
FileMergeService,负责检查分块齐全并按偏移量合并。 - 在合并完成后调用
FileHashService算全量哈希,更新"文件指纹表"。
这一层的设计并不复杂,但要把"临时分块存储区""合并后的正式存储区""指纹数据库"三者分开管理,别混在一张表里。临时分块区可以按"文件的 fileId + 块序号"命名,比如{fileId}/{chunkIndex}.part,这样合并时能一眼看出哪些块已经有了、哪些缺失。
4. 核心实操:把目录结构分块秒传一步步接进你的 Java 插件
4.1 设计后端接口,先讲清参数协议
这一步非常关键,前后端参数约定不统一,后期联调全是泪。我给一套实践中跑得很顺的参数协议:
| 参数 | 含义 | 示例 |
|---|---|---|
fileId | 前端生成的全文件唯一标识,建议用"路径 + 文件MD5"组合 | 2025归档/合同/采购A.pdf-abc123 |
fileName | 原始文件名 | 采购A.pdf |
relativePath | 文件在目录中的相对路径 | 2025归档/合同/采购A.pdf |
chunkIndex | 当前块序号(从 0 开始) | 0 |
chunkTotal | 总块数 | 64 |
chunkSize | 分块大小(字节) | 2097152 |
fileSize | 文件总大小(字节) | 134217728 |
fileMd5 | 全文件 MD5(用于秒传判断) | d41d8cd98f00b204e9800998ecf8427e |
参数看着多,但每一个都有用。特别是fileId,它必须是"路径相关"的。为什么要路径相关?因为秒传和分块都是针对"同一个文件"而言的,同一个文件上传到不同目录,物理路径不同,就不能用同一个 fileId 去合并,否则两个不同目录下的同名文件会互相覆盖。我见过有人只拿文件名的 MD5 当 fileId,最后不同目录下同名文件合并时炸了,就是因为没带路径信息。
接口设计上,三个接口就够了:
POST /upload/chunk:接收单个分块,存储并返回当前已收到的块数。GET /upload/progress:根据 fileId 查询已上传分块索引,用于断点续传和前端恢复进度。POST /upload/merge:根据 fileId 合并分块,合并后计算全量 MD5,写入指纹表,返回最终文件访问路径。
秒传不需要单独接口,它等价于"前端调 merge 之前,先去查一下这个 fileMd5 是否已存在,如果存在直接跳过 chunk 上传阶段"。所以我会再补一个:
GET /upload/exists?fileMd5=xxx&fileSize=xxx:检查服务器是否已有相同内容的文件,有就返回现有文件路径,前端直接"假装上传成功"。
4.2 分块上传核心代码:后端怎么存块
这里给一版可落地的 Spring Boot 实现片段,供你对照自己插件里的上传模块改造。
@RestController @RequestMapping("/upload") public class UploadChunkController { @Value("${upload.chunk-dir:/tmp/upload-chunks}") private String chunkDir; @Value("${upload.storage-dir:/tmp/upload-files}") private String storageDir; @PostMapping("/chunk") public Map<String, Object> uploadChunk( @RequestParam("fileId") String fileId, @RequestParam("chunkIndex") int chunkIndex, @RequestParam("chunkTotal") int chunkTotal, @RequestParam("fileMd5") String fileMd5, @RequestParam("relativePath") String relativePath, @RequestParam("file") MultipartFile file) throws IOException { // 1. 校验 relativePath,防止路径穿越 if (relativePath.contains("..")) { throw new IllegalArgumentException("invalid relativePath"); } String safeRelativePath = normalizePath(relativePath); // 2. 按 fileId 隔离分块目录 Path targetDir = Paths.get(chunkDir, fileId).toAbsolutePath().normalize(); Files.createDirectories(targetDir); // 3. 将分块写入独立文件,文件名就是块序号 Path chunkFile = targetDir.resolve(chunkIndex + ".part"); if (Files.exists(chunkFile)) { Files.delete(chunkFile); } file.transferTo(chunkFile.toFile()); // 4. 返回当前已收到的块数,用于前端展示进度 long received; try (Stream<Path> paths = Files.list(targetDir)) { received = paths.filter(p -> p.toString().endsWith(".part")).count(); } return Map.of("received", received, "chunkTotal", chunkTotal); } private String normalizePath(String path) { // 将反斜杠统一为正斜杠,去除首尾斜杠,拒绝异常字符 return path.replace('\\', '/').replaceAll("^/+", "").replaceAll("/+$", ""); } }上面这段代码有几个关键点值得展开说说。
第一,分块落地目录用fileId隔离,这样同一个文件的分块都在一个文件夹里,合并时直接遍历这个目录即可,不需要扫描全盘。而且用chunkIndex + ".part"命名,天然支持断点续传——如果一个块没传成功,重传时会覆盖同名文件,不会产生脏数据。
第二,relativePath的处理一定要谨慎。前端传来的路径一定要经过程序化校验,用normalize()拿到标准化路径后,确认它既不包含..,也不包含盘符,再作为落盘目录的基础。这一条如果做不好,等于给服务器开了个目录穿越的漏洞。业内有不少上传组件的漏洞都出在这。
第三,file.transferTo()是 Spring 自带的方法,底层会用流拷贝把临时文件挪到指定位置。注意它在某些情况下可能不会自动覆盖已有文件,所以写之前最好先判断 chunkFile 是否已存在,存在时可以先删掉再 transfer,避免内容混乱。
4.3 合并与全量校验代码:顺序、完整性、准确性一个都不能少
分块传完了并不代表合并就一定能成功。这里最容易出事的是"块没传齐"和"顺序错乱"。合并方法如下:
@Service public class FileMergeService { @Value("${upload.chunk-dir:/tmp/upload-chunks}") private String chunkDir; @Value("${upload.storage-dir:/tmp/upload-files}") private String storageDir; public String merge(String fileId, String fileName, String relativePath, String fileMd5, int chunkTotal) throws IOException { Path chunkDirPath = Paths.get(chunkDir, fileId).toAbsolutePath().normalize(); if (!Files.exists(chunkDirPath)) { throw new FileNotFoundException("chunk dir not exists: " + fileId); } // 1. 收集所有 .part 文件并按块序号排序 List<Path> chunks; try (Stream<Path> stream = Files.list(chunkDirPath)) { chunks = stream.filter(p -> p.toString().endsWith(".part")) .sorted(Comparator.comparingInt(p -> parseChunkIndex(p.getFileName().toString()))) .collect(Collectors.toList()); } // 2. 校验块数是否齐全 if (chunks.size() != chunkTotal) { throw new IllegalStateException("chunk count mismatch: expect " + chunkTotal + ", got " + chunks.size()); } // 3. 顺序写入正式存储目录 String safeRelativePath = sanitizePath(relativePath); Path targetDir = Paths.get(storageDir, safeRelativePath).toAbsolutePath().normalize(); Files.createDirectories(targetDir); Path targetFile = targetDir.resolve(fileName); try (OutputStream out = Files.newOutputStream(targetFile)) { for (Path chunk : chunks) { Files.copy(chunk, out); } } // 4. 计算完整文件的 MD5,和前端传的 fileMd5 比对 String mergedMd5; try (InputStream in = Files.newInputStream(targetFile)) { mergedMd5 = DigestUtils.md5Hex(in); } if (!mergedMd5.equalsIgnoreCase(fileMd5)) { Files.deleteIfExists(targetFile); throw new IllegalStateException("md5 mismatch after merge"); } // 5. 删除临时分块目录 deleteChunkDir(chunkDirPath); return targetFile.toString(); } private int parseChunkIndex(String fileName) { return Integer.parseInt(fileName.replace(".part", "")); } private String sanitizePath(String path) { // 清洗路径,防止特殊字符和路径穿越 return path.replace('\\', '/').replaceAll("\\.\\.", "").replaceAll("/+", "/"); } private void deleteChunkDir(Path dir) throws IOException { try (Stream<Path> paths = Files.walk(dir)) { paths.sorted(Comparator.reverseOrder()).forEach(p -> { try { Files.deleteIfExists(p); } catch (IOException e) { throw new RuntimeException(e); } }); } } }这一步的严谨性直接决定了整个上传模块的可用性。我见过太多合并代码只做"写入"不做"校验",一旦合并过程因为网络抖动丢了一块,文件就悄悄坏了,用户下载下来才发现 PDF 打不开,体验极差。所以"合并后重新计算 MD5 并和前端上报值比对"这步绝不能省。比对失败时把合并产物删掉,让前端重新发起该文件的完整上传,这是最干净的处理方式。
另外,并发合并的问题也得留个心。如果用户重复点击"上传"按钮,导致两个合并请求并发操作同一个 fileId,后一个会把前一个的分块目录删掉,进而引发各种诡异异常。最简单的方案是在合并接口入口加一个"基于 fileId 的本地锁"或分布式锁,保证同一时刻只有一个合并在跑。用本地ConcurrentHashMap<String, Lock>即可,业务量大的项目再上 Redis 锁。
4.4 秒传逻辑:用"指纹表"去重,别用"文件名"去重
秒传的核心是一张"文件指纹表",数据结构可以非常简单:
CREATE TABLE file_fingerprint ( id BIGINT AUTO_INCREMENT PRIMARY KEY, file_md5 VARCHAR(64) NOT NULL, file_size BIGINT NOT NULL, storage_path VARCHAR(512) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_md5_size (file_md5, file_size) );秒传判断逻辑:
@GetMapping("/exists") public Result exists(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileSize") long fileSize) { FileFingerprint fp = fingerprintMapper.findByMd5AndSize(fileMd5, fileSize); if (fp != null) { return Result.ok().data("path", fp.getStoragePath()); } return Result.ok().data("path", null); }为什么 MD5 之外还要加file_size做联合唯一?因为 MD5 虽然碰撞概率极低,但万一两个不同文件算出了相同 MD5,你还用大小过滤一下,能在极低成本上再堵一道。这不是学术洁癖,我在生产环境真的见过不同文件计算出相同 MD5 的案例,双字段校验非常必要。
还有一个经验点:指纹表里存的是"正式存储位置",不是"oid"或"业务编号"。因为秒传的本质就是"服务器已经有了物理文件,你直接引用它"。所以当用户对另一个文件名做秒传时,最终返回给前端的文件路径,应该指向指纹表里已有的那个物理文件。这里就牵涉到"命名隔离"的取舍——到底是按内容哈希存储文件(内容寻址),还是按业务路径存储文件(路径寻址)。如果业务上允许,我强烈建议 PDF 这类只读、不可变的文件用"内容寻址"存储:文件名随便改,物理文件只存一份,省磁盘不说,秒传命中率也高。
顺带一提,指纹表里最好再加一列ref_count表示被多少个业务路径引用,删除文件时先减引用,引用归零再物理删除。这样多个目录共享一个文件时,不会误删。
4.5 前端 JS 代码:目录遍历 + 分块切片 + MD5 计算
后端再完善,前端不配合也是白搭。前端需要做三件事:遍历目录得到相对路径、切片、算 MD5。核心代码如下:
const input = document.getElementById('fileInput'); input.addEventListener('change', async (e) => { const files = Array.from(e.target.files); for (const file of files) { const relativePath = file.webkitRelativePath || file.name; if (!relativePath.toLowerCase().endsWith('.pdf')) continue; // 1. 计算整文件 MD5(用 Web Worker,避免卡主线程) const fileMd5 = await computeMD5(file); // 2. 检查秒传 const existRes = await fetch( `/upload/exists?fileMd5=${fileMd5}&fileSize=${file.size}` ); const existData = await existRes.json(); if (existData.data.path) { console.log(`秒传命中:${relativePath}`); continue; } // 3. 分块上传,固定并发数为 3 const chunkSize = 2 * 1024 * 1024; const chunkTotal = Math.ceil(file.size / chunkSize); const fileId = `${relativePath}-${fileMd5}`; let uploadedChunks = await queryProgress(fileId); const queue = []; for (let i = 0; i < chunkTotal; i++) { if (uploadedChunks.includes(i)) continue; queue.push((async () => { const start = i * chunkSize; const end = Math.min(start + chunkSize, file.size); const chunkBlob = file.slice(start, end); const formData = new FormData(); formData.append('fileId', fileId); formData.append('fileName', file.name); formData.append('relativePath', relativePath); formData.append('chunkIndex', i); formData.append('chunkTotal', chunkTotal); formData.append('chunkSize', chunkSize); formData.append('fileSize', file.size); formData.append('fileMd5', fileMd5); formData.append('file', chunkBlob, `${file.name}.part${i}`); const res = await fetch('/upload/chunk', { method: 'POST', body: formData }); const data = await res.json(); if (data.code !== 200) throw new Error(`chunk ${i} upload failed`); updateProgress(relativePath, (i + 1) / chunkTotal); })(); if (queue.length >= 3) { await Promise.all(queue.splice(0, 3)); } } await Promise.all(queue); // 4. 触发合并 await fetch('/upload/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileId, fileName: file.name, relativePath, fileMd5, chunkTotal }) }); } });这里有个前端的细节值得单独拎出来说:fileId的拼接。我强烈建议用relativePath + '-' + fileMd5,而不是只拼文件名。因为同一个目录下的同名文件极少,但不同目录下的同名文件很常见。如果你只拿fileMd5或fileName当 fileId,两个不同目录里的同名 PDF 会互相踩,合并出一个文件后,另一个目录就找不到了。
MD5 放 Web Worker 里算的代码,大概是这样:
// worker.js self.onmessage = async function (e) { const file = e.data.file; const buffer = await file.arrayBuffer(); const hash = CryptoJS.MD5(CryptoJS.lib.WordArray.create(buffer)); self.postMessage({ hash: hash.toString() }); };注意file.arrayBuffer()是一次性把整个文件读进内存,几百 MB 的 PDF 会占用较大内存。如果想省内存,可以分片读取再增量算哈希,实现会复杂一些。大部分场景下,用 Worker 读全文件是可行的,但如果你服务的 PDF 经常有 1GB 以上的,建议用FileReader.readAsArrayBuffer配合分片循环,或者用流式哈希库来减少内存峰值。
5. 上线前必须趟过的几个坑:我的真实踩坑记录
5.1 并发上传块乱序,合并出来的 PDF 打不开
这是我遇到最多的问题,没有之一。现象是:前端并发上传 8 个块,后端收到的顺序是 2、5、0、3、1…… 如果你按"收到顺序"往文件里写,那绝对乱。
解决思路前面已经写了:块按chunkIndex独立落盘,合并时先排序再拼接。但这里还有一个隐藏问题:前端用await串行上传时,速度很慢;用Promise.all无脑并发上传时,后端落盘顺序乱是没问题,但大量连接会占满。我最后的方案是"限制并发数为 3",既保证速度,又不让请求堆积把后端连接池打满。这个并发数可以根据服务器性能调。
5.2 秒传判定用的哈希,前后端算出来不一致
前端用 JS 算 MD5,后端用 Java 算 MD5,两者应该一致,但如果遇到中文文件名、换行符差异、或中间经过代理解码等问题,算出的哈希可能对不上。
排查步骤我一般这么走:
- 先确认前端计算的是
file.arrayBuffer()的原始字节,而不是FileReader.readAsText的结果。后者会把二进制转成字符串,完全不是一回事。 - 再确认后端合并后计算 MD5 时,读的是同一个文件的原始字节流,不要用
InputStreamReader去转换编码。 - 最后确认前端上传的每块
Blob是同一个File.slice()的结果,而不是重新从磁盘读的另一个文件。
这类问题 90% 都是编码或读取方式导致的,对照上面三步基本都能解决。
5.3 目录层级太深,文件名太长,Linux 上落盘失败
PDF 文件经常放在深层目录里,前端传过来的relativePath有可能是部门A/项目X/资料合集/2025年上半年/合同/最终版/采购合同A.pdf这种七八层结构。Windows 和 Linux 对路径长度和特殊字符的限制不一样,拼接存储路径时不处理,极容易报No such file or directory或File name too long。
我的建议是后端存储时不要把relativePath原封不动当物理路径,而是做一层映射:
- 物理存储目录只保留 fileId 的哈希值,比如
storage/{md5前2位}/{md5},这个目录层级很浅、不会过长。 - 真正的业务相对路径(如
部门A/合同/采购A.pdf)存入数据库的relative_path字段,需要列表展示时查询出来拼接。 - 这样做还有个好处:同一份 PDF 被复制到多个目录时,物理层只存一份,业务层通过记录多个
relative_path关联到同一个物理文件,天然支持去重。
当然,如果你的业务对"目录结构必须像文件夹一样真实存在于服务器磁盘上"有硬性要求,那只能限制目录深度和文件名字符集,并在前端做校验。但这个方案在运维上会比较痛苦,能走"逻辑目录"就别走"物理目录"。
5.4 磁盘被临时分块占满,没人清理
分块上传最容易被忽视的运维问题是:临时分块目录会慢慢积累,特别是秒传失败、网络中断、用户中途关闭页面这些情况,都会留下残缺的.part文件。几天不看,磁盘可能就被几十 GB 的临时块占满了。
至少要补两道清理机制:
- 定时任务:每天扫描临时分块目录,删除超过 24 小时未合并的分块文件。这种任务用 Spring
@Scheduled就能做。 - 合并成功后立即删除分块目录:这个前面代码里已经写了,但要注意异常场景也要删,放在
finally块里比较稳妥。
如果项目允许,可以用 Redis 给每个 fileId 记录"最后活跃时间",定时任务根据活跃时间判断哪些该清,比单纯看文件修改时间更精确。
5.5 上传接口的鉴权和防滥用
很多上传插件在本地 demo 跑得欢,一上生产就被人刷流量或传垃圾文件。分块上传引入之后,问题更突出——攻击者可以伪造大量fileId和 chunk 请求,把磁盘写爆。
我建议至少做三件事:
- 上传接口必须走登录鉴权,每个用户的文件 ID 和上传配额在数据库里做登记。
fileId要么由服务端生成,要么服务端严格校验其格式,比如只允许字母、数字、/、-、.,拒绝异常字符。- 单用户的分块并发数和服务端总并发数都做限制,防止恶意并发拖垮服务。
5.6 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 合并后 PDF 打开报错 | 块顺序错乱、漏块、合并没校验 | 检查分块落盘是否按 index 存储,合并是否排序 |
| 秒传一直不命中 | 前端/后端 MD5 计算方式不一致 | 检查是否用了正确的原始字节流 |
| 不同目录下同名文件互相覆盖 | fileId 只用了文件名 | 改成relativePath + md5组合 |
| 上传一半,刷新页面后进度丢失 | 没做进度恢复接口 | 实现/upload/progress并回填上传进度 |
| 服务器磁盘被占满 | 临时分块未清理 | 加定时清理 + 合并后 finally 删除 |
| 路径含特殊字符上传失败 | 未对 relativePath 清洗 | 规范文件名,或改用逻辑目录映射 |
6. 再分享几个我在实践中的小习惯
其实"分块秒传"这种功能,技术难度并不算高,真正拉开差距的是细节处理和对异常场景的态度。同一个开源 Java 插件,有人能稳定处理几十 GB 的文件批量上传,有人在 200 MB 的 PDF 面前就趴窝,差别基本都在这些零零碎碎的边界处理上。
我自己有几个小习惯,养成了之后少踩很多坑。
第一,前后端联调时先定好"失败重传"的语义。前端在某个块上传失败后,是重试这个块,还是从头传整个文件?分块的意义就在这里——只重传失败的那几块,所以前端必须记录已成功上传的块索引,后端必须提供查询接口。这个语义不明,分块实现得再好也白搭。
第二,所有数字参数在前端统一取整,后端统一用 Long 接收,别用 int 存fileSize,一个 2 GB 的文件大小随便就超出 int 上限。这个坑我踩过,合并时大小对不上,排查了半下午才发现是 int 溢出。
第三,上传插件的扩展点尽量做"可插拔",不要为了一个 PDF 场景把代码写死。分块大小、并发数、存储根目录、是否开启秒传、是否保留目录结构,这些最好都做成配置项。别小看这个习惯,需求 90% 都会变,今天传 PDF,明天可能就要传压缩包或视频,配置化了到时候只是改个参数的事。
第四,日志一定要带上fileId、chunkIndex、relativePath这三个字段。分块上传的问题排查极度依赖完整链路日志,少任何一个字段,线上出问题就得靠猜。你用 logback / log4j 的 MDC 机制把这三个值打进每一条日志,排查效率直接翻倍。
如果你手头这个开源 Java 插件已经有基础上传能力,扩展分块秒传的思路其实就三步——前端切片、后端收块合并、指纹表做去重。目录结构只是在这三个环节里多带一个relativePath参数而已。把每一步的职责边界划清楚,代码量不大,但稳定性能比大多数商业组件还强。毕竟,文件上传这事,可靠性永远是第一位的。