如果你维护过一个用JSP/Servlet写的管理后台,一定遇到过这种需求:用户要把整个产品资料文件夹、合同归档目录、项目图纸打包上传。文件夹里几百个文件、好几个GB,浏览器直接卡死,Tomcat直接报内存溢出,前端等半天最后给你一个网络错误。我前两年给一家单位做知识库管理模块,就卡在这件事上整整一个星期,试过各种插件和方案,最后才琢磨出一套稳定的做法,这篇文章把完整的实现思路和踩坑过程都写出来,给同样被“JSP怎么传超大文件夹”折磨的人一个参考。
先说清楚这套方案到底解决什么问题:基于JSP/Servlet这种传统Java Web技术栈,不引入Spring Boot、不换OSS,用HTML5的File API加切片上传,实现文件夹整体的断点续传、并发加速和进度展示。适合老项目改造、内网系统、以及必须用JSP做服务端渲染的场景。
1. 传统JSP上传为什么扛不住超大文件夹
1.1 一次性提交方案的三个硬伤
很多人第一反应是用 <input type="file" name="file"> 加 enctype="multipart/form-data",后端用Commons FileUpload或Servlet 3.0的Part接口接文件。这套方案对付单个几MB的文件完全没问题,但一碰超大文件夹就原形毕露。
第一个硬伤是内存和临时文件压力。整个请求会把所有文件拼成一个multipart流一次性发送,Tomcat默认的 maxPostSize 是2MB,除非在server.xml里调大,否则请求直接拒绝。就算调大了,几GB的数据要么全部载入内存(不现实),要么落盘到Tomcat的临时目录,磁盘IO直接打满。我测试过一个1.2GB的文件夹,用传统方式传,浏览器一直在转圈,Tomcat日志里全是OutOfMemoryError。
第二个硬伤是不可控。一次性提交意味着请求发出之后,服务端只能被动等待,没有进度条、没有重试机制、中途断网就得从头再来。用户看着浏览器右下角的转圈,根本不知道是快了还是死了。这对内网系统来说尤其致命。
第三个硬伤是文件夹结构丢失。原生input拿到的File对象列表只是文件平铺,webkitRelativePath这个属性倒是能拿到相对路径,但传统上传根本不会利用它,传到服务端全部变成扁平文件名列表,重名文件直接互相覆盖。
1.2 为什么放弃ActiveX插件,改用HTML5分片方案
十几年前,老OA系统处理大文件普遍用ActiveX控件,像NTKO那种,我早期也接过这类活儿。ActiveX方案在Windows + IE时代确实能用,但问题很明显:只能Windows、只能IE内核、还要在客户端装插件、权限配置麻烦、360浏览器搞兼容模式的时候经常加载不出来。现在连IE都停服了,再让用户装控件纯属自虐。
HTML5给File对象增加了 slice() 方法,可以把一个文件切成多个Blob片段,再配合 XMLHttpRequest 逐个上传,服务端接收齐所有分片后按顺序合并回完整文件。这套思路绕开了“一次性大请求”,每个分片就是一个普通的multipart请求,单次请求体积可控,Tomcat不用调夸张的参数,内存占用小,还能做断点续传、并发上传、实时进度。浏览器原生支持,跨平台,用户无感。
我一直认为,很多所谓“现代”的优化方案,核心原理在W3C标准里躺着,只是大家习惯性去搜插件、框架,忽略了原生的能力。这次既然要给JSP老项目做改造,用原生File API + Servlet分片接口,是最稳妥、可控、没有额外依赖的路线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:切片、并发与目录结构保存
2.1 文件夹的文件怎么交出来:webkitdirectory的原理
让浏览器交出文件夹,靠的是 <input type="file" webkitdirectory> 属性。这是Chrome/Safari/Firefox支持的做法,把input标记为目录选择模式,用户选定整个文件夹后,input.files 里会得到一个Flat的File对象数组,每个File对象带有 webkitRelativePath 属性,内容类似 产品手册/第1章/架构说明.pdf。
注意,这本质上仍然是“文件选择”,不是浏览器把文件夹对象直接传给你。所以我们需要自己遍历这个数组,用 webkitRelativePath 来重建目录结构。有些读者可能问,为什么不用 directory API(showDirectoryPicker)?那个接口更现代,能拿到真正的目录句柄,但兼容性有限,老项目用户浏览器版本参差不齐,webkitdirectory 反而最通用。
这里有个容易踩的坑:webkitRelativePath 在部分国产浏览器里可能为空字符串或者不标准,我建议前端做一次校验,拿不到相对路径就用文件名加随机后缀兜底,避免人为制造bug。
2.2 分片大小如何设定:不是越细越好
分片的本质是把一次大请求拆成多个小请求,但分片大小和并发数之间存在一个最优解,不是切得越碎越好。
分片太细(比如256KB),会导致请求数量爆炸。1GB文件切256KB需要4096个分片,每个分片都要走一次HTTP握手、multipart编码、服务端IO,光请求开销就占了大量时间。分片太粗(比如50MB),又退化回大请求,超时风险上升。
我的实践经验是:常规网络环境取2MB到5MB,内网高带宽环境可以放宽到10MB,公网低带宽环境建议1MB到2MB。以4MB分片为例,1GB文件切成256个分片,并发控制在4到6路,耗时、服务端压力、断点续传粒度都比较平衡。
另外,最后一个分片往往小于设定值,前端要处理 end 大于等于 file.size 的情况:
javascript复制const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
2.3 并发控制:给上传队列加一个“信号量”
如果不加控制,直接把几千个分片全部 new XMLHttpRequest() 发出,浏览器会瞬间建立大量连接,路由器和小型交换机先扛不住,服务端Tomcat线程池也会被打满。实测中,我见过并发拉满时,nginx直接报 connection reset by peer,后端Servlet日志一片红。
正确的做法是做一个简单的并发池:维护一个待上传任务队列,同时只运行N个上传任务,每当有一个任务结束,就从队列里取下一个。这个“信号量”逻辑不复杂,我在后面的代码里给出一个具体实现,N的值取4到6比较稳妥。如果网络质量差,可以降到3;如果内网十分稳定,可以试到8,需要实测调优。
2.4 断点续传与秒传的落地思路
分片方案天然适合断点续传,因为每个分片之间是独立的,前端可以记录哪些分片成功了。
最简单的实现:前端内存里维护一个 completedChunks 集合,上传成功就加进去,上传过程中如果断网或者页面没关,重新开始时跳过已经成功的分片。如果要把续传能力做到跨页面、跨浏览器,就需要在 localStorage 里记录文件指纹和分片完成状态。
文件指纹一般用文件标识,最简单的是“文件名 + 文件大小 + 最后修改时间”拼接。更严格的做法是前端计算第一个分片和最后一个分片的MD5,或者用SparkMD5计算整个文件hash,但超大文件全量MD5计算耗时挺长,用户体验差,通常只有“秒传”需求时才值得做。
秒传的逻辑则是:前端计算好文件标识后,先发一个请求问服务端“这个文件你已有吗”,服务端查存储记录,如果已存在同名同大小同哈希的文件,直接返回成功,不用再传。这个功能对重复上传同一批资料时特别有用,建议二期再叠加。
3. 实操实现:前端JSP页面 + 后端Servlet完整代码
3.1 前端实现:文件夹选择、切片与并发上传队列
先写上传页面upload.jsp的核心部分。一个文件选择按钮、一个进度条、一个日志区域,JS逻辑重点在Uploader类的设计。
html复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>超大文件夹上传</title>
<style>
.progress-wrap { width: 600px; height: 24px; border: 1px solid #ccc; margin: 20px 0; }
.progress-bar { height: 100%; width: 0; background: #4caf50; transition: width 0.2s; }
#log { width: 600px; height: 200px; overflow: auto; background: #f7f7f7; padding: 8px; font-size: 13px; }
</style>
</head>
<body>
<h3>选择文件夹后自动上传(超大附件分片上传)</h3>
<input type="file" id="folderInput" webkitdirectory multiple />
<div class="progress-wrap"><div class="progress-bar" id="progressBa
