做Java Web的老哥应该都遇到过这种场景用户在页面上传一个几个G的视频素材结果等了十分钟报了一个Connection reset或者直接在Tomcat日志里看到maxPostSize exceeded。我第一次被这种问题找上门是在给某内部系统做附件管理的时候客户反馈上传一份800MB左右的压缩包传一半就断了重新来又是一下午。后来我把方案改成分段上传加断点续传问题才算彻底解决。这篇文章就从JSP网页的角度把大文件分段上传和续传的完整做法掰开揉碎讲一遍。前端用JSP页面里的JavaScript切分文件后端用Servlet接收分块、记录进度、合并文件。整个过程不需要引入任何第三方框架纯Servlet和原生JS就能跑非常适合传统Java Web项目或者不想为了上传功能单独引入组件的团队参考。1. 普通上传为什么对大文件无能为力在动手写分段上传之前先得搞清楚一件事普通的上传方式到底卡在哪了。很多人第一反应是“Tomcat配置不够大”调大限制就行。我一开始也这么想后来发现这只是表面的问题。1.1 限制不只在Tomcat一层一个HTTP上传请求从浏览器到服务器要经过Web服务器、Servlet容器、应用代码三层。每一层都有默认限制限制环节默认情况典型表现Nginxclient_max_body_size默认1MB413 Request Entity Too LargeTomcatmaxPostSize早期版本默认2MBmaxSwallowSize默认2MB请求被拒或连接被重置Servlet容器multipart配置若未显式设置不同版本行为不同request.getParameter拿到空值连接超时通常30秒到60秒大文件传一半连接断开只调大这些参数确实能让一次超大请求进来但随之而来的是更大的问题请求体需要被完整接收Tomcat要先缓冲或者把数据写入临时文件应用层解析multipart时又要做一次全量读取。文件越大内存压力越大一旦网络抖动整个请求直接失败前面的传输全部作废。1.2 核心矛盾是“不可重试”普通上传最大的问题不是慢而是不具备容错能力。一个1GB的文件以单个HTTP请求提交任何一个瞬间的网络波动、浏览器标签页误关、服务器重启都会导致整次上传失败。用户只能重新选择文件再来一遍这种体验放在今天完全说不过去。分段上传的思路就一句话把一个大请求拆成很多个小请求每个小请求独立传输、独立记录、独立重试。单个请求失败了只需要重传那几MB而不是整个文件。这就是“大文件”和“小文件”上传方案的分水岭。2. 分段上传与断点续传的整体设计这一节把方案的整体结构先捋清楚。分段上传不只是“把文件切片传上去”这么简单服务端怎么知道传了哪些块、最后怎么拼、断点续传怎么判断这些都要在设计阶段定下来。2.1 六个核心步骤我通常把流程分成六个环节用户选择文件前端用File.slice()按固定大小切块。切完后逐个分块发起上传请求请求里带上序号、文件名、总分块数等元数据。服务端把分块写入以文件唯一标识命名的临时目录记录已上传分块的序号。前端每传完一块就更新状态遇到失败的分块单独重试不影响其他分块。所有分块传完后前端发一个合并请求服务端按序号把分块拼成完整文件。合并成功之后删除临时分块目录返回最终文件信息。这个流程里最关键的转移点是“服务端状态”。断点续传的本质是前端在开始上传前先问一声服务端“这个文件我已经传过哪些块了”拿到结果之后跳过已传的块只传缺失的部分。2.2 分块大小怎么选分块大小是第一个要拍板的参数。我的经验是1MB到5MB之间比较合理内网或者带宽充裕的局域网可以适当加大到10MB公网弱网环境建议控制在2MB以内。分块太小比如256KB请求数量会非常夸张一个1GB的文件要拆成4096块每个请求都有HTTP头、TCP建连、服务端IO开销总耗时反而更长。分块太大比如50MB切块粒度太粗断点续传的优势就弱了网络抖动导致重传的代价变大。我习惯取2MB作为默认值兼顾请求数量和单块重试成本。这个值在大多数项目里不需要频繁调整属于“不会出错的中间值”。2.3 临时目录与唯一标识设计服务端存分块时不能直接用原始文件名建目录因为两个用户可能上传同名文件会造成互相覆盖。我选择用fileMd5作为每个上传任务的唯一标识临时目录结构如下/data/upload/temp/{fileMd5}/chunk_0 /data/upload/temp/{fileMd5}/chunk_1 /data/upload/temp/{fileMd5}/chunk_2fileMd5并不要求一定是文件内容的MD5它纯粹是给一次上传任务做隔离标识。最省事的做法是用“文件名文件大小最后修改时间”拼一个字符串再做一次哈希既能隔离不同文件又避免前端计算整个文件MD5带来的性能问题。这只是基础方案的取舍。如果要做内容级别的秒传那必须老老实实计算文件内容的MD5后文会专门讲。3. 前端JSP页面里的切块与续传控制JSP在整套方案里的角色只有一个渲染页面然后把控制权交给JavaScript。我强烈不建议在JSP里写服务端逻辑处理文件JSP本质是模板文件上传这种IO密集的操作交给Servlet接口处理页面只管调接口就好。3.1 页面骨架JSP页面本身很薄放一个文件选择框、一个上传按钮、一个进度条、一个日志区再引入上传脚本就可以。% page contentTypetext/html;charsetUTF-8 % !DOCTYPE html html head title大文件分段上传示例/title /head body input typefile idfileInput / button iduploadBtn开始上传/button div idprogress0%/div div idlog/div script src${pageContext.request.contextPath}/js/upload.js/script /body /html实际项目中建议把upload.js独立出来不要一股脑塞进JSP页面里浏览器可以缓存静态脚本后续改逻辑也不用重启服务端。3.2 切块与上传单块JavaScript端用File.slice()切割文件。注意slice的参数是字节位置左闭右开所以最后一块的大小可能不足2MB这是正常的。const CHUNK_SIZE 2 * 1024 * 1024; let file null; let fileMd5 ; let fileSize 0; document.getElementById(fileInput).addEventListener(change, function (e) { file e.target.files[0]; fileSize file.size; // 简易唯一标识名字大小修改时间 fileMd5 simpleHash(file.name _ file.size _ file.lastModified); }); function simpleHash(str) { let hash 0; for (let i 0; i str.length; i) { hash ((hash 5) - hash) str.charCodeAt(i); hash | 0; } return f Math.abs(hash); } function uploadChunk(index) { const start index * CHUNK_SIZE; const end Math.min(fileSize, start CHUNK_SIZE); const blob file.slice(start, end); const formData new FormData(); formData.append(file, blob, file.name); formData.append(chunkIndex, index); formData.append(totalChunks, Math.ceil(fileSize / CHUNK_SIZE)); formData.append(fileName, file.name); formData.append(fileMd5, fileMd5); formData.append(fileSize, fileSize); return fetch(upload, { method: POST, body: formData }).then(resp { if (!resp.ok) { throw new Error(HTTP resp.status); } return resp.json(); }); }fileMd5这里用了简易哈希只作为上传任务的目录标识。前端的fetch一次只发送一个分块所以服务端每次只处理几MB的数据不用管整个大文件的尺寸。3.3 断点续传的核心先查再传续传的逻辑其实不复杂上传前先请求一个状态接口服务端返回该任务已经存在哪些分块的序号前端把这些序号放进一个Set遍历分块时跳过它们。async function queryUploadedChunks() { const url upload?actionstatusfileName encodeURIComponent(file.name) fileMd5 encodeURIComponent(fileMd5); const resp await fetch(url); const data await resp.json(); return data.uploaded || []; } async function startUpload() { const totalChunks Math.ceil(fileSize / CHUNK_SIZE); const uploadedSet new Set(await queryUploadedChunks()); let uploadedBytes 0; // 先统计已传过的字节数进度条才不会从头开始 for (let i 0; i totalChunks; i) { if (uploadedSet.has(i)) { uploadedBytes Math.min(CHUNK_SIZE, fileSize - i * CHUNK_SIZE); } } updateProgress(uploadedBytes / fileSize); for (let i 0; i totalChunks; i) { if (uploadedSet.has(i)) { continue; } let ok false; for (let retry 0; retry 3; retry) { try { const data await uploadChunk(i); if (data.code 0) { ok true; break; } } catch (err) { console.log(分块 i 上传失败 err.message); } await sleep(500 * (retry 1)); } if (!ok) { alert(第 i 块上传失败请检查网络后重试); return; } uploadedBytes Math.min(CHUNK_SIZE, fileSize - i * CHUNK_SIZE); updateProgress(uploadedBytes / fileSize); } // 全部传完请求合并 const mergeUrl upload?actionmergefileName encodeURIComponent(file.name) fileMd5 encodeURIComponent(fileMd5) totalChunks totalChunks; const resp await fetch(mergeUrl, { method: POST }); const mergeData await resp.json(); document.getElementById(log).innerText mergeData.code 0 ? 合并成功 : 合并失败 mergeData.msg; } function sleep(ms) { return new Promise(resolve setTimeout(resolve, ms)); } function updateProgress(ratio) { document.getElementById(progress).innerText Math.floor(ratio * 100) %; }用uploadedBytes而不是分块序号来算进度有个好处最后一块往往不到2MB如果按分块数计算进度条会从99%突然跳到100%看起来很不自然。按字节算最后一个小块只涨一点点走势平滑。3.4 想提速就加个并发池顺序上传实现简单但速度偏慢。一个2GB的文件拆成1024块一次只传一个就算每块耗时0.2秒也要200多秒。实际场景里把并发数控制在3到5路速度提升非常明显同时不会把服务器压垮。async function uploadWithConcurrency(concurrency 3) { const totalChunks Math.ceil(fileSize / CHUNK_SIZE); const uploadedSet new Set(await queryUploadedChunks()); const pending []; for (let i 0; i totalChunks; i) { if (!uploadedSet.has(i)) { pending.push(i); } } let cursor 0; const failed []; let uploadedBytes 0; async function worker() { while (cursor pending.length) { const idx pending[cursor]; let ok false; for (let retry 0; retry 3; retry) { try { const data await uploadChunk(idx); if (data.code 0) { ok true; break; } } catch (err) {} await sleep(500 * (retry 1)); } if (!ok) { failed.push(idx); } else { uploadedBytes Math.min(CHUNK_SIZE, fileSize - idx * CHUNK_SIZE); updateProgress(uploadedBytes / fileSize); } } } await Promise.all(Array.from({ length: concurrency }, () worker())); if (failed.length 0) { throw new Error(存在失败分块 failed.join(,)); } const mergeUrl upload?actionmergefileName encodeURIComponent(file.name) fileMd5 encodeURIComponent(fileMd5) totalChunks totalChunks; await fetch(mergeUrl, { method: POST }); }并发上传时服务端接收分块的顺序和候选顺序无关但合并时按chunkIndex排序读文件所以结果是确定的。4. 后端Servlet分块接收与文件合并的实现前端再花哨最终还是要落在服务端实现上。我用一个Servlet统一处理三种动作上传分块、查询状态、合并文件。如果你的项目是Servlet 3.0及以上不需要引入Commons FileUpload直接用request.getPart就行。4.1 接口入口与整体结构Servlet的doPost根据action参数分发到不同的处理方法代码结构清晰扩展起来也方便。import javax.servlet.ServletException; import javax.servlet.annotation.MultipartConfig; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.Part; import java.io.*; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; MultipartConfig(maxFileSize 10 * 1024 * 1024, maxRequestSize 15 * 1024 * 1024) public class UploadServlet extends HttpServlet { private static final String UPLOAD_TEMP_DIR /data/upload/temp; private static final String UPLOAD_FINAL_DIR /data/upload/final; private static final MapString, Integer uploadMeta new ConcurrentHashMap(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String action request.getParameter(action); if (status.equals(action)) { handleStatus(request, response); } else if (merge.equals(action)) { handleMerge(request, response); } else { handleChunkUpload(request, response); } } }重点说一下MultipartConfig。这个注解不加的话request.getPart(file)会直接抛异常Tomcat默认不解析multipart请求体。这里的maxFileSize和maxRequestSize只对单次请求生效我们的分块是2MB设置10MB和15MB已经足够宽松不会影响大文件整体上传。4.2 分块上传幂等写入是关键上传分块时要保证“同一个分块传两次结果一致”。这个特性直接决定了断点续传能不能成立。private void handleChunkUpload(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { String fileName request.getParameter(fileName); String fileMd5 request.getParameter(fileMd5); long fileSize Long.parseLong(request.getParameter(fileSize)); int chunkIndex Integer.parseInt(request.getParameter(chunkIndex)); int totalChunks Integer.parseInt(request.getParameter(totalChunks)); File chunkDir new File(UPLOAD_TEMP_DIR, fileMd5); if (!chunkDir.exists() !chunkDir.mkdirs()) { writeJson(response, 1, 创建临时目录失败); return; } Part part request.getPart(file); File chunkFile new File(chunkDir, chunk_ chunkIndex); // 已存在且大小一致视为上传成功避免重复写入 if (chunkFile.exists() chunkFile.length() part.getSize()) { uploadMeta.put(fileMd5, totalChunks); writeJson(response, 0, 该分块已存在跳过); return; } try (InputStream in part.getInputStream(); FileOutputStream out new FileOutputStream(chunkFile)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } uploadMeta.put(fileMd5, totalChunks); writeJson(response, 0, 分块上传成功); }这个处理里有三个细节值得注意第一分块文件命名用chunk_加序号合并时按这个序号排序即可不依赖上传顺序。第二判断“已存在且大小一致”就让接口直接返回成功这是续传请求重复提交时最常见的场景。网络超时后前端重试同一个分块服务端不做重复写盘。第三uploadMeta这个Map保存了任务的元信息最简单的实现可以用ConcurrentHashMap。如果项目有多个实例部署要换成Redis或者数据库否则A实例收到的状态查询请求看不到B实例写入的分块信息。4.3 状态查询返回已上传的分块序号状态查询接口直接扫描临时目录把已存在的分块序号返回给前端。这是一种“文件系统即数据库”的思路省去了维护分块记录表的麻烦缺点是大批量任务时目录扫描慢但普通业务完全够用。private void handleStatus(HttpServletRequest request, HttpServletResponse response) throws IOException { String fileMd5 request.getParameter(fileMd5); File chunkDir new File(UPLOAD_TEMP_DIR, fileMd5); SetInteger uploaded new TreeSet(); if (chunkDir.exists()) { File[] files chunkDir.listFiles((dir, name) - name.startsWith(chunk_)); if (files ! null) { for (File f : files) { try { uploaded.add(Integer.parseInt(f.getName().substring(6))); } catch (NumberFormatException ignored) { } } } } StringBuilder sb new StringBuilder(); sb.append({\code\:0,\uploaded\:[); int i 0; for (Integer idx : uploaded) { if (i 0) sb.append(,); sb.append(idx); } sb.append(]}); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(sb.toString()); }有读者可能会问为什么不把“文件总大小”和“总分块数”也放进状态里。这些信息前端本来就有每次上传分块都会带过来没必要让状态接口多传。真正需要持久化的是“这个任务对应哪个文件名、总共多少块”这块信息可以存到uploadMeta里或者等合并时再从前端参数读取。4.4 合并分块顺序拼装加清理合并是整个流程的最后一步也是出错概率比较高的一步。合并前必须确认所有分块都齐了不然会生成一个损坏的文件。private void handleMerge(HttpServletRequest request, HttpServletResponse response) throws IOException { String fileName request.getParameter(fileName); String fileMd5 request.getParameter(fileMd5); long fileSize Long.parseLong(request.getParameter(fileSize)); int totalChunks Integer.parseInt(request.getParameter(totalChunks)); File chunkDir new File(UPLOAD_TEMP_DIR, fileMd5); if (!chunkDir.exists()) { writeJson(response, 1, 临时目录不存在请先上传分块); return; } for (int i 0; i totalChunks; i) { File chunkFile new File(chunkDir, chunk_ i); if (!chunkFile.exists()) { writeJson(response, 1, 缺少分块 i); return; } } File finalDir new File(UPLOAD_FINAL_DIR); if (!finalDir.exists() !finalDir.mkdirs()) { writeJson(response, 1, 创建最终目录失败); return; } String safeName sanitizeFileName(fileName); File targetFile new File(finalDir, safeName); if (targetFile.exists() targetFile.length() fileSize) { deleteRecursively(chunkDir); writeJson(response, 0, 文件已存在合并完成); return; } File tempTarget new File(finalDir, safeName .merging); try (FileOutputStream fos new FileOutputStream(tempTarget)) { byte[] buffer new byte[64 * 1024]; for (int i 0; i totalChunks; i) { File chunkFile new File(chunkDir, chunk_ i); try (FileInputStream fis new FileInputStream(chunkFile)) { int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } } } if (!tempTarget.renameTo(targetFile)) { writeJson(response, 1, 合并文件改名失败); return; } deleteRecursively(chunkDir); uploadMeta.remove(fileMd5); writeJson(response, 0, 合并成功); }合并文件时我用了.merging后缀的临时文件全部写完再改名成最终文件。这样做有个实际好处如果在合并过程中系统崩溃最终目录里不会出现一个残缺的正式文件下次断点续传时临时分块还在重新触发合并即可。缓冲区大小我没有用8192而用了64KB因为合并操作是纯顺序IO读写缓冲区大一点可以减少系统调用次数对大文件合并速度有明显改善。sanitizeFileName是必须的不能让用户传入的fileName直接拼接进路径否则可能出现路径穿越问题private String sanitizeFileName(String fileName) { String name new File(fileName).getName(); return name.replaceAll([\\\\/:*?\|], _); }4.5 统一的JSON响应工具写接口时出现的writeJson方法可以抽成公共方法避免每个处理方法里重复设置ContentType和编码private void writeJson(HttpServletResponse response, int code, String msg) throws IOException { response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\: code ,\msg\:\ msg \}); } private void deleteRecursively(File dir) { if (dir null || !dir.exists()) return; File[] files dir.listFiles(); if (files ! null) { for (File f : files) { if (f.isDirectory()) { deleteRecursively(f); } else { f.delete(); } } } dir.delete(); }到这里一个完整的“前端切块上传、后端按块接收、断点续传、按序合并”的闭环就跑通了。5. 实战中的边界问题与我踩过的几个坑代码能跑通只是第一步实际部署环境下会遇到很多平时想不到的边界情况。下面这几个问题我在不同项目里都真实踩过。5.1 “长度相同”不等于“内容相同”前面分块上传时我用“分块文件已存在且大小一致”来判断是否跳过。这在绝大多数情况下是安全的因为同一个任务ID下的分块序号对应的内容在重复提交时是相同的。但有一个例外如果两个不同内容的文件碰巧生成了相同的fileMd5简易哈希后者的分块就会覆盖前者长度恰好一致的话还会被误判为“已存在”。这种事概率很低但一旦发生就是数据损坏事故。要彻底规避有两个方向一是把fileMd5换成真正的文件内容MD5前端用SparkMD5库在Web Worker里计算成本高一些但最可靠二是后端在合并完成后对整个目标文件做一次MD5与前端上传时预留的校验值对比不一致就删除重传。内容MD5的计算其实不复杂public static String md5(File file) throws IOException { MessageDigest md MessageDigest.getInstance(MD5); try (FileInputStream in new FileInputStream(file)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { md.update(buffer, 0, len); } } StringBuilder sb new StringBuilder(); for (byte b : md.digest()) { sb.append(String.format(%02x, b)); } return sb.toString(); }做内容秒传、内容去重、数据完整性校验都绕不开这个MD5。5.2 合并期间又有分块上传怎么办前端逻辑里合并请求一定是在所有分块都传完之后才发但这并不意味着服务端不会遇到“正在合并又来了一个分块请求”的情况。可能是用户开了两个页面操作同一个文件也可能是前端重试机制在合并阶段仍然触发。最简单的防线是在合并期间给任务加一个状态标记合并开始时将这个fileMd5标记为“合并中”上传分块接口遇到合并中状态直接返回提示。合并完成后清理标记。如果服务端是单实例部署用一个ConcurrentHashMap存状态就够。5.3 临时目录会被“传了一半的任务”塞满断点续传虽然好但也意味着临时分块会长期停留在磁盘上。如果一个用户上传到60%直接关闭浏览器那些分块就留在临时目录里了。业务时间一长这个目录会膨胀得非常快。我的处理方式是加一个定时清理任务定期扫描临时目录超过一定时间比如24小时没有对应的合并记录就整体删除。删除前判断一下目录内文件的最后修改时间避免误删正在上传的任务。另外要强调临时分块目录和最终文件目录最好放在同一个磁盘分区。合并完用renameTo本质上是在同一文件系统里改名字速度极快。如果跨分区那就变成了先拷贝再删除2GB的文件合并会额外花不少时间还容易碰到磁盘空间不足的问题。5.4 老浏览器对File.slice的兼容问题现代浏览器里file.slice(start, end)没有任何问题但如果你的系统还要兼容老旧浏览器需要注意File.slice在早期实现里有webkitSlice和mozSlice的兼容写法。另外老旧的浏览器不支持fetch这时候要把请求换成XMLHttpRequest。我在一个兼容到Windows老版本浏览器的项目里就把上传函数封装了一层统一走XMLHttpRequest反而更稳妥因为它自带upload.onprogress事件进度百分比都不用自己算。5.5 文件类型与安全过滤大文件上传接口如果完全不对文件做约束很容易被用来做坏事。我在实际项目里至少做三层校验登录态校验、文件扩展名白名单、文件大小上限。扩展名白名单过滤的是可执行脚本和危险文件类型比如jsp、jspx、exe、sh。即使系统是内部工具也不能把上传接口裸奔在公网上。6. 个人经验这套方案的适用边界与后续扩展写完代码说说我在真实项目里使用这套方案的一些体会。这套方案最适合的场景是传统Java Web项目、内网部署、文件大小在几百MB到几个GB之间、网络相对稳定但不能保证不中断。我在某内部管理系统中用2MB分块加3路并发大约10分钟传完1.8GB的压缩包比原来的单请求上传快了很多而且中途断网后续传不需要重新开始体验完全是质变。但如果在云环境或者公网场景我的建议是优先看看对象存储的分片上传能力很多云原生方案提供了底层SDK断点续传、秒传、服务端加密都封装好了没有必要自己造轮子。这套代码的价值在于零外部依赖任何跑着Tomcat的机器都能直接复现适合预算有限、不想引入私有SDK的老项目。后续扩展方向也很明确。如果流量大、多实例部署把uploadMeta换成Redis状态查询就用Redis的Set记录已完成分块序号合并请求通过分布式锁防止多个实例同时合并同一个任务。如果想做秒传上传前先查目标文件MD5是否已经在最终目录存在就直接跳过上传返回文件地址。如果合并很耗时可以改成异步合并前端轮询合并状态接口合并完成后通知用户避免大文件合并时接口超时。最后说一个容易被忽略的调优细节分块上传请求虽然小但HTTP握手本身有成本如果项目内网带宽很高可以把分块大小调到5MB甚至8MB明显减少请求总数。反之如果用户经常在公司网络和移动网络之间切换弱网下分块越小越稳1MB分块搭配3路重试传一半切网也能继续跑。参数没有绝对最优得看现场环境。