资讯中心

JSP+Servlet大文件分块上传:基于WebUploader的断点续传与秒传实践

📅 2026/9/26 13:40:15
JSP+Servlet大文件分块上传:基于WebUploader的断点续传与秒传实践
做Java Web的老哥们应该都遇到过这种需求页面上传大文件用户网速又不给力传一半断了还得从头再来。以前我处理这种需求一般就是后端搞个Servlet接MultipartFile前端放一个file input完事。直到有一次被一个2G左右的压缩包折磨到怀疑人生才开始认真研究分块上传。这次把完整实践记录整理出来项目背景是传统JSP Servlet前端用的百度开源组件WebUploader这套方案目前在我的项目里跑得很稳分享给同样还在维护JSP项目的朋友。1. 为什么是WebUploader JSP这套组合1.1 传统上传方案的三大硬伤先说传统表单上传的问题。用原生form enctypemultipart/form-data上传大文件时浏览器会把整个文件一次性塞进请求体里这会导致三个很现实的问题。第一个是超时。Tomcat默认的连接超时是60秒左右加上Nginx层还有proxy_read_timeout用户网速稍微慢点一个大文件传不完连接就被服务端掐了。前端收到的是网络错误用户看到的是“上传失败”谁也不知道已经传了80%。第二个是内存和临时文件压力。Servlet容器接收multipart请求时如果文件较大要么全部载入内存要么落盘到临时目录。Tomcat默认的maxPostSize是2MB超过这个值POST请求会被拒绝——对你没看错传统方案你需要在server.xml里把maxPostSize调到很大但调大了又容易被恶意请求塞爆内存。高并发场景下几个大文件同时上传服务器I/O和内存双双告急。第三个是体验问题。一次请求的生命周期内你很难给用户展示“真实进度”因为你拿不到浏览器侧的实时上传字节数。传统Ajax上传虽然能做进度条但整个文件作为一个请求体进度往往是一段一段跳的到了最后突然100%观感极差。1.2 WebUploader分块能解决什么WebUploader是百度FEX团队开源的上传组件核心思路是把一个大文件切成若干个小块每个小块独立发HTTP请求。这个思路解决了三个问题。一是请求体变小每一个分块只有几MB即便maxPostSize不调大也能过当然我建议还是适当调大几MB后面会讲。每个分块是独立请求超时不至于影响整个文件失败的分块可以单独重试。二是进度精准。前端有uploadProgress事件能拿到当前文件已上传字节数除以总字节数就是真实进度1%、5%……99%一路平滑增长用户体验完全不一样。三是天然支持并发。WebUploader可以通过threads参数控制同时上传的分块数量比如设成3就是3个分块并行上传充分利用带宽比单线程快不少。实际上这就是断点续传和秒传的地基。分块ID固定服务端只要判断哪些分块已经收到通知前端跳过这些分块断点续传就成了。前端把整个文件算一个MD5发给后端后端查一下这个MD5对应的文件是否已经存在存在就直接返回成功这就是秒传。1.3 为什么还在选JSP很多刚接触Java的同学可能觉得JSP已经过时了但现实是大量老系统的前端页面就是JSP。这类项目的特点是不依赖前后端分离架构服务端渲染页面直接放在webapp目录下部署成一个war包扔进Tomcat就完事。在这种老系统里引入前端组件最怕的就是引入一套很重的Node构建链——Webpack、Vite一套下来维护成本比功能本身还大。WebUploader的定位就是免构建的jQuery风格组件直接引入CSS和JS文件用script标签加载即可跟JSP页面的协同非常自然。它可以配合jQuery可选后端只要暴露出正确定义好的接口不需要额外做复杂适配。所以不是我偏爱JSP而是这套技术栈最适合已有JSP系统的升级改造。一次性引入太激进反而容易翻车渐进式增强才是老项目改造的正确打开方式。2. 前端JSP页面初始化与源码拆解2.1 页面骨架与依赖引入先看JSP页面需要引入什么资源。WebUploader的发布包里有dist目录里面包含了webuploader.js、Uploader.swf兼容IE的Flash方案、webuploader.css等文件。我建议把这些文件直接放进项目的webapp/resources/目录下不走CDN毕竟老系统的服务器环境不一定能访问外网。% page contentTypetext/html;charsetUTF-8 languagejava % !DOCTYPE html html head meta charsetUTF-8 title大文件分块上传示例/title !-- 前后端交互依赖jQueryWebUploader原生支持 -- script src${pageContext.request.contextPath}/resources/js/jquery-3.5.1.min.js/script link relstylesheet href${pageContext.request.contextPath}/resources/webuploader/webuploader.css script src${pageContext.request.contextPath}/resources/webuploader/webuploader.min.js/script !-- MD5计算插件秒传和续传的基础 -- script src${pageContext.request.contextPath}/resources/spark-md5.min.js/script /head这里有个细节如果项目里已经用了其他引入script的方式注意顺序——jQuery必须在WebUploader之前加载。虽然WebUploader不是强依赖jQuery但它的很多示例代码和默认组件样式是基于jQuery的后续操作DOM做上传队列渲染时会方便很多。2.2 初始化参数逐项解析初始化上传器是整个前端源码的核心。我贴一份实际项目里用过、注释完整的版本var uploader WebUploader.create({ // 上传按钮的id选择器 pick: { id: #picker, // 是否多选true为多选 multiple: true }, // 上传的请求路径注意是分块上传的后端接口 server: ${pageContext.request.contextPath}/uploadServlet, // 接受的文件类型title用于展示extensions是后缀名白名单 accept: { title: 压缩包, extensions: zip,rar,7z,tar,gz, mimeTypes: application/zip,application/x-rar-compressed,application/x-7z-compressed,application/x-tar }, // 开启分块 chunked: true, // 分块大小单位字节2MB一片 chunkSize: 2 * 1024 * 1024, // 并发上传的分块数量 threads: 3, // 上传时随请求额外携带的参数这里传文件MD5 formData: { md5: }, // 文件上传时的字段名后端通过这个名称取文件 fileVal: file, // 关闭自动上传等文件参数都准备好再触发 autoUpload: false, // 单文件大小限制500MB fileSingleSizeLimit: 500 * 1024 * 1024 });几个参数的坑我逐个说。chunkSize不建议设太大也没必要太小。设太大一次请求的体量还是大失去了分块的意义设太小分块数量太多请求数量爆炸服务器要处理大量的HTTP握手反而拖慢速度。2MB到5MB是比较均衡的范围。如果网络环境特别差可以降到1MB。threads设多少要谨慎。并不是越大越好并发3到5个比较靠谱。并发太多后端如果同步写盘磁盘I/O会成为瓶颈。另外浏览器对同一域名的并发连接数有限制HTTP/1.1下Chrome最多6个并发你设10个也没有意义。fileSingleSizeLimit是单文件大小限制不是分块大小。在不限制文件大小时我建议还是设一个上限避免用户把几十G的东西往服务器上传后端的临时磁盘受不了。2.3 上传队列渲染与事件绑定WebUploader的典型交互是用户点击选择文件文件进入上传队列队列里显示每个文件的进度条然后点击“开始上传”或选择完立即上传。我们的示例中autoUpload: false所以需要手动触发。// 文件被添加到队列时触发 uploader.on(fileQueued, function (file) { // 计算文件的MD5用于秒传和断点续传 var md5 computeMd5(file); md5.done(function (hash) { uploader.options.formData.md5 hash; file.md5 hash; // 把文件信息添加到页面队列 $(#fileList).append( div id file.id classfile-item span classfilename file.name /span span classprogress/span /div ); }); }); // 文件上传过程中持续触发可用来更新进度条 uploader.on(uploadProgress, function (file, percentage) { $(# file.id .progress).text(Math.round(percentage * 100) %); }); // 文件上传成功时触发 uploader.on(uploadSuccess, function (file, response) { $(# file.id .progress).text(上传成功); if (response response.success) { // 拿到后端返回的文件最终存储路径或地址 console.log(文件访问路径 response.fileUrl); } }); // 文件上传出错时触发 uploader.on(uploadError, function (file) { $(# file.id .progress).text(上传失败点击重试); // 重新上传时只会上传失败的分块 uploader.retry(file); }); // 所有文件上传结束时触发 uploader.on(uploadFinished, function () { // 可以在这里统一提示“全部完成”并跳转或刷新列表 }); // 点击按钮开始上传 $(#btnUpload).click(function () { uploader.upload(); });这里的computeMd5函数需要借助spark-md5对文件做分片读取计算WebUploader的示例文档里有完整的实现我把可用的版本贴出来function computeMd5(file) { var deferred $.Deferred(); var blobSlice File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; var chunkSize 2 * 1024 * 1024; var chunks Math.ceil(file.size / chunkSize); var currentChunk 0; var spark new SparkMD5.ArrayBuffer(); var fileReader new FileReader(); fileReader.onload function (e) { spark.append(e.target.result); currentChunk; if (currentChunk chunks) { loadNext(); } else { deferred.resolve(spark.end()); } }; fileReader.onerror function () { deferred.reject(); }; function loadNext() { var start currentChunk * chunkSize; var end ((start chunkSize) file.size) ? file.size : start chunkSize; fileReader.readAsArrayBuffer(blobSlice.call(file, start, end)); } loadNext(); return deferred; }MD5计算是有性能开销的。一个500MB的文件在普通PC上计算MD5可能需要几秒到十几秒期间页面会显得有些“卡”。我建议把这个计算放在fileQueued阶段不要在文件选择和上传之间再加额外的阻塞操作。实际使用中如果觉得慢可以做成“可选开启秒传校验”的开关或者仅对超过某阈值的大文件计算MD5。3. 后端分块接收与合并的Servlet实现3.1 分块传输的字段约定前端的每个分块会以multipart表单形式POST到后端WebUploader默认会携带以下字段字段名含义示例值file本分块的二进制内容二进制流name原始文件名系统备份.zipchunk当前分块序号从0开始0, 1, 2...chunks总分块数20md5整个文件的MD5自定义e99a18c428cb38d...后端必须严格按照这些约定取参。注意chunk是从0开始计数的合并时循环遍历也应当从0开始这个细节容易搞错我见过不止一次因为从1开始遍历导致最后少拼一个分块的案例。3.2 接收分块源码与临时目录设计后端用Servlet接收分块我采用了Servlet 3.0的Part接口来处理multipart请求。代码里省略了包导入和异常处理的完整展开但核心逻辑都在。WebServlet(/uploadServlet) public class UploadServlet extends HttpServlet { // 文件分块临时存放的根目录 private static final String TEMP_DIR /data/fileupload/tmp; // 文件合并完成后的正式存放目录 private static final String STORE_DIR /data/fileupload/store; Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); response.setContentType(application/json;charsetUTF-8); // 固定上传目录没有则创建 File tempDir new File(TEMP_DIR); if (!tempDir.exists()) { tempDir.mkdirs(); } File storeDir new File(STORE_DIR); if (!storeDir.exists()) { storeDir.mkdirs(); } // 取前端传的文本参数 String fileName request.getParameter(name); String fileMd5 request.getParameter(md5); int chunkIndex Integer.parseInt(request.getParameter(chunk)); int chunkCount Integer.parseInt(request.getParameter(chunks)); // 从请求体里取文件分块 Part filePart request.getPart(file); String submittedFileName filePart.getSubmittedFileName(); // 分块的命名规则md5_chunkIndex.part // 用md5做前缀可以防止不同文件的分块互相干扰 String chunkFileName fileMd5 _ chunkIndex .part; File chunkFile new File(tempDir, chunkFileName); // 将分块写入临时目录 try (InputStream in filePart.getInputStream(); OutputStream out new FileOutputStream(chunkFile)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } // 判断所有分块是否都已经上传完毕 boolean allUploaded true; for (int i 0; i chunkCount; i) { File f new File(tempDir, fileMd5 _ i .part); if (!f.exists() || f.length() 0) { allUploaded false; break; } } if (allUploaded) { mergeChunks(fileMd5, fileName, chunkCount, tempDir, storeDir); } // 返回JSON告知前端处理结果 PrintWriter writer response.getWriter(); if (allUploaded) { writer.write({\success\: true, \msg\: \上传完成\, \fileUrl\: \/files/ URLEncoder.encode(fileName, UTF-8) \}); } else { writer.write({\success\: true, \msg\: \分块已接收\}); } } private void mergeChunks(String fileMd5, String fileName, int chunkCount, File tempDir, File storeDir) throws IOException { File mergedFile new File(storeDir, fileName); // 避免并发问题如果目标文件已存在且大小不为0先删除重建 if (mergedFile.exists()) { mergedFile.delete(); } try (FileOutputStream fos new FileOutputStream(mergedFile)) { byte[] buffer new byte[8192]; for (int i 0; i chunkCount; i) { File chunkFile new File(tempDir, fileMd5 _ i .part); try (FileInputStream fis new FileInputStream(chunkFile)) { int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } // 每个分块合并后立即删除减少磁盘占用 chunkFile.delete(); } } } }这个实现里有几个地方值得专门展开。第一个是分块文件名设计。我用的是md5_chunkIndex.part。相比用文件名chunkIndexMD5前缀的好处是万一用户上传了两个同名的不同文件分块不会混在一起。虽然WebUploader前端传的chunk是每个文件独立的但服务端如果只按文件名区分两个同名文件并发上传时就可能相互覆盖分块。第二个是判断“所有分块是否上传完”的方式。这段代码用的是轮询文件系统循环检查分块文件是否存在。这个方法简单可靠但存在一个小问题如果最后一个分块在判断时还没写盘完毕比如写到一半长度还是0会误判。所以我在判断里加了f.length() 0这个条件长度为零视为未完成。实际项目中并发环境里还可能遇到更极端的情况两个请求同时发现“所有分块都齐了”触发两次合并导致合并文件被重复写入。解决方式通常是对合并过程加锁或者用一个状态文件标记“合并中/已合并”。建议同步控制private void mergeChunks(String fileMd5, String fileName, int chunkCount, File tempDir, File storeDir) throws IOException { // 使用一个锁文件或synchronized块控制合并并发 String lockKey fileMd5.intern(); synchronized (lockKey) { // 合并逻辑 } }synchronized (fileMd5.intern())是一种常见的轻量级方案如果不放心可以换分布式锁。这里只是单体JSP项目用synchronized已经足够。3.3 合并后的完整性校验合并文件写完之后最理想的情况是再对合并后的文件算一次MD5与前端传来的md5对比一致才算真正成功。这是最后一道安全防线能防止传输过程中分块丢失或损坏。private String calcMd5(File file) throws IOException { try (FileInputStream fis new FileInputStream(file)) { byte[] buffer new byte[8192]; int len; MessageDigest digest MessageDigest.getInstance(MD5); while ((len fis.read(buffer)) ! -1) { digest.update(buffer, 0, len); } byte[] bytes digest.digest(); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new IOException(MD5算法不可用, e); } }合并后MD5校验有个性能问题大文件算一遍MD5需要时间这会让“最后一个分块上传请求”的响应时间变长。前端WebUploader等待这个请求返回时可能会因为响应太慢而产生超时假象。所以如果对性能敏感可以把合并和校验交给异步任务先回复前端“分块接收完毕”等异步任务完成后通过回调通知前端。不过本文示例偏简单直接同步处理也能接受前提是你知道这个延迟在哪。4. 断点续传与秒传的玩法扩展4.1 前置条件文件MD5必须前端算好分块上传本身是“打散再拼装”但断点续传和秒传都需要一个统一的文件标识。我在第2节写的computeMd5就是这个基础。没有MD5服务端不知道哪些分块属于“同一个文件”也就无法告诉前端“哪些分块已经不用传了”。需要强调一个现实问题前端计算MD5是读取本地文件内容大文件超过1GB计算时间会比较长。而且WebUploader的chunked模式是默认不计算整个文件MD5的只有你主动加spark-md5插件才能拿到完整文件的哈希值。有的项目为了省事把MD5计算放到后端合并时做但这样断点续传就做不了——因为续传要前端先告诉后端“我上次传过这个文件”而后端只有合并完才知道完整文件的MD5逻辑上就矛盾了。所以MD5计算放前端是断点续传的必然选择。4.2 秒传与续传的接口思路秒传和续传在后端实现上是同一个接口checkFileServlet。前端在选择文件后先向后端发送整个文件的MD5后端查询该MD5是否已存在。WebServlet(/checkFileServlet) public class CheckFileServlet extends HttpServlet { Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String md5 request.getParameter(md5); String fileName request.getParameter(name); response.setContentType(application/json;charsetUTF-8); PrintWriter writer response.getWriter(); // 检查正式存储目录是否已存在同名且MD5一致的文件 File storeDir new File(/data/fileupload/store); File target new File(storeDir, fileName); JSONObject result new JSONObject(); if (target.exists() target.length() 0) { String fileMd5 calcMd5(target); if (fileMd5.equalsIgnoreCase(md5)) { // 相同文件已存在直接秒传 result.put(exist, true); result.put(fileUrl, /files/ fileName); } else { // 文件名相同但内容不同提示用户或改名 result.put(exist, false); } } else { // 文件不存在检查临时目录已有哪些分块 ListInteger uploadedChunks new ArrayList(); File tempDir new File(/data/fileupload/tmp); File[] chunkFiles tempDir.listFiles((dir, name) - name.startsWith(md5 _) name.endsWith(.part)); if (chunkFiles ! null) { for (File f : chunkFiles) { String name f.getName(); String numStr name.substring(md5.length() 1, name.length() - .part.length()); uploadedChunks.add(Integer.parseInt(numStr)); } result.put(exist, false); result.put(uploadedChunks, uploadedChunks); } } writer.write(result.toJSONString()); } }前端拿到checkFileServlet返回结果后分两种情况处理。如果是秒传exist true直接提示“上传成功”不用进入上传流程。如果是续传exist false但uploadedChunks有数据需要把已上传的分块记录下来。WebUploader支持在初始化时指定chunked模式下的skipChunks但更通用的做法是让后端接口在接收分块时判断如果这个分块已经存在且大小一致直接返回成功不再重复接收。这样前端无需特别处理——它只管把分块全部发一遍后端自动跳过已存在的分块。我用的是这个方案逻辑更简单对前端代码零侵入。4.3 把整套逻辑串起来完整的上传流程现在就很清晰了第一步用户选择文件fileQueued触发。 第二步前端计算文件MD5同时向后端checkFileServlet发查询请求。 第三步后端返回秒传结果或已上传分块列表。 第四步前端初始化上传队列。如果是秒传直接结束否则进入分块上传流程WebUploader按chunkSize切块并发发送到uploadServlet。 第五步后端每接到一个分块就写入临时目录同时检查是否所有分块齐全。齐全时触发合并。 第六步合并完成后删除临时分块返回最终下载地址。整个链路里最容易被忽略的是“用户中途关闭页面”的场景。分块上传意味着用户在任意时刻关闭浏览器服务器临时目录里都会残留若干.part文件。如果用户不再回来续传这些文件就永远躺在磁盘上。我建议写一个定时任务比如每天凌晨4点扫描临时目录删除最后修改时间超过7天的.part文件防止磁盘被垃圾分块塞满。5. 分块上传的排障记录与经验速查表5.1 高频问题与解决对照表这一节整理了我实际踩过的坑和排查方法有些问题让人印象很深因为排查过程非常痛苦。问题现象可能原因解决方式Nginx返回413 Request Entity Too LargeNginx默认client_max_body_size为1MB分块虽小但并发上传时可能超限在Nginx的server或location块中设置client_max_body_size 10m;Tomcat直接拒绝POST请求maxPostSize默认2MB的限制在server.xml的Connector中调大maxPostSize或者直接用Servlet 3.0的Part接口不走表单参数注意仍需调大合并后的文件损坏大小不对分块序号从0开始合并循环起始数字错误或分块并发写入时互相覆盖核对chunk从0开始分块文件名中加入MD5前缀上传进度条卡住不动文件太大前端MD5计算期间没有响应或startUpload触发时机不对把MD5计算放在fileQueued阶段使用deferred异步处理避免阻塞UI最后一个分块上传特别慢合并文件时同步计算了整文件MD5阻塞了请求合并和校验放在后台异步任务不阻塞当前请求的返回上传成功但后端找不到文件WebUploader传的参数名和后端不匹配检查fileVal设置、request.getParameter(name)和chunk参数是否都能取到刷新页面后重启上传进度从0开始后端没有“跳过已存在分块”的逻辑每次都是全新接收在接收分块时先判断md5_序号.part是否存在存在则跳过写盘直接返回成功IE浏览器下分块上传不生效WebUploader在低版本IE下会自动降级到Flash方案确认引入了Uploader.swf配置swfPath并确保路径可访问这些坑里面我特别想拎出来说两个。第一个是Nginx的client_max_body_size。现在绝大多数JSP项目前面都架着Nginx很多人只记得改Tomcat的maxPostSize忘了Nginx这一层。分块上传单块只有2MB看起来不会触发1MB的限制但实际上多个分块并发时Nginx在接收请求体时如果达到上限会直接返回413。线上很多“分块上传失败报413错误”的问题根因都在这一层。第二个是**chunk序号从0开始**。这个真的是老生常谈却又屡屡踩坑。前端WebUploader切块时第一块的下标是0。后端在合并循环里如果把初始值写成1第一个分块就被跳过了合并出来的文件必然损坏。排查起来非常费劲因为文件看起来“差不多”但大小少了一块或者中间某段数据错位。5.2 几个建议保留的配置细节最后分享一些我认为值得保留的工程化细节分块上传不只是“把文件切开再拼起来”这么简单还有很多细节影响稳定性。一个建议是不要用固定文件路径存正式文件。我前面的示例里合并后的文件直接以用户上传的原始文件名存盘这在大规模系统中是不合适的应该给文件重新生成存储名比如UUID 后缀文件原名只保留在数据库记录里。否则用户上传两个同名文件第二个就会覆盖第一个。另一个建议是给分块上传接口加一个简单的Token校验。JSP项目一般不复杂但分块上传接口会暴露成HTTP端点任何人都可以往你的临时目录写文件。如果被别有用心的人利用大量write请求能轻松填满磁盘。我建议在初始化上传器时生成一个随机Token存在Session里后端每次接收分块时校验。代码很简单// 前端初始化时生成token var token new Date().getTime() _ Math.random().toString(36).substr(2); uploader.options.formData.token token; // 后端校验 String token request.getParameter(token); String sessionToken (String) request.getSession().getAttribute(upload_token); if (token null || !token.equals(sessionToken)) { // 拒绝请求 response.sendError(403); return; }还有一点是关于临时目录的磁盘空间监控。如果分块上传的频率很高临时目录的峰值占用会非常可观。一个500MB的文件分块上传过程中临时文件占用的空间接近500MB。我在运维侧会用定时脚本监控临时目录占用超过阈值就告警。上线初期我们忽视过这个结果磁盘满了导致所有上传全部失败属于很典型的低级事故。分块上传这套方案在JSP项目里落地并不复杂但每个环节都有值得推敲的细节。从选型、前端初始化、后端接收合并到断点续传和排查核心就是“明确数据格式、处理好并发、兜底异常场景”这三件事。如果你也在维护传统JSP项目并且被大文件上传折磨过这套源码示例可以直接拿过去改造遇到的具体问题欢迎在评论区交流我能帮上忙的一定给出我的排查建议。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案