☰
JSP大文件上传攻略:分块上传与多附件处理实战
2026/9/29 21:31:30 网站建设 项目流程

1. 从一次真实故障说起:传统JSP上传大文件为什么必挂

我是在一个维护了六七年的传统JSP项目里遇到这个问题的。系统本身不复杂,JSP页面加Servlet加Tomcat,war包扔到webapps里就完事。之前上传的附件基本都在几十MB以内,一直相安无事。直到有一天业务方要往系统里传一个多GB的地质数据包,页面转圈了半个多小时,最后浏览器直接报“无法访问此网站”,后台日志只有一行连接被重置。

排查了很久,最终发现根本不是某一个环节坏了,而是整条链路都为“小文件”设计,碰到大文件时从浏览器到nginx再到Tomcat,每一层都在卡脖子。当时网上搜出来的方案大多围绕“加大内存”“调大上传限制”,试了之后能缓解,但只要文件超过1GB依然会出问题。后来把上传方式改成“分块上传”,这个问题才算真正解决。

这篇文章适合还在维护传统JSP+Servlet+Tomcat项目的人,也适合用Idea新建JSP项目做毕设、实训的同学。我会把分块上传的完整思路、前端切片、后端接收合并、参数调优和踩坑经历都写清楚。核心关键词就三个:JSP、大文件、多附件,但看完你会发现,真正解决问题的核心并不在JSP语法里,而在请求模型的设计上。

1.1 一次典型的“大文件上线事故”

那次故障的触发条件很典型:业务方凌晨上传一个2.3GB的数据包,从凌晨0点传到快2点,进度条一直在走,最后却显示失败。用户心态崩了,我们的电话也被打爆了。

当时我们的技术栈是nginx做反向代理,Tomcat 8.5跑应用,上传用的是最传统的multipart/form-data方式。JSP页面上一个<input type="file">,后端用request.getInputStream()读流,再写到指定目录。这套组合对付一两百MB勉强能跑,一旦上GB,问题就开始连环出现:

  • 浏览器端把整个文件组织成FormData一次发出,内存和网络连接都要长时间占用;
  • nginx默认的client_max_body_size只有1MB,虽然项目里改过,但稍微没改到位就又拦一道;
  • Tomcat端要等完整请求体到达才能返回响应,中间任何一个网络抖动,之前传的数据全部作废;
  • 更隐蔽的问题是,Tomcat的maxSwallowSize和connectionTimeout也会在超大请求下触发异常,表现却是五花八门的http500或者连接重置。

这些坑单看都不难解决,但它们叠加在一起时,总觉得像拆东墙补西墙。真正让人下定决心改造的,是那次上传失败后查看临时目录,发现服务端已经收到了超过1GB的半截文件却没有合并逻辑可用,只能人工清理。从那一刻我就意识到,整包上传这条路在大文件场景下是不可持续的。

1.2 浏览器、nginx、Tomcat三层各自卡在哪

与其一个一个查文档,不如把一次上传拆成三层来看:客户端、反向代理层、应用服务器层。每一层都有自己的“默认限制”,也要分别处理。

层级关键参数典型默认值大文件时的表现
浏览器请求体一次性构造无明示限制JS内存上涨、弱网易中断、失败后整包重来
nginxclient_max_body_size1m大文件直接413 Request Entity Too Large
nginxproxy_request_bufferingon会先把请求体缓冲到临时文件,增加磁盘开销
TomcatmaxPostSize2MB左右超过后Tomcat可能直接拒绝解析表单参数
TomcatmaxSwallowSize2MB左右上传中断时吞不完剩余数据,连接处理异常
应用代码InputStream一顿读无一次性读入内存,大文件撑爆堆内存

很多人搜“nginx支持jsp吗”,其实nginx本身不执行JSP,它只是反向代理到Tomcat,但代理层对请求体的大小和处理方式有独立的限制。如果只改Tomcat不改nginx,大文件会在代理层被拦掉;只改nginx不改Tomcat,请求虽然进了Tomcat,也可能因为表单解析限制而失败。这两层必须一起调。

应用代码就更直接了。很多老项目在Servlet里写的是:

