JSP大文件分段上传与断点续传完整实战指南
JSP项目里碰上大文件上传大概是每个做Web开发的人都会头疼一阵子的事。不管是上传视频、压缩包、还是设计图纸只要文件一上G传统的那种一次性提交表单的方式基本就宣告失效了。我之前在维护某管理系统时也踩过这个坑上传几个G的影像资料动不动就超时中断用户体验极差服务器日志也刷得没法看。后来花了几天时间重新梳理方案把分段上传和断点续传做进了基于JSP的老项目里才算把这个问题彻底解决掉。这篇文章就围绕JSP网页如何实现大文件的分段上传与续传聊一聊我的设计思路、核心代码片段、参数选型逻辑以及实际部署中容易掉进去的坑。如果你也正被这类需求卡住或者想给手上的老项目打一个“补丁”式的上传方案这篇文章应该能给你一个完整的落地参考。1. 整体设计与思路拆解为什么非要分段1.1 传统表单上传在什么情况下会失效只要用过enctypemultipart/form-data的表单直接传文件肯定遇见过这类提示“请求被服务器拒绝”“连接被重置”“文件过大服务器返回500”。原因其实不在文件本身而是Web容器和HTTP报文处理方式对请求体大小和超时时间有硬性限制。拿常见的Tomcat举例maxPostSize默认值是2MBmaxSwallowSize默认也是2MB如果不调整传个超过2MB的请求体中间件就会直接拒绝。就算你手动调高了参数把限制加到几GB另一个问题也会立刻暴露出来客户端和服务端之间的网络连接只要抖动一下整个请求就废了所有已传输的字节全都白费。更糟的是multipart/form-data是整体解析的服务端必须等到整个请求体接收完毕才能开始解析内存和临时文件占用都非常难看。所以核心矛盾就一句话HTTP服务器和浏览器在处理超大请求体时既有限制又缺乏容错。而分段上传的核心思嗯路就是绕开“一次性提交一个大请求体”把大文件切成若干个小块让小请求体去适应服务器的限制同时为每一块单独记录进度哪一块断了就重传哪一块——这就是断点续传能实现的基础。1.2 分段上传和断点续传的技术本质及选型分段上传分片上传本质上就是把文件切割成多个Blob逐个提交给服务端再由服务端按顺序拼接成完整文件。断点续传则是基于分段上传衍生出来的能力——文件被切好了每一块都有编号和标识如果中途断了下次上传时先问一下服务端“哪些块已经收到了”然后只上传缺失的部分。反过来如果直接做“断点续传”而不做“分段”是没有实际意义的。因为HTTP请求要么完成要么失败没有“停在中间某个字节”的概念。所以分段上传和断点续传是一套方案里的两个层次前端负责切片、编号、发送、查询进度后端负责接收分片、暂存分片、合并分片、记录状态。在工具选型上我们当时尝试过引入现成的上传组件比如WebUploader、Plupload这类库。说实话这类组件确实方便但由于依赖的生态比较老在JSP传统项目里集成时遇到了不少CSS冲突和脚本兼容问题反而拖慢进度。最后我决定自己在原生JS里实现配合几行封装好的函数清爽、可控、不背历史包袱。当然如果你的项目前端已经用了较新的工具链想用现成库的封装也是可以的核心逻辑与这篇文章讲的原理是一致的。提示本文展示的方案不依赖任何第三方上传库前端是原生JavaScript后端是Java Servlet 3.1 JSP兼容绝大多数老项目。1.3 整体架构预览浏览器、JSP页面、Servlet分别承担什么整个流程是“前端切片 服务端收片 状态查询 合并”四段式。每一段的分工可以大致这样划分环节承担角色核心职责浏览器端原生JS脚本读取文件、切片、串行/并发发送分片、展示进度、重试JSP页面展示层提供上传界面、展示上传状态列表、与后端交互Servlet接收与存储接收分片、保存到临时目录、记录分片到达状态合并机制服务端触发所有分片到齐后按顺序合并为完整文件这套架构顺手解决了一个很实际的工程问题JSP页面只是“壳”真正的上传接口是Servlet页面与接口之间用标准HTTP协议处理所以哪怕以后前端换成别的框架后端接口逻辑完全不需要重写。这是个很值得养成的习惯——不要把业务处理堆在JSP脚本里。2. 前端切片实现FileReader和Blob.slice的正确用法2.1 切片大小定多少合适为什么别贪大也别贪小在真正写代码之前先得定一个关键参数分片大小。我最初图省事定了10MB一片结果在弱网环境测试时发现一旦网络波动10MB的块也容易超时重传成本不小。后来改成2MB一片虽然请求数变多了但每个请求都能快速完成整体体验反而更好。这里推荐一个经验区间2MB到5MB之间比较稳妥。如果太多如50MB一片就失去了分段的优势跟直接传整个文件差别不大。如果过小如64KB一片请求数量就会爆炸Tomcat会忙于创建线程处理海量请求反而导致整体吞吐下降。切片的核心是Blob.slice()这个API在IE9时代就存在了兼容性极好。切好后用FileReader读取为ArrayBuffer或者直接以Blob形式放进FormData发送也是可行的。很多老教程喜欢用FileReader.readAsDataURL再转二进制这会有额外的base64膨胀约33%的流量我推荐直接用formData.append(file, blob, filename)这种方式把Blob直接放进表单底层是二进制流没有多余开销。2.2 前端分片与发送的核心代码示例下面这段是我们在JSP页面里实际使用的切片和发送逻辑。它做了三件事记录文件标识、按固定大小切片、用XMLHttpRequest逐个上传。function sliceAndUpload(file, fileMd5, chunkSize) { var chunks Math.ceil(file.size / chunkSize); var currentChunk 0; uploadNextChunk(file, fileMd5, chunkSize, currentChunk, chunks); } function uploadNextChunk(file, fileMd5, chunkSize, currentChunk, totalChunks) { var blob file.slice(currentChunk * chunkSize, (currentChunk 1) * chunkSize); var fd new FormData(); fd.append(file, blob, file.name); fd.append(fileMd5, fileMd5); fd.append(chunkIndex, currentChunk); fd.append(totalChunks, totalChunks); var xhr new XMLHttpRequest(); xhr.open(POST, uploadServlet, true); xhr.timeout 30000; xhr.onload function () { if (xhr.status 200) { var resp JSON.parse(xhr.responseText); if (resp.ok) { currentChunk; if (currentChunk totalChunks) { uploadNextChunk(file, fileMd5, chunkSize, currentChunk, totalChunks); } else { // 所有分片上传完成通知服务端合并 doMergeRequest(fileMd5, file.name); } } else { alert(第 (currentChunk 1) 块上传失败 resp.msg); } } }; xhr.onerror function () { // 网络异常直接重试当前块 uploadNextChunk(file, fileMd5, chunkSize, currentChunk, totalChunks); }; xhr.send(fd); }2.3 进度条与并发控制的取舍传统文件上传的进度条很好做因为只有一个请求监听XHR的upload.onprogress即可。但分段上传时进度条的计算公式变成了已上传分片数 / 总分片数也可以结合每个分片的实际字节数加权计算更精准。关于并发我尝试过“每次发3个分片”的方案吞吐量确实比串行高但随之而来的是服务端并发写临时文件的压力以及Tomcat线程占用。做了一阵子AB对比之后我更推荐在“内网环境”用并发在“公网弱网环境”用串行。又想省时间又想稳可以采用一个折中策略并发数限制为2。如果服务器配置比较好内存充足最多建议并发到3不要更高了。分段的好处已经解决了稳定性的核心矛盾并发只是锦上添花。注意上传分片用了xhr.timeout 30000这是一个比较稳妥的超时设置既不会让用户等待过久也不会因为网络抖动频繁中断。如果你在移动端弱网环境可适当放宽到60秒。3. JSP后端如何接收和暂存分片3.1 为什么用Servlet而不是JSP直接处理理论上JSP页面直接通过内置对象接收请求也能处理上传——它本身就是一个Servlet。但实际搞过的人都知道JSP的职责应当是生成页面而不是处理业务。把文件分片的接收逻辑写进JSP不仅不利于调试还会让代码维护变得痛苦。所以我在项目中单独写了一个UploadServlet并在web.xml中注册。JSP页面只用一段JavaScript发起请求后台对应的Java类接收分片数据并把每个分片写入磁盘临时目录。这个分工是从工程可维护性出发做的决定也算是一次不错的实践示范。Servlet接收分片用Apach common-fileupload也可以但更轻量的是Servlet 3.1提供的Part接口。在web.xml或者Servlet注解里只需配置好临时目录和大小限制——当然分片本身不大常规限制已经够用。3.2 分片标识设计简单MD5能解决多大问题分段上传最核心的难点是服务端如何识别“这些分片属于同一个文件”。前端的每个分片都带上了三个参数fileMd5、chunkIndex、totalChunks。fileMd5是全文件的唯一标识。有人问为什么不能直接用文件名因为不同目录下可能存在同名文件而同一个文件也可能被用户改名重新上传。用文件名做标识会遇到各种边界情况而MD5值更稳定。当然前端的MD5计算需要加载一个独立的库比如spark-md5。如果不想引入任何第三方库也可以用自定义的简易字符串标识时间戳文件大小文件名组合但唯一性保障会弱一些。计算出全文件的MD5在客户端进行不用传给后端后端只需把前端算好的值作为目录名使用。服务端存储分片的目录结构大概是这样的upload_temp/ staf_upload_20240615/ chunks/ 0.part 1.part 2.part 3.part upload.info (记录状态)3.3 服务端分片接收与状态保存代码下面给出核心的UploadServlet代码只保留了主干逻辑适合快速集成。WebServlet(/uploadServlet) MultipartConfig( fileSizeThreshold 1024 * 1024 * 2, maxFileSize 1024 * 1024 * 10, maxRequestSize 1024 * 1024 * 10 ) public class UploadServlet extends HttpServlet { private static final String TEMP_DIR /upload_temp/; protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { request.setCharacterEncoding(UTF-8); response.setContentType(application/json;charsetUTF-8); PrintWriter out response.getWriter(); JSONObject result new JSONObject(); // 可用fastjson或手写响应 String fileMd5 request.getParameter(fileMd5); int chunkIndex Integer.parseInt(request.getParameter(chunkIndex)); int totalChunks Integer.parseInt(request.getParameter(totalChunks)); // 每个文件一个独立子目录避免多个文件分片混在一起 String fileDir TEMP_DIR fileMd5 /chunks/; File dir new File(fileDir); if (!dir.exists()) { dir.mkdirs(); } Part part request.getPart(file); String suffix part.getSubmittedFileName(); // 保存分片命名规则索引.part String chunkPath fileDir chunkIndex .part; part.write(chunkPath); // 记录上传状态已到达的分片索引追加到文件 File infoFile new File(TEMP_DIR fileMd5 /upload.info); FileWriter fw new FileWriter(infoFile, true); fw.write(chunkIndex \n); fw.close(); result.put(ok, true); out.write(result.toJSONString()); } }这段代码在关键部分做了两件事接收分片存为0.part、1.part再把收到的分片编号追加到upload.info。这个记录文件就是续传时用作查询进度的依据。提示这里的MultipartConfig限制要合理设好。maxFileSize是单个Part的上限既然分片已经切到2MB级别这里给到10MB留个余量即可。maxRequestSize是整个请求体的上限也需要给到10MB左右因为除了文件还有其它参数。3.4 临时文件的落地方式和磁盘IO优化写入分片时容易踩一个隐形坑每写一个分片就新建FileWriter往upload.info追加内容在高并发下可能会产生IO阻塞。实测并发数高的时候这种频繁写状态文件的做法会成为瓶颈。优化思路有两个方向一是把状态维护放到内存里用缓存保存fileMd5到已接收分片集合的映射定期持久化或等合并完成后删除二是把状态写入SQLite之类的小型库文件。考虑到这套方案面向的是JSP老项目很多环境连额外软件都不方便安装内存Map加文件持久化是最划算的选择。以下重点内存Map会带来“状态丢失”风险。如果Servlet重启所有内存里的分片状态就没了。所以同时还要定期扫描临时目录比对现有分片数量和upload.info重建内存状态。这是一个需要兼顾的折中方案。4. 断点续传的状态查询与合并流程4.1 客户端如何判断哪些分片已上传有了upload.info续传的思路立刻清晰起来前端在开始上传前会先发起一个GET请求问后端“这个文件的哪些分片已存在”查询接口设计得非常简单。前端把fileMd5传过去后端遍历对应的chunks目录找出已存在的.part文件返回一个数字数组function checkUploadedChunks(fileMd5) { return fetch(checkUploadServlet?fileMd5 fileMd5) .then(function (resp) { return resp.json(); }) .then(function (data) { return data.existChunks || []; }); }拿到已存在的分片序号前端在切片发送循环里直接跳过这些分片从断裂的地方继续。这就是断点续传最核心的本质。它不依赖于任何复杂的连接保持机制只是重新发起HTTP请求把未完成的工作做完。4.2 分片合并的最佳时机和服务端实现什么时候合并文件最简单粗暴的策略是前端发送完最后一个分片后额外发一次合并请求。这也是方案里最直观、最可控的一种。合并服务的实现思路如下校验upload.info中记录的分片索引是否覆盖0到totalChunks-1如果全部齐了按索引顺序读取0.part、1.part... 依次写入目标文件合并完成后删除整个临时目录如果没齐返回提示“缺失的分片编号”前端补传。合并逻辑的核心代码public void mergeChunks(String fileMd5, String fileName, int totalChunks) throws IOException { String chunkDir TEMP_DIR fileMd5 /chunks; String targetDir /upload_final/; File targetFile new File(targetDir fileName); // 防重复如果目标文件已存在加时间戳 if (targetFile.exists()) { targetFile new File(targetDir System.currentTimeMillis() _ fileName); } try (FileOutputStream fos new FileOutputStream(targetFile)) { for (int i 0; i totalChunks; i) { File partFile new File(chunkDir, i .part); if (!partFile.exists()) { throw new IOException(缺失分片: i); } byte[] buffer new byte[1024 * 1024]; try (FileInputStream fis new FileInputStream(partFile)) { int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } partFile.delete(); } } // 删除整个chunks目录和状态文件 deleteDirectory(new File(TEMP_DIR fileMd5)); }4.3 大文件合并速度和安全性的平衡合并是一个IO密集操作。如果机械硬盘的顺序写入速度在150MB/s左右一个2GB文件合并耗时大概10到15秒。这个时间可以接受但要注意合并期间不要有重复的合并请求同时进入。解决双重合并风险的办法很简单用一个ConcurrentHashMap记录正在合并中的fileMd5。如果发现某个文件的合并任务已在执行直接返回“合并中”不再启动新的合并线程。另外合并后的文件校验也很关键。每个分片上传时可以在分片Part写入临时目录后计算它的MD5值并保存到信息文件。合并完成后再计算整个目标文件的MD5如果和前端初始传来的全文件MD5一致说明文件完全可靠。如果不一致大概率是某次网络传输中出现了静默损坏。这个校验逻辑对文件完整性要求高的业务不是可选项而是必须项。5. 常见问题与排查技巧实录5.1 Tomcat配置和JSP工程里的那些坑实际搭建这套方案时我遇到过几个高频问题整理成了一张速查表现象原因解决方案请求直接返回413/500Tomcat maxPostSize太小或maxSwallowSize太小调整server.xml中的maxPostSize和maxSwallowSize上传后文件内容是乱的或可读性差分片顺序被并发搞乱串行发送或并发时加入序号并等待全部到达后再按序号合并JSP页面中调用上传接口跨域如果前后端分离部署在Servlet中配置CORS响应头合并后文件比原文件小某些分片没有真正写完整检查磁盘空间合并前校验分片数量和大小之和等于原文件上传进度条卡在99%最后一个分片可能上传成功但合并请求失败服务端合并逻辑打印异常并给前端明确的错误反馈5.2 前端JavaScript与后端编码不一致问题上传中文文件名时我曾经在JSP页面收到???这样的乱码。查了半天发现是request.getParameter(fileName)的编码问题。Servlet端要统一设置request.setCharacterEncoding(UTF-8)同时JSP页面charset也必须是UTF-8。再加上Tomcat的URIEncodingUTF-8三层对齐中文才不会再乱。分片的文件名我完全用数字索引命名规避中文问题。但合并后的最终文件名是从前端传过来的所以这块必须解决好编码。5.3 临时目录清理没做好会让你磁盘爆掉分片上传的临时文件占用的空间是完整的文件大小乘以并发数。如果用户体验很差上传了60%就关闭页面临时目录里的分片会一直躺在磁盘上。如果没人清理一个月下来会累计很多脏数据。清理策略我建议做三档第一档每次Servlet初始化时扫描临时目录删除超过24小时的目录第二档合并成功后立即删除对应临时目录第三档给临时上传文件单独设定挂载磁盘配额。用定时任务跑前两步即可不用专门引框架。我见过一个生产事故就是因为临时目录没清理磁盘满了以后整个应用所有的上传功能全部瘫痪连日志都写不进去。这种问题不提前预防出事时是很被动的。5.4 网络断开后自动重试的“背书”机制我们的最终方案里加入了一个“自动重试”机制。前端捕获到xhr.onerror时不直接提示上传失败而是对该分片做最多3次重试。每次重试间隔按指数退避算法递增分别是1秒、2秒、4秒。连续3次失败之后再告知用户明确原因。这样做的好处很直观用户不需要手动反复点“重试”弱网环境下自动恢复的概率非常高。实测下来有约70%的断点都可以通过一次自动重试恢复对整体体验的提升非常显著。6. 完整示例整合从JSP页面到后端的一站式实现6.1 前端JSP页面完整示例把上面的知识点整合起来一个可以直接复制的upload.jsp页面大致长这样省略了CSS美化% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8% !DOCTYPE html html head meta charsetUTF-8 title大文件分段上传示例/title script src/js/spark-md5.min.js/script /head body h3选择文件进行分段上传/h3 input typefile idfileInput button onclickstartUpload()开始上传/button div idprogressBar stylewidth:100%;border:1px solid #ccc;margin-top:10px; div idinnerBar stylewidth:0%;height:20px;background:#4CAF50;0%/div /div script var CHUNK_SIZE 2 * 1024 * 1024; function getFileMd5(file, callback) { var reader new FileReader(); var spark new SparkMD5.ArrayBuffer(); var chunks Math.ceil(file.size / CHUNK_SIZE); var current 0; function readNext() { var blob file.slice(current * CHUNK_SIZE, (current 1) * CHUNK_SIZE); reader.readAsArrayBuffer(blob); } reader.onload function (e) { spark.append(e.target.result); current; if (current chunks) { readNext(); } else { callback(spark.end()); } }; readNext(); } function startUpload() { var file document.getElementById(fileInput).files[0]; if (!file) return; getFileMd5(file, function (md5) { // 先查哪些分片已存在跳过它们 fetch(checkUploadServlet?fileMd5 md5) .then(function (resp) { return resp.json(); }) .then(function (data) { var existChunks data.existChunks || []; var chunks Math.ceil(file.size / CHUNK_SIZE); var index 0; function sendNext() { while (index chunks existChunks.indexOf(index) ! -1) { index; } if (index chunks) { // 全传过了直接合并 fetch(mergeServlet?fileMd5 md5 fileName encodeURIComponent(file.name) totalChunks chunks); updateProgress(100); return; } var blob file.slice(index * CHUNK_SIZE, (index 1) * CHUNK_SIZE); var fd new FormData(); fd.append(file, blob, file.name); fd.append(fileMd5, md5); fd.append(chunkIndex, index); fd.append(totalChunks, chunks); var xhr new XMLHttpRequest(); xhr.open(POST, uploadServlet, true); xhr.timeout 30000; xhr.onload function () { if (xhr.status 200) { var json JSON.parse(xhr.responseText); if (json.ok) { index; updateProgress(Math.round(index / chunks * 100)); sendNext(); } else { alert(json.msg); } } }; xhr.onerror function () { sendNext(); // 自动重传当前块 }; xhr.send(fd); } sendNext(); }); }); } function updateProgress(percent) { var bar document.getElementById(innerBar); bar.style.width percent %; bar.innerHTML percent %; } /script /body /html注意我在合并完成后把进度直接设成100%。实际业务里文件上传完可能存在缓冲转码之类的后续处理那时候需要一个更丰富的状态机这个示例先做到上传这一层。6.2 后端Servlet组合与web.xml配置后端的UploadServlet、CheckUploadServlet、MergeServlet三个Servlet分别承担接收分片、查询进度、合并文件三个任务。web.xml里配置解码和默认servlet路径就可以如果是Servlet 3.0以上直接用WebServlet注解就行连web.xml映射都省了。需要注意的一点是如果Tomcat是8.5以上maxPostSize可以设为 -1 表示不限制但为了安全性我们要用一个明确的值比如20MB防止有人用超大请求袭击。Context allowCasualMultipartParsingtrue maxPostSize20971520 /这一行的效果是Tomcat层面允许容器解析multipart且请求体上限在20MB。因为分片已经被控制在2MB左右算上Base64编码等额外开销20MB足够充裕而且能将异常恶意请求拒之门外。6.3 部署和压测时需要观察哪些关键指标上线前建议做一轮针对性的压测模拟真实场景下的多文件多分片并发。需要重点观察的指标包括磁盘IO带宽是否打满Tomcat线程数是否飙到maxThreads上限内存中的临时分片状态Map会不会太大合并操作的耗时是否超出预期网络错误后自动重试对带宽的额外损耗比例。我用一个脚本并发上传10个200MB文件每个2MB分片相当于1000个请求。后端如果是普通的机械硬盘极限状态大约每秒能处理30到50个分片请求。如果发现IO等待过高优先优化分片大小和并发数而不是直接升配置。7. 实测感悟与后续扩展方向7.1 这个方案在老项目里落地最核心的三个经验第一分片状态维护尽量独立且简单。不要指望在JSP页面里写复杂的业务逻辑前端只负责发送后端只负责存储和拼接中间通过标准和明确的HTTP接口通信——这样出了问题一查日志就能定位。第二不要轻信文件大小判断完整性。很多上传方案仅仅检查文件大小就认为上传成功但网络传输过程中数据变化很难察觉。对数据完整性要求高的场景必须做MD5校验。第三用户体验的痛点在续传而不是上传速度。用户真正不满意的不是上传慢而是上传失败后要全部重新来一次。能断点续传愿意等的人会多很多。7.2 后续可以做哪些增强这套方案本身是一个完善的基座后续扩展空间很大。如果项目有云存储可以把分片直接转发到对象存储服务端不落盘。如果前端进阶可以改成Web Worker后台线程处理MD5计算和切片避免界面卡顿。如果需要多用户场景可以在分片目录里加上用户ID和业务单据ID做多维度隔离。我还在这个基础上为某个模拟项目X加了一个“分片秒传”的逻辑如果后端已经存在同一个fileMd5文件前端直接跳过全部上传流程提示“秒传完成”。这个逻辑只需要在CheckUploadServlet里加一个判断如果目标目录已存在完整文件返回一个skipAll: true。老项目的用户看到秒传提示时都觉得自己在用很“新潮”的产品其实底子就是一套老实的文件校验。7.3 写在最后的建议如果你正在改造一个带大文件上传需求的JSP项目不要急着从网上找现成组件整包接入先花半天时间理清楚自己的需求文件平均多大、网络条件如何、是否需要并发、是否需要秒传。需求搞清楚了用这篇文章里的思路自己拼一套方案出来以后维护的时候你会感谢当初的自己。分段上传 断点续传并不是什么高不可攀的黑科技它就是一系列熟悉工具的组合使用。把大问题拆成小问题再把小问题逐一解决——做技术也好解决工程问题也好本质都是这样。