
最近在翻一个老项目的需求单看到一条让我印象特别深的提问“JSP网页断点续传文件夹有哪些方法”这问题不是我第一次见了。很多还在用JSPServlet维护老系统的团队被甲方一句“我们要能直接传整个文件夹断网了还能接着传”搞得焦头烂额。普通的input file控件一次提交一个完整请求网络一抖就前功尽弃文件夹动辄几百个文件重传成本完全不能接受。我前后在三个不同规模的项目里实现过文件夹断点续传从最原始的Servlet手工分片到后来的分片上传框架再到对象存储的分片机制方法都摸过一遍这里把路线、原理、代码和坑一次说清楚。1. 需求拆解JSP网页里“断点续传文件夹”到底指什么1.1 这类需求的典型场景先说场景。不只是网盘类项目才需要文件夹上传日常开发里遇到的情况多得很。旧系统改造最常见。比如有个内部资料归档系统原来是单文件上传几百个合同档案要一个一个点甲方受不了要求“把整个文件夹拖进去自动按目录结构传上来”。还有流程审批系统审批附件经常是一个项目文件夹里面几十个文件甚至有子目录领导只愿意做一次拖拽操作。另外还有学生管理系统、个人信息展示页面这类典型JSP应用批量上传证件照、作品集都是同一个痛点。这些场景共同点是文件数量多、总体积大、网络环境不稳定、用户没有精力盯着上传。单纯加一个多文件上传控件解决不了问题必须做到“文件夹级”的上传任务管理并且断了能续。1.2 为什么浏览器不能自己搞定断点续传很多人第一次遇到这个需求时第一反应是“浏览器不是自带断点续传吗”。还真不是。HTTP协议本身是请求-响应模型一次上传请求要么成功要么失败服务端不会因为你传了一半就帮你记住偏移量。那些看起来“断点续传”的下载工具是靠HTTP Header里的Range字段实现的服务端明确支持分段返回内容。但上传场景没有统一的Range上传标准浏览器层面也没有提供原生的上传续传API所以必须靠业务代码自己实现。说白了断点续传上传的原理并不玄乎把一个大文件或一批文件切成固定大小的分片逐个上传服务端记录哪些分片已经收到下次打开页面前端先问服务端“哪些分片你已经有了”然后只传缺失的部分。所有技术方案本质上都是这个逻辑的工程化变体。1.3 文件夹上传和单文件上传不是一回事文件夹上传比单文件上传多了一层难度不只是“循环遍历文件”那么简单。第一目录结构要保留。用户传一个项目文件夹里面可能有一层、两层子目录。传上去之后服务端要按原样还原目录层级否则文件全堆在一个目录里会乱套。所以前端要把每个文件的相对路径相对于选中文件夹根目录记录下来后端合并时需要根据相对路径建目录。第二进度展示维度不同。文件夹上传时用户关心的是整个文件夹的总进度。如果只按每个文件单独显示进度几十个小文件闪得太快体验反而很碎。好的做法是维护一个“上传任务”总览任务下面挂若干文件文件下面再挂若干分片三级结构。第三大小差异悬殊。文件夹里可能有一个100MB的压缩包也有一堆几KB的文本文件。如果每个文件都走分片逻辑小文件就走分片反而是浪费时间和请求数。实际项目里我会设定一个阈值比如小于5MB的文件直接整体上传大于阈值的才分片。2. 方案选型三条主路线按项目阶段选2.1 纯Servlet手动分片适合老系统改造如果你在维护一个老的JSP项目项目结构还是ServletJSP没有引入Spring Boot甚至连前端构建工具都没有那直接用原生Servlet接收分片是最稳妥的方式。原理不复杂。前端用JavaScript把文件切成固定大小比如每片2MB的分片逐个用XMLHttpRequest或FormData提交到Servlet。Servlet端每次只接收一个分片按“任务标识分片序号”命名写进一个临时目录。等所有分片传完前端调用一个“合并接口”Servlet把临时目录里的分片按顺序合并成完整文件。为什么说这种方式适合老系统因为依赖最少不需要额外引入中间件Servlet容器本来就支持文件上传解析Servlet 3.0的Part接口。我改造过一个JSP旧系统就是用这个方式在不升级框架的前提下把单文件上传换成了文件夹分片上传改动局限在新增两个Servlet和前端一个页面。缺点也很明显手动管理分片状态、合并逻辑、失败恢复逻辑还要自己处理并发代码量不小。适合“能用就行、不想动架构”的场景。2.2 前端分片后台合并目前最主流的JSP项目做法如果项目里能引入前端库哪怕还是JSP后端体验会好很多。市面上常见的WebUploader、Plupload、FineUploader、bootstrap-fileinput都支持分片上传、并发上传、失败重试。以WebUploader为例它是百度开源的老牌上传库API设计很符合国内开发者的习惯文档和示例都是中文的。它对分片上传有内置支持设置chunked: truechunkSize指定分片大小上传时它会自动把文件切块每个分片作为一个独立请求发出。后端只需要实现对应接口接收分片、记录分片、分片合并。我当年第一次做文件夹断点续传就是WebUploaderServlet的配置。WebUploader通过formData参数可以在每个分片请求里带上自定义字段比如文件唯一标识、分片序号、文件总大小、源文件名、相对路径。后端拿到这些数据自己管理状态。这样前端分片逻辑不用自己从头造轮子只需要关注文件夹遍历和任务UI。这套方案目前依然是JSP系项目的主流选择因为前端库成熟后端接口你自己说了算兼容性最好。2.3 对象存储分片适合使用SpringBoot/云原生的场景再看另一种情况。如果项目已经是Spring Boot甚至已经上了云那完全没必要自己维护文件服务器。阿里云OSS、腾讯云COS、七牛云以及开源的MinIO都提供现成的分片上传接口。这些服务通常的玩法是前端向你的后端请求一个上传授权临时凭证然后前端直接跟对象存储节点通信分片上传每传一个分片可以得到一个ETag全部传完后前端带上所有分片的ETag列表请求“合并分片”接口。服务端把分片合并成完整对象。如果中途断了你只需要查对象存储里已经上传了哪些分片继续传缺失分片即可。这个路线最大的优势是稳定性和扩容性。文件不占自己服务器磁盘分片数据存储在专业的存储系统里不怕磁盘写满并发能力也强。缺点是需要在代码里对接SDK而且一旦用了某个云厂商的SDK接口设计会跟着厂商走后面想换存储商稍微麻烦点。如果只是内网系统、不想上云用MinIO自己部署一套也一样能达到效果。2.4 三条路线怎么选我按自己的经验给一个参考维度具体选哪个要看项目现状。对比维度纯Servlet手动分片前端分片后台合并对象存储分片适合项目老JSP系统、无构建工具JSP/Spring MVC均可Spring Boot、云原生项目前端工作量中自己写切片逻辑低用现成库中对接SDK后端工作量高分片接收、状态、合并全自己写高接收分片合并但要适配库的约定较低SDK封装好接口存储位置自己服务器磁盘自己服务器磁盘对象存储/MinIO断点续传能力自己实现自己实现服务端原生支持稳定性上限取决于你的代码质量取决于你的代码质量高分布式存储成本最低低存储费用或维护成本一句话老系统不想动架构选方案一想用成熟前端库提升体验选方案二新项目且愿意上对象存储直接选方案三。下面我重点拆方案二的具体实现因为它是理解整个断点续传机制最好的切入点方案一和方案三很多底层逻辑跟它相通。3. 核心实现一WebUploader Servlet的分片与断点记忆3.1 前端如何遍历文件夹并把文件切成分片WebUploader本身支持多文件但文件夹选择功能需要配合HTML5的webkitdirectory属性。正确做法是让用户选择文件夹后读取里面所有文件记录相对路径再把这些文件交给WebUploader的上传队列。var uploader WebUploader.create({ server: /upload/uploadChunk, chunked: true, chunkSize: 2 * 1024 * 1024, // 2MB一个分片 threads: 3, fileNumLimit: 500, fileSizeLimit: 10 * 1024 * 1024 * 1024, // 10GB,按需调整 formData: { taskId: generateTaskId() // 每次选择文件夹生成一个任务ID } }); // 文件夹选择按钮 $(#pickFolder).on(click, function() { var $input $(input typefile webkitdirectory); $input.on(change, function() { var files this.files; for (var i 0; i files.length; i) { var file files[i]; // 从file.webkitRelativePath里取相对路径 uploader.addFile(file); } }); $input.trigger(click); });这里file.webkitRelativePath是文件夹上传最关键的字段比如选了“项目资料”文件夹里面有个文件路径是“项目资料/文档/需求.docx”这个字段就会是“项目资料/文档/需求.docx”。后端保存文件时就要用这个相对路径来重建目录。分片大小我建议设成2MB到5MB之间。太小了请求数量爆炸服务器容易被请求洪峰打垮太大了又体现不出断点续传优势一个分片传一半断网照样要重传。2MB是我在普通带宽环境下的折中值如果内网传输可以调到5MB。3.2 后端Servlet怎么接收分片、按序落盘后端核心是接收分片的Servlet。WebUploader在每个分片请求里会带上固定参数你也可以自己追加但这个库自动带的分片信息已经很全chunk当前分片索引、chunks总分片数、name源文件名、size文件总大小。使用Servlet 3.0的Part接口接收分片代码如下WebServlet(/upload/uploadChunk) public class UploadChunkServlet extends HttpServlet { private static final String UPLOAD_TMP_DIR /data/upload_tmp/; protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { request.setCharacterEncoding(UTF-8); String taskId request.getParameter(taskId); String chunkIndex request.getParameter(chunk); String fileName request.getParameter(name); String relativePath request.getParameter(relativePath); // 自定义参数 Part part request.getPart(file); // 分片存放目录: 临时目录/taskId/分片序号 File taskDir new File(UPLOAD_TMP_DIR, taskId); if (!taskDir.exists()) { taskDir.mkdirs(); } File chunkFile new File(taskDir, chunkIndex); try (InputStream in part.getInputStream()) { Files.copy(in, chunkFile.toPath(), StandardCopyOption.REPLACE_EXISTING); } // 返回已接收状态 response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\ok\:true}); } }有一个细节容易忽略part.getName()在WebUploader里默认是“file”但不同前端库可能取不同名字最好在前端formData里固定一个字段名并且用request.getPart(file)去取避免浏览器差异导致取不到文件流。每个分片单独写成一个文件文件名就是分片序号。不要边接收边合并否则一个分片丢了没法重传合并到一半的完整文件也是损坏的。分片全部落盘后再统一合并。3.3 秒传的判定逻辑用MD5/SHA-1做文件指纹断点续传的“续传”是知道哪些分片传过了秒传是知道整个文件之前已经传过了。这都需要建立文件指纹体系。前端在文件加入队列时计算整个文件的MD5作为文件的唯一标识。WebUploader不提供现成的MD5计算一般需要配合SparkMD5库。大文件不能直接一次性读入内存算要分片读取、增量更新哈希。具体逻辑是读取前2MB计算一部分MD5再读下一部分继续更新直到读完。浏览器脚本里这一步可能比较耗时但它是值得的因为后面所有断点恢复和秒传判断都靠这个值。function calcFileMD5(file, callback) { 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 { callback(spark.end()); } }; function loadNext() { var start currentChunk * chunkSize; var end Math.min(start chunkSize, file.size); fileReader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); }文件指纹计算出来后上传前先请求后端接口传fileMd5和taskId后端查分片记录表返回“已存在该文件”或“已有分片列表”。如果文件已存在且状态是已完成前端直接跳过整个文件显示“秒传成功”。如果只是部分分片存在前端把分片列表标记为已完成只传缺失的分片。后端判断“文件已存在”要谨慎。不仅要比对MD5还要比对文件大小MD5碰撞的概率极低但文件大小比对成本更低两个条件同时满足才能判定为同一文件。如果一个文件MD5相同但大小不同那基本可以断定是前端算法或传输出了问题返回错误而不是秒传。4. 核心实现二合并校验与目录级任务管理4.1 目录结构、临时文件命名规则分片上传期间的文件都是零散的命名和目录组织直接决定后续合并和排查的难度。我常用的目录规则分三层临时根目录/data/upload_tmp/任务目录/data/upload_tmp/{taskId}/分片文件/data/upload_tmp/{taskId}/{chunkIndex}taskId可以用UUID也可以用文件MD5时间戳组合确保唯一即可。合并完成后的正式文件放到另一个目录比如/data/uploads/{relativePath}按原相对路径创建目录结构。临时目录和正式目录必须物理隔离避免合并过程中出现“读到一半被其他逻辑扫描到”的问题。还有一个小技巧任务目录下除了分片文件我会额外写一个meta.json记录这个任务的文件名、总大小、总分片数、MD5、相对路径、创建时间。这样即使数据库里的记录丢了后端也能靠目录里的meta.json恢复任务信息排查问题时非常有用。4.2 合并时的流处理与校验合并接口做的事情本质上就是按分片索引从0到N-1把所有分片按顺序写入目标文件。但这个过程有几个容易翻车的细节。第一分片文件排序必须用数字排序不能用字符串排序。如果你用File.listFiles()默认排序第10个分片会排在第2个前面合并出的文件必然是坏的。要按文件名转成int再比较。第二合并时不要把所有分片一次性读入内存。一个2GB的文件分片有1000个如果每个分片都读成byte[]再写JVM内存直接爆掉。正确做法是流式拷贝一个分片读完就丢了引用。File[] chunks taskDir.listFiles(); Arrays.sort(chunks, Comparator.comparingInt(f - Integer.parseInt(f.getName()))); File targetFile new File(targetDir, finalFileName); try (FileOutputStream fos new FileOutputStream(targetFile)) { for (File chunk : chunks) { Files.copy(chunk.toPath(), fos); } }第三合并完成后要校验。最简单的校验是对比合并后的文件大小和前端上报的总大小是否一致不一致就说明分片有缺失或重复。更进一步可以重新计算合并后文件的MD5和前端上报的MD5比对但大文件在后端重新算MD5也耗时我一般只在文件大小校验通过后针对特别重要的文件做MD5校验。校验失败时不要急着报错先把任务状态重置为“待补传”前端下次检测到缺失分片列表后会自动重传。这就是断点续传的闭环。4.3 前端进度展示与续传恢复的实现思路进度展示分三个层次单文件分片进度、单文件总进度、文件夹总进度。WebUploader内置了file.on(progress)事件可以拿到当前文件的分片上传进度。文件夹总进度需要自己算已上传字节数/总字节数。一个文件如果被标记为秒传它的字节数也计入已上传部分。页面刷新后前端重新加载文件夹时先请求后端接口拿到当前taskId下每个文件的上传状态已传的分片直接跳过。这里有一个和产品经理对齐的点续传不是“断点”两个字就能让用户感知的。用户看到的效果应该是上次传了一半的文件夹重新打开页面拖进来进度条从上次的位置继续走而不是从头走。所以前端不能等用户重新选择文件夹后才检查状态而应该在页面加载时就尝试恢复未完成的上传任务。我在实现时是这么做的选择文件夹后前端遍历文件对每个文件先查一次状态接口已有完整记录的跳过有部分分片的直接从断点续传完全没有的开始上传。这个过程对用户是透明的。5. 部署与排查真实项目里最容易翻车的几个点5.1 服务器默认超时与请求体大小限制很多团队把分片上传代码写好本地测没问题一到测试环境就各种失败最常见的原因是服务器或代理层限制。Tomcat默认的maxPostSize是2MB如果不调分片稍大一点就被拦截。虽然可以靠修改web.xml或Tomcat配置提高限制但我不建议把maxPostSize调得特别大因为分片本身就小2MB到5MB是常态调大到10MB足够太大的请求体反而容易触发代理层缓冲区问题。如果前面挂了Nginx还要改client_max_body_size默认是1MB。很多年前我踩过一次很坑的分片大小设了2MBTomcat本地直连没事一上Nginx就报413排查了半天才发现是Nginx默认限制。改配置的地方多每层都要看一遍。另外Servlet 3.0的Part接口解析是同步读取整个请求体才返回如果分片上传过程中客户端断开Part读取会抛异常。要在Servlet里捕获IOException并把异常信息转换成前端口径的“分片上传失败”而不是返回一个500让前端误以为服务端崩了。5.2 一次分片丢失导致的“文件合并后打不开”我在一个客户项目里遇到过典型的坑文件上传完成后点击下载文件始终打不开压缩包报“文件已损坏”。查了很久发现是分片上传过程中个别分片请求其实返回了失败但前端重试逻辑没生效把失败的分片当作成功跳过后端合并时就缺了一块。为什么前端没重试WebUploader的默认行为里分片上传失败会触发uploadError但我在初始化配置里没有明确设置“失败重试次数”。后来调整配置对分片设置重试并且在后端记录每个分片是否完整接收检查分片文件大小是否等于固定分片大小最后一片允许小于分片大小不满足条件就返回失败强制前端重传。这给我一个经验断点续传系统里前端对“成功”的判断必须以后端返回为准不能以发起请求为准。只要后端没有明确返回成功这个分片在状态记录里就必须是未完成。5.3 并发上传与文件锁冲突WebUploader默认支持并发上传我设置threads:3一次最多三个分片同时上传。这样能提高吞吐但也带来了并发写文件和合并时机冲突的问题。最危险的情况是分片还没有完全落盘合并请求却已经发出。比如最后一个分片刚到后端合并请求也跟着到了合并时发现最后一个分片还不存在或者正在被写入读出来的数据不完整。解决思路是在合并接口里加一个检查点后端收到合并请求后先遍历所有分片文件确认数量等于总分片数并且每个文件大小符合预期全部满足再开始合并。另外同一个taskId下最好不要出现两个合并请求并发执行。简单做法是同步锁加在taskId维度上。单机部署可以用ConcurrentHashMap存taskId对应的锁对象分布式部署要用分布式锁。我建议不要为了省时间在合并时开多线程流式顺序拷贝在正常场景下已经够快瓶颈一般在前端网络和分片上传合并阶段反而是最不需要优化性能的地方。5.4 上传进度在JSP页面的实时刷新方案JSP页面不像前后端分离项目那样方便做实时刷新但实现进度展示并不难。前端上传库本身有回调事件可以直接更新DOM不需要额外查询后端。需要从后端拉数据的是“页面刷新后恢复进度”的场景。我的做法是提供一个状态查询接口返回包里包含任务下每个文件的MD5、已传分片序号、总分片数、文件大小、状态。前端根据这份数据渲染进度条用JS定时轮询或者只在页面加载时查一次因为上传过程中前端本来就在动态更新进度不需要靠轮询。有一种情况是多个浏览器标签页同时操作同一个任务比如用户开了两个页面都选择了同一个文件夹。这时进度刷新会互相覆盖任务状态也容易混乱。我在生产环境里干脆限制一个taskId同时只能有一个活跃上传会话后端记录上传客户端标识新会话接入时旧会话被标记失效。这个限制在业务上是合理的因为同一个用户极少需要开两个页面同时传同一个文件夹。6. 选型建议与个人经验6.1 按团队技术栈选方案聊了这么多最后回到选型上。如果团队里都是写惯了JSP和Servlet的老手不熟悉前端工程化那就老老实实用方案一或方案二把复杂逻辑放在后端。方案一适合后端主导、前端尽量简单的场景缺点是比较原始的切片逻辑会让你写不少重复代码。方案二适合你能接受引入WebUploader这种前端库的场景这是我个人做过的最顺的组合。如果团队已经是Spring Boot前台后端分离直接看对象存储方案。自己用MinIO搭一套代码量最少分片状态、断点续传、合并校验全都由存储服务处理。尤其团队里没有专门运维不想处理磁盘清理和临时文件管理的时候对象存储能省掉很多维护成本。6.2 数据表设计和清理策略不管哪种方案如果后端要自己管理分片状态建议至少设计两张表。表名字段说明upload_tasktask_id, file_md5, file_name, relative_path, total_size, total_chunks, status, create_time任务表一个文件一条记录upload_chunktask_id, chunk_index, chunk_size, create_time分片表记录已传分片序号分片表的数据量会很大上传完成后要及时清理已完成任务的分片记录只保留必要的信息用于秒传判断。临时目录里的分片文件也要做定时清理超过24小时未完成的任务把对应的临时目录整个删掉。我见过一个系统上线两年upload_tmp目录里堆了几个T的垃圾分片磁盘报警才发现清理逻辑没写。清理脚本不要写得太激进比如“超过24小时删除”要留白万一用户是隔天继续传的任务状态表和临时文件都被清了续传就失效了。我的建议是把临时文件保留时间放宽到7天同时加上磁盘空间使用率阈值超过阈值才优先清理更早的文件。6.3 几个我常用的工具和库前端库我用的最多的是WebUploader但说实话它已经停止维护很多年了新项目里我会更倾向Plupload或者直接用Simple-Uploader一个基于HTML5的上传库支持文件夹和分片。如果团队前端能力不错用vue-simple-uploader这类组件配合JSP输出的页面也能用只是需要一点前端构建能力。后端口粮上Servlet方案里其实不需要额外引入文件处理库Java原生的Files.copy就够。如果要追求代码整洁可以考虑Apache Commons IO的FileUtils但没必要为了一个上传功能引入重型框架。数据库层面建议加上唯一索引(task_id, chunk_index)防止同一分片因为前端重试被插入两次。并发环境下这个唯一约束能帮你挡住很多隐性问题。断点续传文件夹上传这件事表面上是个“功能需求”实际做下来你会发现它是一个典型的前后端协作工程前端要管好文件夹遍历、分片切片、进度展示和失败重试后端要管好分片存储、状态记录、合并校验和定时清理。任何一头有漏洞最终都会以“文件损坏”或“传不上去”的形式暴露出来。我最想分享的经验是不要在写代码前急着选框架先把分片的状态流理清楚——谁产生状态、谁记录状态、谁消费状态。这个链路想明白了用Servlet也好、用OSS也好都能把断点续传做得稳。