byte[] bytes = IOUtils.toByteArray(request.getInputStream());

这种写法等于是把整个文件装进内存,文件一大了基本等于自杀。还有用commons-fileupload的,虽然它会把文件先落盘,但如果分块逻辑没做,依然解决不了网络中断导致的全量重传问题。

1.3 分块上传到底解决什么、不解决什么

分块上传的核心思路很简单:把一个大文件切成若干个小块,分别上传,全部上传完后再合并。每块可以独立失败、独立重试,服务端提前记录已收到的块,客户端对“已经传过的块”可以跳过,这就是断点续传的基础。

但它不是银弹。分块上传解决的是“一次请求体过于庞大导致的不稳定”,不解决带宽不够、服务端磁盘不够、文件重复存储、权限校验不完善这些工程问题。如果一个文件动不动就是100GB,我的建议是直接走对象存储加分块下载方案,不要再让流量经过业务Tomcat中转。那已经是另一个话题了,要在“大文件下载测试”里单独验证。

而且分块上传是有成本的。每切一块就是一次额外请求,前端要管理队列,后端要管理工作目录和合并逻辑,复杂度一定会上升。如果业务里绝大多数文件都在几十MB以内,老老实实调大nginx和Tomcat限制就够了,没必要为了技术上的“政治正确”强行分块。

2. 分块上传的整体设计:协商、分块提交、最终合并

改造之前,我先把上传流程画了一遍。我不是画图,只是列步骤。分块上传的骨架其实就三个环节:

  1. 前端选择文件后,先确定文件标识fileId、总块数totalChunks、每块大小,把这些元数据告诉后端;
  2. 前端把文件按固定大小切成N块,每一块用独立的HTTP请求提交,后端把每一块写入临时目录;
  3. 所有分块提交完成后,后端按顺序把分块合并成完整文件,做大小和校验和验证,最后返回可访问的文件地址。

这套设计里最容易被忽略的是“协商”这一步。很多初学者上来就切分块,但没有文件标识、没有总块数,后端接收之后根本不知道这个文件一共有几块、是否齐了。所以我强烈建议先把接口协议定义清楚。

2.1 接口与参数约定

我用的是一套很朴素的接口,不需要额外引入框架支持:

接口入参说明
POST /upload/chunkfileId、fileName、fileSize、totalChunks、chunkIndex、file上传单个分块
POST /upload/mergefileId、fileName、fileSize、totalChunks、fileMd5所有分块传完后触发合并
GET /upload/progressfileId查询已上传分块索引,用于断点续传

参数里最关键的是fileId。同一文件的所有分块必须共用一个fileId,后端靠它创建独立目录,避免不同用户上传同名文件时出现分块互相覆盖。fileId一般用UUID在前端生成,也可以由后端在“创建上传任务”时下发。

chunkIndex建议从0开始,和后端循环顺序一致,避免出现“我以为从1开始,后端从0开始”的错位问题。fileSize必须传,合并后拿实际大小跟它比对,是判断分块是否丢失最简单的手段。fileMd5可以后补,因为大文件在前端计算MD5非常耗时,如果网络好、时间紧,可以只做大小校验。

2.2 临时目录与文件命名规范

后端接收分块后,第一件事是为每个fileId建立独立目录,目录结构长这样:

/data/upload_tmp/ 20250612/ {fileId}/ 0.part 1.part 2.part metadata.json

为什么不用客户端的原始文件名直接拼接路径?因为文件名会带来两类问题:一是不同目录下的同名文件互相覆盖,二是中文文件名、特殊字符可能引发编码问题,甚至被构造出路径穿越。所以我在存储层面全程只用fileId和chunkIndex,原始文件名只保存在元数据里,最后合并时重新命名。

metadata.json里记录了fileName、fileSize、totalChunks、createTime这些字段。用文件做元数据简单直接,但如果系统已经接入了MySQL,也可以建一张upload_task表,字段对应上面这些信息,再加一个status字段标记“上传中”“合并中”“已完成”“已失败”。有数据库的好处是查询进度、清理临时文件都方便,坏处是写代码多一点。我两种都试过,如果只是应急改造,用metadata.json最快。

