做Java Web开发的早晚都会碰到大文件上传这个坑。不是那种几MB的小文件而是几百MB、几个GB的大文件比如视频素材、数据备份包、离线安装包。普通的表单上传在这一类场景下几乎没法用超时、内存溢出、中断后全部重传任何一个问题都够让人头疼。大文件分片上传就是解决这些问题的标准方案前端把大文件切成若干小块逐块上传后端逐个接收最后再合并成完整文件。整个过程跨平台浏览器端只要能跑JavaScript就行服务端只要是标准的Java Web容器就没问题。这篇文章就把整个方案拆开讲透从为什么必须分片到前后端具体怎么实现再到我踩过的那些坑全部写出来照着做就能用。1. 先想清楚为什么要分片而不是一口气传完1.1 不分片时你到底在跟哪些东西对抗很多刚接触文件上传的同学总觉得HTTP协议本身没有限制请求体大小为什么不能直接把一个2GB的文件通过一次请求传上去。从理论上说确实可以但现实里你会遇到一连串的问题。第一个是连接稳定性。一个HTTP请求要维持几十分钟甚至几个小时中间任何一次网络波动、路由器重启、服务器主动断开都会导致连接中断。一旦断开浏览器端收到的就是一个失败响应前端根本拿不到已经传输了多少数据唯一的办法就是从头再来。上传一个500MB的文件试了三次都在80%左右断掉每次都要重新传这种体验足以让用户直接放弃。第二个是服务端内存限制。Spring Boot默认的MaxRequestSize是10MB左右即使你把Spring配置里的max-file-size和max-request-size调大Tomcat的maxPostSize也要同步调整。就算全部调大了2GB的文件一次性加载到内存哪怕服务器内存是8GB也就只能扛住三四个并发上传系统马上就卡死。第三个是超时问题。Nginx默认的proxy_read_timeout是60秒Tomcat的connectionTimeout默认是20秒这些超时设置和文件大小没有关系只管连接空闲的时间。你上传一个大的文件中间某个阶段网络稍微慢了一点一段时间没有数据传输服务器就判定超时直接把连接断掉了。1.2 分片上传到底解决了什么分片上传的思路非常朴素把大文件切成比如每片10MB的小块然后对这些小块分别发起请求。每个请求体很小几秒到几十秒就能传完连接不会长时间处于空闲状态天然绕开了超时问题。单个分片即使失败只需要重传这一片不需要动整个文件。服务端也不是一次性把所有数据加载到内存而是每收到一片就写入磁盘内存占用几乎可以忽略。分片还有一个额外的好处就是可以做到并发上传。浏览器对同一个域名的并发连接数有限制但我们可以控制前端同时发3个或5个请求分片之间互相独立整体上传速度会有明显提升。如果再配合后端的断点续传逻辑用户上次传了一半退出了下次打开页面已上传的切片还在服务器上直接从断点继续传就行。1.3 与其重复造轮子先看看现成的方案大文件上传不是一个特别新的问题业界已经有一些成熟的框架比如Web Uploader、Plupload还有新一代的Uppy、filepond。它们都内置了分片、并发、断点续传这些能力前端引入一个JS文件就能用。但我的经验是如果你只是在一两个内部系统里用直接上现成框架没问题。但如果你要把这个功能做成可维护、可扩展的通用组件我还是建议自己实现一遍哪怕最后不用自己这套过程也值得。原因很简单现成框架封装得太好你不知道它的分片策略、合并逻辑、容错机制到底是怎么设计的出了问题你根本无从下手。自己实现一遍之后你再看那些框架一眼就能看懂它们在做什么维护起来心里有底。2. 整体架构与核心设计前后端各自该干什么2.1 端到端的流程梳理一个完整的分片上传流程从前端到后端要经历这几个关键步骤用户选择文件后前端先对文件做MD5计算生成一个唯一的任务标识符。前端根据配置的分片大小用Blob.slice()方法把文件切成多个分片。后端提供检查接口接收任务标识符返回该任务已上传的分片编号列表。前端拿到已上传列表跳过这些分片把剩余的分片逐个或并发上传。后端每收到一个分片把它写入临时目录文件名包含任务标识符和分片编号。前端所有分片上传完成后调用合并接口。后端校验分片完整性把所有分片按顺序合并成一个完整文件。合并完成后清理临时分片返回最终文件的访问地址。这套流程里最核心的两个设计点是任务标识符和分片元数据。2.2 任务标识符识别“同一个文件”的关键任务标识符的作用是让后端能识别出当前上传的文件是谁、属于哪次上传任务。最简单的方案是用文件内容的MD5值作为标识。同一个文件不管用户从哪个电脑传MD5都一样这样还能顺带实现一个“秒传”的功能后端检查MD5对应的完整文件已经存在时直接返回上传完成不用再传一次。用MD5做任务标识符需要注意一点计算大文件的MD5需要时间。一个1GB的文件纯JavaScript计算MD5可能要花上几秒到十几秒期间页面会卡顿。解决办法是利用Web Worker在后台线程里计算不阻塞UI。我把这部分设计成前端一个专门的模块用户选完文件之后先显示“正在校验文件信息”MD5算完再进入上传流程。2.3 分片大小怎么定分片大小是整个方案里最重要也最灵活的参数。片切得太小分片数量就会很多HTTP请求数暴增网络开销和服务器压力都会变大片切得太大又回到了大请求的老路上超时和失败重传的代价变大。我的经验值是10MB到20MB之间。局域网场景用20MB不会有问题公网环境建议10MB。考虑到不同用户的网络状况差别很大可以把分片大小做成可配置项前端根据当前网络状况动态调整网络好的时候用大分片减少请求数网络差的时候用小分片提高单片成功率。2.4 并发数的选择与限流并发上传能显著提升速度但并不是并发数越高越好。浏览器对同一域名的TCP连接数有限制HTTP/1.1下Chrome的默认限制是6个。并发数设置太高请求排队反而导致阻塞。我在前端做了并发控制默认并发数为3用户可以根据自己网络状况在1到5之间调整。这样既保证速度又不至于把服务器打挂。后端也要做一层并发防护。虽然前端限制了并发数但恶意客户端可以伪造请求绕过限制。后端的防护思路是给每个上传任务设置一个“进行中的分片数量”限制超过阈值直接返回繁忙提示。3. 实操落地前端切片、分片上传与进度展示3.1 前端需要准备的基础模块前端这部分我用原生JavaScript来实现不依赖任何框架这样不管你的项目是Vue、React还是传统的JSP页面都能直接迁移过去。核心模块分成三个文件切片器、上传调度器、进度展示器。文件切片器的逻辑很简单用File对象自带的slice方法就可以// 文件切片器把文件切成N个Blob分片 function createChunks(file, chunkSize) { const chunks []; let start 0; let index 0; while (start file.size) { const end Math.min(start chunkSize, file.size); const blob file.slice(start, end); chunks.push({ chunk: blob, index: index, start: start, end: end, size: blob.size }); start end; index; } return chunks; }用这个函数传入File对象和分片大小拿到一个分片数组。每个分片里记录了它在原文件中的序号和字节范围这个序号在合并阶段至关重要后面会讲到。3.2 上传调度器控制并发、负责重试上传调度器是前端最复杂的部分。它要维护一个待上传队列控制同时进行的请求数量处理失败重试并且把每个分片的上传状态同步到UI上。// 简化版上传调度器核心逻辑 class UploadScheduler { constructor(options) { this.chunks options.chunks; this.file options.file; this.identifier options.identifier; this.concurrent options.concurrent || 3; this.uploadUrl options.uploadUrl; this.retryCount options.retryCount || 3; this.running 0; this.index 0; this.results new Map(); } start() { while (this.running this.concurrent) { this.uploadNext(); } } uploadNext() { if (this.index this.chunks.length) { // 所有分片已进入上传流程 return; } const currentIndex this.index; const item this.chunks[currentIndex]; this.running; this.uploadChunk(item, currentIndex, 0); } async uploadChunk(item, chunkIndex, retry) { const formData new FormData(); formData.append(file, item.chunk, this.file.name); formData.append(identifier, this.identifier); formData.append(chunkIndex, chunkIndex); formData.append(totalChunks, this.chunks.length); try { const response await fetch(this.uploadUrl, { method: POST, body: formData }); if (!response.ok) { throw new Error(上传失败HTTP response.status); } this.results.set(chunkIndex, ok); } catch (e) { if (retry this.retryCount) { // 指数退避重试1秒、2秒、4秒 await this.sleep(1000 * Math.pow(2, retry)); return this.uploadChunk(item, chunkIndex, retry 1); } this.results.set(chunkIndex, failed); this.onError this.onError(chunkIndex, e); } this.running--; this.updateProgress(); if (this.results.size this.chunks.length) { if (this.isAllSuccess()) { this.onComplete this.onComplete(); } return; } this.uploadNext(); } isAllSuccess() { for (const [index, status] of this.results) { if (status ! ok) { return false; } } return true; } sleep(ms) { return new Promise(resolve setTimeout(resolve, ms)); } updateProgress() { const successCount [...this.results.values()].filter(s s ok).length; const percent Math.round(successCount / this.chunks.length * 100); this.onProgress this.onProgress(percent, successCount, this.chunks.length); } }这个调度器的核心思路是每次只有3个上传请求在飞行这个数字对应配置的并发数一个请求完成后立即从队列里取出下一个分片补齐。与一次性把几百个请求全部发出相比这能保证连接数始终可控。3.3 断点续传的前端逻辑断点续传的实现依赖后端提供一个查询接口。前端在上传开始之前先调用这个接口把当前任务的已上传分片列表拿回来然后跳过这些分片。// 查询已上传分片跳过这些分片开始续传 async function prepareUpload(file, identifier, chunkSize) { const checkUrl /upload/check?identifier${identifier}; const response await fetch(checkUrl); const result await response.json(); if (result.uploadedComplete) { // 文件已经完整上传过了直接提示成功 return { uploadedChunks: [], completed: true }; } return { uploadedChunks: result.uploadedChunks || [], completed: false }; }拿到已上传分片列表后在创建上传任务时把这些分片从待上传队列里剔除就好。注意一点已上传的每个分片后端应该做完整性校验只记录大小和位置不够最好记录MD5防止分片在传输过程中发生损坏。不过对大部分应用来说传输层有TCP做差错控制HTTP层面数据被篡改的概率极低所以很多实现只做了大小校验我也遵循了这个标准做法仅在合并时做最终完整性校验。3.4 进度展示怎么做到准确且顺滑进度展示分两块单分片的进度和总进度。单分片进度可以用XMLHttpRequest的progress事件获取fetch API不支持上传进度事件所以我在实现里用XMLHttpRequest来发送分片而不是fetch。总进度按已上传分片数除以总分片数来算。如果文件已经被切成了100个分片那么显示“45%”时真实传输进度偏差不会太大因为每个分片大小一致。需要注意的是分片合并的时间不计算在用户可见的进度里大文件的合并其实很快属于磁盘顺序读写基本一两秒内完成。4. 实操落地后端接收、合并与断点续传4.1 后端接口的整体设计后端我用Spring Boot来写涉及三个接口POST /upload/check查询上传状态返回已上传的分片列表和文件是否已完成。POST /upload/chunk接收单个分片将其写入磁盘。POST /upload/merge合并所有分片生成最终文件。另外还需要一个静态资源映射把合并后的文件目录映射到URL方便浏览器直接访问下载。4.2 分片接收接口的实现分片接收是本方案最核心的接口。它的职责是接收分片数据、按照约定的目录结构存储、返回当前分片的上传结果。// 分片上传接收接口Controller层 RestController RequestMapping(/upload) public class UploadController { private static final String UPLOAD_DIR /data/upload_temp/; private static final int EXPECTED_CHUNK_SIZE 10 * 1024 * 1024; // 10MB PostMapping(/chunk) public ResponseEntityMapString, Object uploadChunk( RequestParam(file) MultipartFile file, RequestParam(identifier) String identifier, RequestParam(chunkIndex) int chunkIndex, RequestParam(value totalChunks, required false) Integer totalChunks) { // 1. 对目录做安全校验防止路径穿越 String safeId validateIdentifier(identifier); // 2. 创建当期上传任务的目录 File taskDir new File(UPLOAD_DIR, safeId); if (!taskDir.exists()) { taskDir.mkdirs(); } // 3. 分片文件命名规则identifier_chunkIndex.part File chunkFile new File(taskDir, safeId _ chunkIndex .part); // 4. 分片大小校验防止异常请求 if (file.getSize() EXPECTED_CHUNK_SIZE) { return ResponseEntity.badRequest().body(Map.of(message, 分片大小超过限制)); } // 5. 写入磁盘用临时文件的方式保证原子性 try { File tempFile File.createTempFile(uploading_, .tmp, taskDir); file.transferTo(tempFile); // 如果目标文件已存在则先删除这里不考虑覆盖旧分片 if (chunkFile.exists()) { chunkFile.delete(); } tempFile.renameTo(chunkFile); } catch (IOException e) { return ResponseEntity.status(500).body(Map.of(message, 分片写入失败)); } return ResponseEntity.ok(Map.of(uploaded, true, chunkIndex, chunkIndex)); } private String validateIdentifier(String identifier) { // 只允许字母、数字、下划线、短横线防止路径穿越攻击 if (!identifier.matches(^[a-zA-Z0-9_-]{8,64}$)) { throw new IllegalArgumentException(非法标识符); } return identifier; } }这里面有两个容易被忽略的细节。一个是安全校验identifier会直接参与到文件路径的拼接里如果不校验用户传入一个../../etc/passwd之类的值就可能造成路径穿越漏洞。这个必须是第一优先级处理的。另一个是写入的原子性不要直接transferTo到目标分片文件而是先写临时文件再rename。一旦上传请求处理了一半失败磁盘上不会留下一个损坏的半截分片来干扰后续的合并。4.3 合并接口的实现合并接口的逻辑不复杂但处理细节值得注意。核心步骤是检查所有分片是否存在、按照片序号读出来、拼接到一个文件里、删除临时分片目录、返回最终文件信息。// 合并分片接口 PostMapping(/merge) public ResponseEntityMapString, Object mergeFile( RequestParam(identifier) String identifier, RequestParam(fileName) String fileName) { String safeId validateIdentifier(identifier); File taskDir new File(UPLOAD_DIR, safeId); if (!taskDir.exists()) { return ResponseEntity.badRequest().body(Map.of(message, 任务不存在)); } // 1. 统计分片数量 File[] partFiles taskDir.listFiles((dir, name) - name.endsWith(.part)); if (partFiles null || partFiles.length 0) { return ResponseEntity.badRequest().body(Map.of(message, 没有找到分片文件)); } // 2. 对分片按序号排序 Arrays.sort(partFiles, (a, b) - { int indexA extractChunkIndex(a.getName()); int indexB extractChunkIndex(b.getName()); return Integer.compare(indexA, indexB); }); // 3. 创建一个完整性校验清单 // 分片数量是否与文件开头传的totalChunks一致 // 相邻分片之间是否存在缺失 // 4. 拼接分片到最终文件 File finalDir new File(/data/files/); if (!finalDir.exists()) { finalDir.mkdirs(); } // 对文件名做安全处理只保留基础文件名 String safeName Paths.get(fileName).getFileName().toString(); File finalFile new File(finalDir, safeId _ safeName); try (OutputStream out new FileOutputStream(finalFile)) { for (File part : partFiles) { byte[] buffer new byte[8192]; try (InputStream in new FileInputStream(part)) { int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } } } catch (IOException e) { return ResponseEntity.status(500).body(Map.of(message, 文件合并失败)); } // 5. 合并完成后清理临时分片目录 deleteRecursively(taskDir); // 6. 返回文件下载地址 String downloadUrl /files/ finalFile.getName(); return ResponseEntity.ok(Map.of( message, 合并完成, downloadUrl, downloadUrl, fileSize, finalFile.length() )); }合并时有两个性能点。一是缓冲区大小用8192字节的缓冲区这是一般Java IO的标准选择太小的缓冲区会导致系统调用过于频繁太大的缓冲区收益并不明显。二是输出流选择我这里用的是FileOutputStream如果觉得速度不够快可以考虑改成BufferedOutputStream多加一层缓冲能提升明显。4.4 查询接口断点续传的基石查询接口要帮前端确认两件事这个文件是不是已经完整传过了以及当前已有哪些分片。前者直接返回completedtrue让前端跳过整个上传流程后者返回一个已上传分片序号数组前端据此跳过。// 查询上传进度接口 GetMapping(/check) public ResponseEntityMapString, Object checkUpload( RequestParam(identifier) String identifier) { String safeId validateIdentifier(identifier); File taskDir new File(UPLOAD_DIR, safeId); ListInteger uploadedChunks new ArrayList(); if (taskDir.exists()) { File[] partFiles taskDir.listFiles((dir, name) - name.endsWith(.part)); if (partFiles ! null) { for (File part : partFiles) { uploadedChunks.add(extractChunkIndex(part.getName())); } } } // 是否已经存在完整文件秒传场景 File finalFile findFinalFile(safeId); boolean completed finalFile ! null finalFile.exists(); return ResponseEntity.ok(Map.of( identifier, safeId, uploadedChunks, uploadedChunks, completed, completed )); }这个接口是对外暴露的所以设计上要保证足够轻量查询速度要快。避免在这个接口里做遍历磁盘统计大文件大小之类的重操作就只检查文件是否存在以及列出目录下的.part文件。5. 常见问题排查与避坑实录5.1 分片之间出现并发写冲突同时上传3个分片时理论上不会写同一个文件因为分片序号不同、文件名不同。但有一种特殊情况会被忽视用户在断点续传时or上一次上传任务还没完全结束又重新发起了一个相同的任务。两个任务的前端分片序号从0开始后端的目录是同一个旧的请求和新的请求可能同时写同一个分片文件。我实际遇到过一次表现出来就是合并后的文件大小对不上中间有些区域是重复的有些区域是缺失的。解决方案分两步。第一步是前端的串行化同一时间只允许一个上传任务对同一个identifier发请求。第二步是后端的幂等处理写分片的时候判断目标文件是否已存在且大小一致如果相同就认为是重复上传直接返回成功不做覆盖。5.2 合并后的文件与原始文件MD5不一致这个问题最隐蔽也最容易让人摸不着头脑。现象是所有分片都显示上传成功合并也很顺利但最终文件用MD5校验和源文件对不上。排查下来有两类原因。一类是前端切片逻辑有边界错误比如分片的start和end计算不当导致有些切片重复切了一段。另一类是分片上传的请求没有完全送达后端用同名的.part文件覆盖了旧文件的一部分。修复方式前端切片逻辑严格使用[start, end)半开区间保证每个字节只属于一个分片后端接收分片时先检查分片大小是否和预期一致不一致直接拒绝。合并完成后后端再算一次MD5和前端回传的MD5做比对不一致就让前端重传整个任务。5.3 服务器磁盘空间被临时分片占满大文件上传中途用户取消或者浏览器刷新了页面前端不会再发后续请求但已经上传到服务器上的分片文件会一直留在临时目录里。这是最容易被忽视的资源泄漏问题。我处理的办法是加了一个定时清理任务每天凌晨扫描临时目录凡是最后修改时间超过24小时的.part文件全部删除。如果系统里还有定时任务框架用Scheduled注解就能实现。// 定时清理过期分片任务 Scheduled(cron 0 0 3 * * ?) // 每天凌晨3点执行 public void cleanupExpiredChunks() { File tempDir new File(UPLOAD_DIR); File[] taskDirs tempDir.listFiles(File::isDirectory); if (taskDirs null) return; long expires System.currentTimeMillis() - 24 * 3600 * 1000L; for (File taskDir : taskDirs) { File[] partFiles taskDir.listFiles((dir, name) - name.endsWith(.part)); if (partFiles ! null) { for (File part : partFiles) { if (part.lastModified() expires) { part.delete(); } } } // 如果任务目录已经空了整个目录删除 if (taskDir.list() ! null taskDir.list().length 0) { taskDir.delete(); } } }5.4 移动端上传时网络切换导致分片失败移动端用户上传过程中从Wi-Fi切换到4G/5G网络会短暂断开已经发送的请求可能会失败。这是分片上传的重试机制需要覆盖的典型场景。我在前端重试逻辑里引入了退避策略第一次失败等1秒第二次失败等2秒第三次失败等4秒最多重试3次。对于网络瞬间抖动这种策略基本都能自动恢复。如果重试3次还失败把该分片的状态标记为failed让用户手动点击“重试失败分片”按钮不自动无限重试避免在极差网络环境下反复消耗流量。5.5 浏览器兼容性的几个隐蔽差异File.prototype.slice方法在早期的浏览器里需要带前缀比如Firefox 12之前是file.mozSlice()Chrome 20之前是file.webkitSlice()。我这里做了兼容处理先用file.slice不存在时回退到file.webkitSlice。现在主流浏览器都已经支持无前缀的slice但如果你要兼容老旧的国产浏览器内核这个回退还是得保留。另一个差异是FormData在低版本浏览器里的行为不同好在现在还在用的浏览器基本都原生支持FormData这个兼容性问题可以不用太担心。6. 值得进一步做的扩展点分片上传的基础版本做完之后我强烈建议把“秒传”做出来这是投入产出比最高的一个功能。实现非常简单查询接口里已经检查了是否存在MD5相同的完整文件如果存在前端就不走上传流程直接提示上传完成。用户第一次觉得“这系统好快”往往就是从秒传开始的。还有一个实用扩展是进度持久化。把上传进度记录到浏览器的localStorage里用户不小心关掉标签页后再打开能直接恢复到上次的进度这个体验提升比断点续传还要大。实现上也不复杂每上传完一个分片就把这个分片的索引存到localStorage页面重新加载时读取出来和服务器端的已上传列表做对比取并集。如果你要在同一套系统里支持多个文件同时上传可以在调度器之上再加一层任务队列把多个文件的调度器串起来限制全局并发数。这个改造的复杂度可控但代码结构会清晰很多避免出现多个上传任务相互争抢连接的情况。整体上分片上传这套方案做扎实了再往上扩展就是多文件、多任务、秒传、限速这些功能它们都是在同一个地基上做增量不会推倒重来。