还有一个容易踩的坑:临时目录不要放在webapps下面,尤其是传统JSP项目打包war部署的场景,重新部署war包时Tomcat有可能会把整个应用目录清掉,你的半成品分块就没了。更合理的路径是独立配置,比如/data/upload_tmp,和应用目录彻底隔离。

2.3 同步合并还是异步合并

最后一小块普通请求把其余分块合并掉,看起来最省事。但问题在于,合并大文件会占用请求线程,Tomcat线程一直占着不放,前端那个请求就一直转圈。如果文件正好几千兆,合并可能耗时几十秒,期间HTTP连接超时、用户刷新页面,线程才会释放。

所以设计合并环节时,要先想清楚“最后一小块到达后,是同步返回结果,还是立刻返回‘合并中’,由前端轮询/等待通知”。

我的经验是:文件总大小低于500MB时,同步合并够用;超过这个量,建议异步合并。异步也很简单,服务端放一个线程池:

ExecutorService mergeExecutor = Executors.newFixedThreadPool(4); // 在最后一个分块落地后提交合并任务 mergeExecutor.submit(() -> mergeFile(fileId, totalChunks));

合并任务执行期间,前端定时调用GET /upload/progress,或者用Servlet 3.1的AsyncContext,等合并成功后再把响应写回去。传统JSP项目可能没引入WebSocket,轮询是成本最低的方案,合并一个几个GB的文件,轮询间隔设1到2秒完全够用。

3. JSP页面端改造:纯JS切片与多附件管理

JSP页面在这个方案里的任务很纯粹:渲染页面、引入前端脚本、展示上传状态。不要在JSP脚本片段里写上传逻辑,更不要用<%= %>去拼一堆Java状态,否则页面刷新一次,整个上传队列就没了。上传队列、分块索引、重试状态这些应该放在浏览器端JS对象里。

3.1 JSP页面编码与资源引入

页面头部的编码设置必须一致,否则中文文件名非常容易乱码。JSP页面我通常这样写:

<%@ page pageEncoding="UTF-8" contentType="text/html;charset=UTF-8" %>

对外的URL上传参数里,中文文件名必须做一次encodeURIComponent,后端拿到后再用URLDecoder.decode还原。如果你发现下载下来的文件名全是问号,多半是中间某一层没有统一UTF-8,或者漏了URL编解码。为了保险,我在controller里一律这么处理:

String fileName = URLDecoder.decode(req.getParameter("fileName"), "UTF-8");

前端用原生XHR或axios都可以。如果项目里已经引了jQuery,用$.ajax也问题不大,但要注意processData和contentType必须设成false,否则FormData会被jQuery转换掉,后端getPart("file")会取不到文件。

3.2 切片与并发控制代码

这是前端最核心的部分。我直接用原生JS写了个精简版,逻辑是:选择文件后,按固定分块大小切片,然后维护一个任务队列,限制并发数为3,每传完一块就补一个新任务。这样既能提高速度,又不会一次性把几十个请求全打出去把Tomcat压垮。

<input type="file" id="fileInput" multiple="multiple" /> <button onclick="startUpload()">开始上传</button> <progress id="totalProgress" value="0" max="100"></progress>
const MAX_CONCURRENT = 3; const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB let taskQueue = []; let activeCount = 0; let totalUploaded = 0; let totalSize = 0; function startUpload() { const files = document.getElementById('fileInput').files; totalSize = Array.from(files).reduce((sum, f) => sum + f.size, 0); Array.from(files).forEach(file => { const fileId = generateUUID(); const totalChunks = Math.ceil(file.size / CHUNK_SIZE); for (let i = 0; i < totalChunks; i++) { const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const blob = file.slice(start, end); taskQueue.push({ fileId, fileName: file.name, fileSize: file.size, totalChunks, chunkIndex: i, blob }); } }); pumpTasks(); } function pumpTasks() { while (activeCount < MAX_CONCURRENT && taskQueue.length > 0) { const task = taskQueue.shift(); activeCount++; uploadChunk(task).finally(() => { activeCount--; pumpTasks(); }); } } function uploadChunk(task) { const formData = new FormData(); formData.append('fileId', task.fileId); formData.append('fileName', encodeURIComponent(task.fileName)); formData.append('fileSize', task.fileSize); formData.append('totalChunks', task.totalChunks); formData.append('chunkIndex', task.chunkIndex); formData.append('file', task.blob, task.fileName); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/upload/chunk'); xhr.upload.onprogress = evt => { if (evt.lengthComputable) { // 这里只代表当前分块的进度,整体进度要在外部累加 } }; xhr.onload = () => { totalUploaded += task.blob.size; document.getElementById('totalProgress').value = Math.floor(totalUploaded / totalSize * 100); resolve(); }; xhr.onerror = () => reject(xhr.statusText); xhr.send(formData); }); } function generateUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); }

我特别想说一下并发数。并发不是越大越好。每开一个上传请求,Tomcat就要占用一个线程,nginx也要维持一条连接。并发从3调到5,上传速度可能没有明显提升,但线程占用和失败重试的次数会明显增加。尤其在内网带宽不高的情况下,并发5时常出现某些分块超时,反而需要重传。我们压测下来,普通办公网络下并发3是个性价比很高的值。

3.3 多附件选择、进度展示与断点续传

多附件和单文件最大的区别,在于每个文件是独立的上传任务,失败、重试、取消都不能互相拖累。所以我给每个文件分配独立fileId,进度条也分两级:总进度条显示所有文件的整体进度,文件列表每一项有独立的分块进度。用户能直观看到“a.zip传了3/10”“b.tar.gz传了7/20”。

断点续传的实现思路其实不复杂。上传分块时,后端把已落地的分块索引记录下来。前端在上传前先调一次GET /upload/progress?fileId=xxx,拿到后端返回的Set<Integer>,生成任务队列时跳过这些索引。但有一点必须说清楚:页面刷新后,File对象就没了,必须让用户重新选择文件。此时fileId能不能对得上,取决于你愿不愿意把fileId存在localStorage里。

我踩过这个坑之后的做法是:选择文件时生成fileId,同时把fileId和文件基本信息写入localStorage;页面刷新后,如果发现localStorage里有这个文件且文件大小相同,就继续沿用原fileId,否则重新生成。这属于锦上添花,但体验差异非常大,特别是那些传一个多GB文件传到80%突然手滑刷新页面的人。

如果担心JS在主线程切片、读文件导致UI卡顿,可以考虑把切片和文件读取逻辑放进Web Worker。Worker处理的是二进制数据,不影响页面渲染。老项目引入Worker时最需要注意的是URL同源问题,部署成war包后把Worker脚本放在js目录下就行。这个改造不是必须的,文件在2GB以下时主线程直接slice()的感受并不明显,但如果你追求极致,这是一个可选项。

4. 后端Servlet实现:分块接收、完整性校验与异步合并

后端是整个分块上传真正受力最多的地方。前端切块再溜,后端如果不能正确接收、落盘、合并、校验,一切都是白搭。传统JSP项目用的最多的就是Servlet,所以这部分我直接按Servlet写。

4.1 Servlet 3.0 multipart配置

Servlet 3.0开始支持request.getPart("file"),不用再手动解析multipart流。给上传分块的Servlet加一个注解即可:

@WebServlet("/upload/chunk") @MultipartConfig( fileSizeThreshold = 1024 * 1024 * 2, maxFileSize = 1024 * 1024 * 20, maxRequestSize = 1024 * 1024 * 20, location = "/data/upload_tmp" ) public class ChunkUploadServlet extends HttpServlet { // ... }

这里每个参数都有讲究:

  • fileSizeThreshold设为2MB,表示超过2MB的part直接写入磁盘,不再放内存,防止多个并发分块同时堆在堆内存里。
  • maxFileSize设为20MB,是为了配合前端10MB分块,挡住有人“绕过前端直接整包POST”的行为。
  • maxRequestSize同样设为20MB,让每个请求体的上限可控。如果你不想限制,可以写成-1,但生产环境我不建议这样做,等于给DoS攻击留了个大洞。
  • location指向临时根目录,必须确保目录存在并有写权限。

如果项目还在用web.xml部署方式,就用<multipart-config>标签配置,效果一样。传统JSP项目打包war时,这个配置会打包进war里,在Tomcat里正常生效。

4.2 分块落盘与元数据记录

分块落地我不用part.write(),因为不同容器对Part.write(String fileName)的路径解析方式不完全一样,遇到路径分隔符和相对路径很容易踩坑。稳妥做法是读取输入流,自己写入目标文件:

protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); String fileId = req.getParameter("fileId"); String fileName = URLDecoder.decode(req.getParameter("fileName"), "UTF-8"); long fileSize = Long.parseLong(req.getParameter("fileSize")); int totalChunks = Integer.parseInt(req.getParameter("totalChunks")); int chunkIndex = Integer.parseInt(req.getParameter("chunkIndex")); File chunkDir = new File("/data/upload_tmp", fileId); if (!chunkDir.exists() && !chunkDir.mkdirs()) { throw new IOException("create chunk dir failed"); } Part part = req.getPart("file"); File chunkFile = new File(chunkDir, chunkIndex + ".part"); 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); } } // 写入元数据 if (!new File(chunkDir, "metadata.json").exists()) { // 用Jackson/Gson或手拼JSON都可以,这里不展开 } // 查询已落地的分块数量 long completed = Arrays.stream(chunkDir.listFiles((dir, name) -> name.endsWith(".part"))) .count(); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"chunkIndex\":" + chunkIndex + ",\"completed\":" + completed + "}"); }

写完每个分块后,检查已落地分块数量是否等于totalChunks。如果相等,说明最后一个分块到了,可以触发合并。这里有一个并发窗口问题:某些分块还在上传时,另一个请求也可能同时检查到数量相等,导致同一个fileId被合并两次。我的处理方式是在合并前加一个状态标记:

File lockFile = new File(chunkDir, "merging.lock"); if (completed == totalChunks && lockFile.createNewFile()) { mergeExecutor.submit(() -> mergeFile(fileId, fileName, fileSize, totalChunks, chunkDir)); }

createNewFile()能保证同一时刻只有一个线程成功创建锁文件,其他线程看到文件已存在就不触发合并了。这种文件锁方式适合单机部署,比synchronized(fileId.intern())更安全,而且重启后锁文件还在,不会重复合并。

4.3 合并与完整性校验

合并逻辑并不复杂,按顺序把所有.part文件内容写入最终文件:

private void mergeFile(String fileId, String fileName, long fileSize, int totalChunks, File chunkDir) { File target = new File("/data/upload/" + System.currentTimeMillis() + "_" + fileName); try (FileOutputStream fos = new FileOutputStream(target); FileChannel out = fos.getChannel()) { for (int i = 0; i < totalChunks; i++) { File partFile = new File(chunkDir, i + ".part"); if (!partFile.exists()) { // 分块缺失,可以记录失败状态后中断 return; } try (FileInputStream fis = new FileInputStream(partFile); FileChannel in = fis.getChannel()) { in.transferTo(0, in.size(), out); } } } catch (IOException e) { // 合并失败,删除半成品文件 target.delete(); return; } // 大小校验 if (target.length() != fileSize) { target.delete(); return; } // 可选:MD5校验 // if (!md5(target).equalsIgnoreCase(fileMd5)) { target.delete(); return; } // 合并成功,删除临时目录 deleteQuietly(chunkDir); }

这里有两个细节很值得说。第一个是transferTo的效率比边读边写的通用IO好,因为它在底层可能走sendfile或零拷贝优化,合并大文件时差距明显。第二个是合并期间如果某一块缺失,不要继续合并,直接返回。我刚开始做的时候觉得“先把有的合了,等缺的块补上来再补写”也行,后来发现这样会引入大量“已合并部分字节数”的复杂度,最后文件错乱。分块上传就应该严格遵循“全部就位才合并”。

MD5校验要谨慎使用。一个大文件算一次MD5非常慢,在传统JSP项目的同步请求里做,很可能拖到前端超时。所以我建议把MD5校验也放到异步合并线程中,而且即使要校验,也得在大小校验通过之后再算,避免无谓开销。另一个思路是每传一个分块时,前端同时传一个chunkMd5,后端逐块校验。开销更小,定位问题也更精确。

秒传的判断可以放在合并之前:如果数据库里已经存在相同fileId、相同fileSize、相同fileMd5的记录,那就直接把已存储文件的访问地址返回给前端,不需要再合并一次。这个功能对重复上传场景收益很高,尤其是工程行业那种“不同人反复上传同一个数据包”的情况。

4.4 清理策略与集群部署的坑

临时目录一定会残留。用户传到一半关闭浏览器、弱网断开、断电,都会留下半成品分块。不清理的话,/data/upload_tmp很快会被填满。我写过最简单的定时任务,每天凌晨扫描临时根目录,删除所有“最后修改时间超过24小时且没有合并锁文件”的任务目录。如果任务目录里有merging.lock,说明合并中或者合并异常,还需要人工介入,但大多数残留都能自动清掉。

集群部署是另一个大坑。传统JSP项目打包war后,如果真的部署了多个Tomcat实例,你就得想清楚一个问题:同一个fileId的不同分块,可能被负载均衡分到不同的机器上。如果每台机器都写本地磁盘,分块散落在各自节点,合并时谁也找不到谁。解决方向有三个:

  • 把临时目录挂到共享存储上,比如NFS,让所有节点读同一个目录;
  • 按hash路由,把相同fileId的请求都发到同一台机器,但要保证机器不宕机;
  • 不依赖本地文件系统,直接把分块上传到对象存储,合并也在对象存储侧完成。

第一种最符合老项目习惯,但NFS的锁和IO性能要压测。第三种是长期最优解,后面我会单独说。

5. 压测结果与调优参数

改造完成后,我在测试环境做了几轮压测。压测不是为了看一个“上传速度”的数字,而是为了回答三个问题:分块大小选多少?并发数设多少?nginx和Tomcat参数怎么配才不会被“分块”这种多请求模型拖垮。

5.1 测试环境与文件准备

测试环境不复杂:一台2核4G的虚拟机跑Tomcat 8.5,前面挂nginx 1.18,客户端是Chrome。测试文件我用命令生成,Linux用dd,Windows可以用fsutil file createnew,都很快:

dd if=/dev/zero of=test_1g.bin bs=1M count=1024

注意不要用/dev/urandom生成大文件,随机数的生成速度远低于零字节,测试文件还没生成完,时间都浪费了。

5.2 不同分块大小和并发数的对比

我分别测了2MB、5MB、10MB、20MB四种分块大小,以及并发2、3、5三种情况。数据整理出来比较能说明问题,但也要说明,不同网络环境下的绝对值差异会很大,重点是趋势。

分块大小并发数1GB文件耗时(参考)请求数量观察结果
2MB2约5分30秒512个稳定,但请求太多,Tomcat解析开销高
5MB3约4分20秒205个稳定,适合弱网环境
10MB3约3分50秒103个推荐组合,网络与请求数平衡
10MB5约4分10秒103个偶发超时,需要重试,线程占用高
20MB3约3分40秒52个速度最快,但弱网下失败后重试成本高

分块太小,比如1MB或2MB,单个请求本身很快,但总请求数量暴涨,Tomcat对每个multipart请求都要做解析和临时文件写入,CPU和磁盘小文件操作成本反而拖慢整体速度。分块太大,比如50MB,几乎退化成整包上传,网络一旦抖动,重试一个50MB分块很痛苦。综合下来,普通办公网络选10MB、并发3最稳。

5.3 nginx与Tomcat参数调整

分块上传也并不是“只要分块了,其他参数就不用调了”。nginx和Tomcat层面的临时目录、缓冲区大小,仍然会影响单块的传输速度。

nginx里我给的配置是这样:

client_max_body_size 20m; proxy_request_buffering off; proxy_read_timeout 300s; proxy_send_timeout 300s;

client_max_body_size只要比前端分块大小大,理论上20MB就够。但有些用户可能在浏览器里直接拖了一个超过限制的文件进来,或者外部脚本绕过前端整包POST,所以如果你想留一点兜底空间,可以调到200m甚至0表示不限制,但后端Servlet的maxRequestSize要配合控制,避免有人发超大请求攻击。

重点说下proxy_request_buffering off。很多教程不会提这个,但nginx默认会先把请求体缓冲到临时文件,等完整收到后才转发给后端。对单个10MB分块影响不大,但如果分块并发是5,5个10MB的请求同时被缓冲,磁盘IO会多出一层负担。关掉缓冲区后,nginx把请求体流式转发给Tomcat,上传体验明显更顺滑。

Tomcat的Connector配置这样改:

<Connector port="8080" protocol="HTTP/1.1" maxPostSize="20971520" maxSwallowSize="-1" connectionTimeout="60000" maxThreads="200" />

maxPostSize设为20MB,匹配单块大小;maxSwallowSize="-1"是允许异常中断时吞掉任意大小的残余数据。如果你不设maxSwallowSize,默认只有2MB左右,用户传一半断了,Tomcat为了复用连接会尝试把剩余数据读完,超过限制就抛异常,前端看到的表现就是连接被重置。这个参数太隐蔽了,我一开始根本没注意到,是看Tomcat localhost日志里疯狂刷SEVERE才定位到的。

6. 从分块上传到更高阶方案

分块上传解决了我项目里“JSP页面能传大文件和多附件”的问题,但它并不是终点。随着附件越来越大、上传量越来越多,你会发现业务Tomcat既要跑页面又要中转文件,始终是个瓶颈。

6.1 断点续传的工程化经验

断点续传最能提升用户信心。我在前端实现时,不仅传分块,还在每个分块请求成功时把chunkIndex存到localStorage的一份Map里:

{ "fileId": "xxx", "uploaded": [0,1,2,3] }

重新选择同一个文件后,前端先请求GET /upload/progress?fileId=xxx,拿后端返回的已传分块索引,两个集合求差集,只传缺的部分。这个功能上线后,用户再也不用因为一次网络闪断从头传起。不过要留意,这是前端层面的语义,后端仍然要保证分块落盘和索引记录准确,两边对不齐的话续传就是续了个寂寞。

6.2 对象存储:把分块逻辑交给MinIO这类系统

如果项目有条件引入对象存储,我会非常建议把分块上传的“分块管理”责任从业务代码里挪出去。以MinIO为例,它提供S3标准的Multipart Upload接口,前端或后端调用CreateMultipartUpload、UploadPart、CompleteMultipartUpload,分块的合并由MinIO自己完成,应用层不需要维护临时目录,也不需要写合并线程。

JSP后端在其中的角色就变成了“签发行为人”:校验用户身份后,生成上传凭证或者预签名URL,前端拿这个URL直传MinIO/OSS。业务Tomcat不经过大文件流量,只处理元数据和回调。这正是很多人搜“minio上传很多大文件方案”时想要的答案。迁移的工作量其实不大,JSP页面里的切片逻辑可以复用,只是把请求地址从/upload/chunk换成对象存储的UploadPart地址,后端从“读流写文件”变成“管理分块状态”。

6.3 前端Worker与大文件下载的延伸思考

前端如果要用Web Worker做切片和MD5计算,我建议规划好Worker脚本的目录。在传统JSP项目打包war后,Worker脚本放在webapp/static/js/worker.js,页面里new Worker('static/js/worker.js')即可。但要注意,有些容器对静态资源路径的处理和开发环境不一致,上线前一定要用部署后的war包路径再验证一次。

和大文件上传对应的还有大文件下载。很多人搜“大文件下载链接”“大文件导出”,本质是把服务端文件以流式响应写给浏览器,或者让浏览器走断点续传/Range请求。分块上传的思路反过来用,就成了分块下载。这套方法在传和导两个方向都适用,所以把分块概念吃透之后,后续做报表大文件导出也能少走弯路。

最后说一个我个人的习惯:凡是给传统项目做上传改造,我都会在测试环境里故意用弱网工具模拟丢包,然后试一次中途断电、一次磁盘写满、一次nginx重启。分块上传逻辑如果能在这些极端情况下不留下脏数据和错乱文件,才算真正合格。很多代码看起来没问题,一遇到“最后一个分块还没到但合并线程已经跑了”这种边界情况就翻车,这些坑,都是要在上线前用真实故障去逼出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询