news 2026/10/13 12:06:47

JSP+Servlet实现大文件分段上传与断点续传完整方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JSP+Servlet实现大文件分段上传与断点续传完整方案

做Web开发的人,迟早会撞上大文件上传这堵墙。网盘传个2GB的压缩包、后台管理系统传几百MB的视频素材,如果直接拿form表单往里甩,大概率是传着传着没了反应,或者几分钟后浏览器报错,重新来一遍又是从头传起。这篇文章就专门讲讲,在JSP网页这套技术体系里,怎么把“大文件分段上传”和“断点续传”这两件事彻底搞定——前端怎么切文件、后端怎么收分片、最后怎么合并、中途断了又怎么续,全部走一遍。

这套方案不依赖任何花哨的框架,一个JSP页面加Servlet接口就能跑,适合正在维护JSP老项目的人,也适合想在Servlet/JSP体系里解决上传问题的新手。看完之后你不需要再去搜零散的代码片段,直接按这套结构抄就能落地。

1. 大文件上传连环坑:为什么要分段,又为什么要续传

1.1 HTTP协议不擅长传大文件,问题比你想的多

很多人觉得HTTP传文件和FTP差不多,无非是数据大一点慢一点。其实HTTP协议在设计之初主要是传HTML页面和图片,对超大文件的传输并没有做过多的优化。浏览器直接上传大文件时,你会遇到一连串非常现实的问题。

第一是连接超时。一个500MB的文件在普通带宽下可能要传很久,如果中间有几十秒没有数据传输,服务端或者中间的反向代理就可能把连接掐掉。掐掉之后,用户看到的就是一个上传失败。

第二是内存压力。传统表单上传时,整个请求体到达服务端,容器要解析multipart数据。如果服务端把文件完整读进内存再落盘,并发几个大文件上传,JVM很快就会被撑爆。所以很多Servlet/Tomcat默认对POST请求体大小做了限制,超了直接报错。

第三是不可恢复。这是最让人崩溃的,上传到80%断网,重新上传一切归零,又要从头开始。在弱网环境或者移动网络下,这种体验基本就是劝退。

所以分段上传的思路就是:把大文件切成很多个小块,一块一块分开传,每一块都是独立的HTTP请求,谁失败了就单独重传谁。这样既降低了单请求的超时风险,也让服务端每次只处理一小块数据,内存压力大大减小。断点续传则是分段上传的自然延伸——既然文件已经被切成块了,我只要知道哪些块已经成功上传,下次从没传的块继续就行。

1.2 分段上传与断点续传到底在解决什么问题

先说清楚一个容易混淆的概念:HTTP里还有一个叫“分块传输编码”(Chunked Transfer Encoding)的机制,那是HTTP协议层把响应体分块发给浏览器的技术,跟本文说的“把文件用JS切成小块再上传”是两码事,不要混在一起。

分段上传要解决的问题有三个:单请求体积过大导致的超时、内存溢出、失败后全量重传。它的核心做法是:前端读取文件字节流,按固定大小(比如每个分片2MB)用File API的slice方法切分,然后把每个分片作为单独的multipart请求发给服务端;服务端为每个文件建一个临时目录,分片文件按索引号命名保存;全部传完后,服务端按索引顺序把分片拼回完整文件。

断点续传在这个基础上多做了两件事:一是上传前先查一下“这个文件已经传到哪了”,服务端把已上传分片的索引列表返回给前端;二是前端跳过那些已上传的分片,只传缺失的部分。这样一来,哪怕传了一半浏览器崩了,重新打开页面、选中同一个文件,也能接着上次的进度继续。

如果做得好,还可以进一步实现“秒传”。所谓秒传,其实不传,而是靠文件指纹来判断——同一个文件之前已经有人传过了,服务端记录过它的唯一标识,你再次上传时只要提交标识,服务端发现已经存在完整文件,就直接返回“上传成功”,连分片都不用传。

1.3 技术选型:JSP/Servlet + 原生JS完全可以搞定

有人一听“JSP”就觉得是老古董,觉得是不是得上Spring Boot、上分布式文件系统才算正经方案。我的看法是,工具没有新旧之分,只有合不合适。JSP项目里要解决大文件上传,用Servlet处理接口、JSP渲染页面,再配合前端原生JavaScript,已经能覆盖绝大多数场景。这套方案的好处是依赖极少,不用引入额外的组件,也不要求你重构整个项目。

如果你的项目已经跑了几年的JSP体系,临时要加一个视频上传功能,为了这一个功能去改造技术栈,风险和成本都太高。先用Servlet把分片接收、状态记录、合并这几个接口写好,前端在现有页面上加一段JS,就能把问题解决掉。后来如果要迁移到Spring Boot,核心思路完全一样,把Servlet换成Controller、把JSP换成HTML页面,代码逻辑保持不变。

当然,如果是从零开始的新项目,我也不会劝你用JSP,但本文讲的这套分段上传+续传的设计思路,在任何Web技术栈里都是通用的。理解了JSP这边怎么落地,换成别的语言一样能写。

2. 前端分段与上传设计:切分、标识、并发、进度

2.1 File API切分文件:用slice按固定大小切块

前端切分文件是分段上传的第一步,实现起来其实就一行核心代码:file.slice(start, end)。HTML5的File API提供了这个能力,它能在不把整个文件读入内存的情况下,取文件字节范围的一个Blob对象。这个Blob就是我们要上传的一个分片。

切分大小是个需要权衡的参数。分片太小,比如256KB,那么1GB的文件会切出4000多个分片,每个分片都是一个独立HTTP请求,请求头、连接的握手开销会变得很大,整体速度反而慢。分片太大,比如50MB,单请求超时风险又回来了,而且失败后重传的代价变高。以我自己的实践看,普通互联网环境用2MB到5MB的分片比较均衡,内网环境可以放宽到10MB。下面这个表是我常用的参考:

场景推荐分片大小说明
公网弱网1MB~2MB单分片小,失败重试代价低,但请求数多
公网常规2MB~5MB请求数和单请求体积比较均衡,最常用
内网/带宽充足5MB~10MB请求数少,服务端压力小,但重试代价偏大

切分时还要注意一个边界问题:如果文件大小正好不是分片大小的整数倍,最后一片的长度会小于分片大小,这是正常的,循环条件写清楚就行。前端拿到分片总数用Math.ceil(file.size / CHUNK_SIZE)计算,避免漏掉最后一片。

2.2 文件唯一标识:为什么建议用MD5而不是文件名

服务端接收多个分片后,需要知道这些分片属于哪个文件。最简单的方案是用文件名,但文件名没有任何唯一性:如果两个用户分别上传同名文件,服务端的分片就会串掉。所以必须给每次上传生成一个唯一标识。

我推荐用文件的MD5值加上文件大小作为标识,或者至少让前端在切片前把文件的MD5算出来。MD5在这里的作用不是加密,而是给文件一个足够唯一的指纹。两个不同内容的文件算出同一个MD5概率极低,对于上传场景里的文件识别和完整校验来说完全够用。有些安全要求更高的场景可以用SHA-256,Web Crypto API原生支持,但计算大文件时要注意分批喂数据,别一次性把整个文件塞进内存。

这里要提醒一下,浏览器原生并没有直接提供计算文件MD5的接口,行业里常用的做法是引入一个开源MD5计算库(比如SparkMD5),读取文件后分块喂给库函数计算。计算一个2GB文件可能要几秒甚至更久,所以不能让用户干等着,要在界面上同步显示“正在计算文件校验,请稍候”,或者用Web Worker在后台线程算,避免卡住界面。后面章节我会专门讲大文件计算MD5的优化方案。

真正传到服务端时,每个分片请求都要带上fileId(MD5值)、index(分片序号)和totalChunks(总分片数)。服务端用fileId建目录、用index命名分片文件、用totalChunks校验完整性。这样即使前端换了浏览器、换了个时间重新打开页面,只要选的文件相同,MD5就相同,就能无缝续传。

2.3 并发上传与队列控制:串行还是并发?

分片切好之后,怎么传也有讲究。最简单的方法是一个接一个串行上传:传完第0片再传第1片,依次执行。串行的好处是逻辑简单、进度好算、对服务端压力小,缺点是慢,链路延迟会全部摊在每一片之间。如果网络环境好,串行上传大量分片会明显感觉浪费带宽。

另一种做法是并发上传,同时开几个请求一起传。这样能充分利用带宽,但并发数要控制好。一次开几十个并发请求,浏览器和服务器都受不了,服务端线程池可能被打满,其他业务接口也跟着受影响。我的做法是把并发数限制在3到5个,用一个简单的队列控制:同时最多N个请求在飞,一个分片完成后再从队列里取下一个分片。

并发上传对服务端没有顺序要求,因为分片是独立保存的,合并时统一按index排序。前端要处理的只是进度计算和失败重试逻辑:某个分片失败了,重传它自己就行,不用影响其他分片。

2.4 进度条的正确计算方式

进度条看似简单,实际上有细节。如果只是上传当前分片时把xhr.upload.onprogress里的loaded/total当进度,那进度条会在0%到100%之间反复跳,因为每个分片都是独立的请求,进度不是整个文件的进度。

正确做法是:已经完成的分片数乘以分片大小,再加上当前正在上传分片里的已传字节数,除以文件总大小。用公式说就是progress = (completedChunks * CHUNK_SIZE + currentLoaded) / file.size。并发场景下,可以简化为completedChunks * CHUNK_SIZE / file.size,当前正在飞的分片进度先不展开算,整体进度依然平滑,只是略微滞后一点,完全可接受。

还有一个常见的细节:xhr.upload.onprogress里的event.total是这个分片的总大小,不是整个文件大小,别直接拿去展示。我就是因为一开始没注意,做出来的进度条一路飙升到1000%,排查了半天才发现是单位弄错了。

3. 后端接收分片与文件合并:Servlet侧核心实现

3.1 JSP只负责展示,上传逻辑交给Servlet

先纠正一个常见的误区:JSP页面本身虽然也是Servlet,但它的定位是视图层,职责是把动态内容渲染成HTML。如果你把文件保存、分片合并这些业务逻辑直接写在JSP页面里,代码会迅速变成一团乱麻,文件上传这种逻辑一次性的还好,遇到要做续传、校验、清理这些功能就完全没法维护了。

正确的做法是JSP页面只管展示上传界面、调用接口,所有上传逻辑都放到独立的Servlet里。为了配合JSP页面,一般至少需要三个接口:上传分片接口、查询已上传分片状态接口、合并文件接口。这三个接口用原生Servlet实现即可,不需要引入框架。如果你用的Servlet版本支持@WebServlet注解,连web.xml都不用配,写起来很清爽。

3.2 用Part API接收分片,设置MultipartConfig参数

Servlet 3.0之后,接收multipart上传变得非常简单,核心是新提供的Part API。你只需要在Servlet上加上@MultipartConfig注解,然后在doPost里用request.getPart("file")拿到文件分片。

这里有个新手必踩的坑:不要试图用request.getParameter("file")去获取文件内容。multipart请求里的文本字段可以通过getParameter获取,但文件部分必须用getPart。getParameter返回的是表单字段字符串,你拿不到二进制文件内容。我第一次写的时候就犯过这个错,接口都调通了,保存下来的文件却一直是空文件。

@MultipartConfig注解有三个关键参数要理解:

  • fileSizeThreshold:这个值表示超过多大就直接写入临时文件,默认是0,意味着所有上传内容都会落盘。建议设成1MB或者2MB,小文件能直接用内存,大文件自动切换到磁盘,性能和内存占用都要好一些。
  • maxFileSize:单个文件的最大大小限制。分片上传时,这里要设置成不低于分片大小,比如分片2MB就设为10MB,留点余量。
  • maxRequestSize:单个请求的最大大小。由于每个请求只包含一个分片,这个值可以设置成分片大小再加点表单字段的余量。

如果把maxFileSize或者maxRequestSize设成0,表示无限制,但生产环境不建议这么干,容易被人用来消耗服务器资源,需要合理设置。

3.3 分片保存与已上传状态记录:临时目录枚举法

服务端收到每个分片后,要做的第一件事是把分片保存到磁盘上。我的做法是:在某个临时目录下,为每个fileId创建一个子目录,分片文件名直接用分片索引号,比如0.part、1.part、2.part。

String uploadRoot = "/data/temp_upload"; File chunkDir = new File(uploadRoot, fileId); if (!chunkDir.exists()) { chunkDir.mkdirs(); } part.write(new File(chunkDir, index + ".part").getAbsolutePath());

这样设计的好处是:已上传分片的状态根本不需要额外存数据库或内存,直接“数目录下有几个.part文件”就知道哪些分片传过了。查询已上传分片列表时,遍历目录下的文件名就能返回给前端。这种“临时目录枚举法”是最省事、最不容易出状态不一致的方案。

当然,如果你需要更精确的状态管理,比如要记录每个分片上传的时间、大小,或者分片数量非常多、目录枚举太慢,那就在数据库建一张上传记录表,或者在应用内存里用一个ConcurrentHashMap缓存已上传分片集合。缓存方案速度快,但服务端重启后状态丢失,需要依赖目录里的分片文件来判断实际已上传情况。我的经验是,在大部分业务场景下,目录枚举法已经足够用了,数据库和缓存反而增加了复杂度。

3.4 分片合并:顺序拼装与内存安全

所有分片传完后,前端会发送一个合并请求。合并Servlet要做的事情就是读取所有分片文件,按顺序写入目标文件。

合并的核心有两点要注意。

第一点就是分片排序。千万不能直接用字符串排序去排1.part和10.part,字典序会把10.part排在2.part前面,合并出来的文件就是乱序的。要么在保存分片时用补零的方式命名,类似00001.part、00010.part,保证字符串排序等于数字排序;要么在合并排序时用数字比较器。补零的方案更稳,文件在磁盘上的排列也清晰。

第二点是合并时的内存安全。最笨的写法是Files.readAllBytes()把某个分片一次性读进内存再写出去,这在分片不多、文件不大的时候没问题,但文件一大人就慌了。正确做法是固定一个8KB或者32KB的字节缓冲区,循环读写,把内存占用控制在一个固定常数级别。合并1GB的文件,进程内存也不会明显上涨。

byte[] buffer = new byte[8192]; try (OutputStream out = new FileOutputStream(targetFile)) { for (File chunk : sortedChunks) { try (InputStream in = new FileInputStream(chunk)) { int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } } }

合并完成后,最好再校验一下最终文件的大小是否等于各分片大小之和,或者校验一下最终文件的MD5是否等于前端传过来的fileId,能对得上再返回成功。这一步能拦截掉大多数“合并后发现文件损坏”的问题。

4. 断点续传与秒传的完整实现方案

4.1 断点续传的工作原理:先询问再跳过

断点续传其实不玄乎。前端在开始上传之前,先往服务端发一个查询请求,问“我打算传的这个文件(fileId就是它的MD5)已经传到哪了”,服务端根据临时目录里已有的分片文件,返回一个已上传分片索引列表。前端拿到这个列表,在循环上传分片的时候跳过这些索引,只传剩下的分片。

这中间有一个关键的传参设计:前端计算出来的fileId必须稳定。同一个文件,不管你在哪台机器上、什么时间打开上传,MD5算出来都一样,所以fileId就稳定。这在实现断点续传时价值非常大:中途断了,刷新页面重新选中文件,MD5还是它,查询接口就能把之前传过的分片找回来。

需要说明的是,这里的“断点”是指分片级别的断点,不是字节级别的断点。文件被切成2MB的分片后,一个分片要么完整上传成功、要么没传,不存在“传了一半的分片”。如果一个分片传输过程中断掉,服务端可能保存了一个不完整的.part文件,前端查询时发现这个分片索引已存在就跳过了,会导致合并时文件损坏。所以服务端在保存分片时,要把传输完整的文件才写入.part文件,中间落盘失败就直接清理掉;查询接口返回的也应该只是“确认完整”的分片。

4.2 查询接口返回已上传分片列表

查询接口非常简单,一个GET请求就行了。前端传fileId,服务端遍历对应临时目录,把存在的分片索引号返回。

@WebServlet("/upload/status") public class UploadStatusServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { String fileId = req.getParameter("fileId"); File chunkDir = new File("/data/temp_upload", fileId); List<Integer> uploadedList = new ArrayList<>(); if (chunkDir.exists() && chunkDir.isDirectory()) { File[] files = chunkDir.listFiles((dir, name) -> name.endsWith(".part")); if (files != null) { for (File f : files) { uploadedList.add(Integer.parseInt(f.getName().replace(".part", ""))); } } } resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write(new Gson().toJson(uploadedList)); } }

前端拿到这个数组后,就可以愉快地跳过已传分片。这里要注意一次返回的JSON不要用太大的格式,如果分片很多,返回几万个索引字符串会有点占带宽,可以把索引号之间用逗号拼接成字符串,前端split一下再转数字,效率更高。

4.3 秒传少传:MD5命中直接返回成功

秒传是断点续传的上一层应用,技术上不复杂,但对业务体验的提升非常明显。它的逻辑是:如果服务端已经存在一个相同内容的完整文件,前端不需要再传分片,直接提交fileId,服务端发现文件已存在,就返回“上传完成”。

实现秒传需要服务端维护一个“文件指纹 → 完整文件路径”的映射关系。映射可以存在数据库表里,也可以用一个持久化的JSON文件。上传分片前,或者说在查询状态接口的时候,服务端先查一下这个fileId对应的完整文件是否已经存在,如果存在,直接让前端跳过所有分片去走最后的“合并/完成”流程。

这里要说清楚一个细节:MD5适合做文件去重和完整性校验,但它不适合用来做密码学安全场景,因为MD5存在碰撞攻击的可能。但在文件上传场景,两个不同文件碰撞出相同MD5的概率极低,前面的简单校验足以保证业务正确性。如果业务对完整性要求很高,可以在合并后重新计算文件MD5与前端提交的fileId比对,不一致就报错。

4.4 合并触发时机:前端通知还是服务端自动触发?

合并操作什么时候触发,有两种设计思路。第一种是前端把最后一个分片传完后,主动调用合并接口;第二种是服务端保存分片后检查已上传数量,如果是最后一个分片,自动触发合并。两种方案都能实现功能,但我更推荐前端显式触发。

前端触发的好处是可控性强:合并是一个相对耗时的操作,如果服务端自动触发,前端不知道合并是成功了还是失败了,不方便给用户反馈。前端显式调用合并接口,接口成功就告诉用户“上传完成”,接口失败就可以弹出重试按钮,让用户再点一次“完成上传”。而且合并接口本身是幂等的,即使重复调用,只要在合并前后加一个状态判断,就不会重复处理。

服务端自动触发的好处是少一次请求,逻辑上更简洁,但错误处理和状态反馈都变差了。除非是特别追求极致性能的场景,否则我建议用前端显式触发。

5. 完整可运行的代码示例:从切分到合并

5.1 前端核心代码:分片上传、进度与续传

下面这段前端代码是我在实际项目中精简出来的核心结构,只依赖原生JavaScript,不涉及任何框架。计算MD5的部分用到了开源的SparkMD5库,注释里都写清楚了。

const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB分片 const CONCURRENCY = 3; // 同时上传3个分片 async function computeFileMd5(file) { // 使用 SparkMD5 计算文件MD5,需要提前引入该库 return new Promise((resolve, reject) => { const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); let offset = 0; const step = 2 * 1024 * 1024; function loadNext() { const slice = file.slice(offset, offset + step); reader.readAsArrayBuffer(slice); } reader.onload = function (e) { spark.append(e.target.result); offset += step; if (offset < file.size) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror = function () { reject(new Error('计算MD5失败')); }; loadNext(); }); } function uploadChunk(fileId, chunk, index, totalChunks) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/upload/chunk'); xhr.onload = function () { if (xhr.status === 200) { resolve(); } else { reject(new Error('分片' + index + '上传失败')); } }; xhr.onerror = function () { reject(new Error('分片' + index + '网络异常')); }; const formData = new FormData(); formData.append('file', chunk, 'chunk.part'); formData.append('fileId', fileId); formData.append('index', index); formData.append('totalChunks', totalChunks); xhr.send(formData); }); } async function queryUploaded(fileId) { const resp = await fetch('/upload/status?fileId=' + encodeURIComponent(fileId)); const text = await resp.text(); if (!text) return []; return text.split(',').map(Number); } async function mergeFile(fileId, fileName) { const formData = new FormData(); formData.append('fileId', fileId); formData.append('fileName', fileName); const resp = await fetch('/upload/merge', { method: 'POST', body: formData }); return resp.status === 200; } async function uploadFile(file, onProgress) { const fileId = await computeFileMd5(file); const totalChunks = Math.ceil(file.size / CHUNK_SIZE); const uploaded = await queryUploaded(fileId); const queue = []; for (let i = 0; i < totalChunks; i++) { if (uploaded.indexOf(i) !== -1) continue; queue.push(i); } let completed = uploaded.length; let cursor = 0; async function worker() { while (cursor < queue.length) { const index = queue[cursor++]; const start = index * CHUNK_SIZE; const blob = file.slice(start, start + CHUNK_SIZE); await uploadChunk(fileId, blob, index, totalChunks); completed++; onProgress && onProgress(completed / totalChunks); } } const workers = []; for (let i = 0; i < CONCURRENCY && i < queue.length; i++) { workers.push(worker()); } await Promise.all(workers); // 已经存在所有分片或全部传完,执行合并 const merged = await mergeFile(fileId, file.name); if (!merged) { throw new Error('文件合并失败,请稍后重试'); } return { fileId, name: file.name }; }

这段代码里并发上传用了一个非常经典的生产者消费者模式:一个cursor指针维护待传分片队列,3个worker并发消费队列。completed变量在进度回调里传出去,前端就能拿到0到1之间的进度值。

5.2 后端Servlet代码:分片上传与状态查询

后端代码我分成两个Servlet来写。第一个是处理分片上传的,第二个是查询状态的。由于代码里用了中文注释和注解方式访问,这里按Servlet 3.0的规范写,直接贴进去就能用。

@WebServlet("/upload/chunk") @MultipartConfig( fileSizeThreshold = 1024 * 1024, maxFileSize = 10 * 1024 * 1024, maxRequestSize = 10 * 1024 * 1024 ) public class UploadChunkServlet extends HttpServlet { private static final String UPLOAD_ROOT = "/data/temp_upload"; protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String fileId = req.getParameter("fileId"); String indexStr = req.getParameter("index"); String totalStr = req.getParameter("totalChunks"); if (fileId == null || indexStr == null || totalStr == null) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.getWriter().write("缺少必要参数"); return; } int index = Integer.parseInt(indexStr); int totalChunks = Integer.parseInt(totalStr); Part filePart = req.getPart("file"); if (filePart == null || filePart.getSize() == 0) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.getWriter().write("分片内容为空"); return; } File chunkDir = new File(UPLOAD_ROOT, fileId); if (!chunkDir.exists() && !chunkDir.mkdirs()) { resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); resp.getWriter().write("创建临时目录失败"); return; } // 文件名补零到5位,保证字符串排序正确 String chunkName = String.format("%05d.part", index); filePart.write(new File(chunkDir, chunkName).getAbsolutePath()); // 分片大小记录,合并后可以校验 File chunkFile = new File(chunkDir, chunkName); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":0,\"index\":" + index + "}"); } }

查询状态的Servlet在4.2节里已经给过代码,这里只补充一个逻辑:查询时如果发现/data/temp_upload/{fileId}/merge.lock文件存在,说明这个文件已经合并完成,前端可以直接走合并成功分支。用这个方式避免前端在合并期间重复触发合并。

5.3 合并Servlet与文件写入

合并Servlet负责把所有分片拼回完整文件。它做的事情很明确:拿到fileId和原始文件名,检查临时目录,排序分片,写入目标目录,最后清理临时分片目录。

@WebServlet("/upload/merge") public class MergeServlet extends HttpServlet { private static final String UPLOAD_ROOT = "/data/temp_upload"; private static final String FINAL_ROOT = "/data/files"; protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String fileId = req.getParameter("fileId"); String fileName = req.getParameter("fileName"); File chunkDir = new File(UPLOAD_ROOT, fileId); File lockFile = new File(chunkDir, "merge.lock"); synchronized (fileId.intern()) { if (lockFile.exists()) { resp.getWriter().write("{\"code\":0,\"message\":\"already merged\"}"); return; } File[] chunkFiles = chunkDir.listFiles((dir, name) -> name.endsWith(".part")); if (chunkFiles == null || chunkFiles.length == 0) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.getWriter().write("没有可合并的分片"); return; } // 数字排序,注意不要直接用字符串排序 Arrays.sort(chunkFiles, Comparator.comparingInt( f -> Integer.parseInt(f.getName().replace(".part", "")))); File finalDir = new File(FINAL_ROOT); if (!finalDir.exists()) finalDir.mkdirs(); // 文件名建议做去重处理,避免同名文件被覆盖 File targetFile = new File(finalDir, fileId + "_" + fileName); byte[] buffer = new byte[8192]; try (OutputStream out = new FileOutputStream(targetFile)) { for (File chunkFile : chunkFiles) { try (InputStream in = new FileInputStream(chunkFile)) { int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } } } // 合并完成后删除分片目录,并写入锁文件 deleteQuietly(chunkDir); File finalLock = new File(FINAL_ROOT, fileId + ".lock"); try (OutputStream lockOut = new FileOutputStream(finalLock)) { lockOut.write(fileName.getBytes("UTF-8")); } resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":0,\"message\":\"merge success\"}"); } } private void deleteQuietly(File dir) { File[] files = dir.listFiles(); if (files != null) { for (File f : files) { if (f.isDirectory()) deleteQuietly(f); else f.delete(); } } dir.delete(); } }

这里有几个细节值得展开讲一下。第一,合并操作本身是串行的,同一个fileId同时被两个请求触发合并,会出问题。我在合并方法外层套了一个synchronized,锁对象用fileId.intern(),能锁住同一个fileId的并发请求。第二,最终文件名用fileId + "_" + fileName,这样即使不同用户上传了不同文件但同名,也不会覆盖。第三,合并完成后直接删除临时分片目录,这是必要的清理动作,不然磁盘会慢慢被.part文件占满。

5.4 部署时容易踩的配置坑

代码写完部署上去,你可能会遇到几个很隐蔽的坑,我一个个说。

第一个是Tomcat的maxPostSize限制。老版本的Tomcat默认maxPostSize是2MB,也就是说你form表单POST请求体超过2MB,连接会被直接断开。好消息是,一旦你给Servlet加了@MultipartConfig注解,Tomcat会改用注解里的maxRequestSize,默认等于0(不限制)。但如果你没有加@MultipartConfig,或者你的Servlet容器版本比较老,就要留意这个参数有没有被改过。

第二个是前面挂了Nginx或Apache做反向代理时,代理层默认也有请求体大小限制。Nginx的client_max_body_size默认是1MB,不修改的话,不管后端怎么设置,大请求都会被Nginx直接拦截返回413。部署的时候一定要检查代理配置,加上类似client_max_body_size 50m;的设置。这个坑我踩过一次,排查了半天后端,最后发现是Nginx层的问题。

第三个是上传目录的磁盘空间和权限。分片上传过程中,所有数据都先落在临时目录,如果临时目录所在分区空间不足,写入就会失败。生产环境最好把上传目录单独挂一个大分区,并且定期查看磁盘占用。目录权限也要确认应用进程有读写权限,否则part.write会抛IOException。

第四个是跨域问题。如果前端页面和后端接口不在同一个域名下,XHR上传会触发CORS,需要服务端在响应头里加上Access-Control-Allow-Origin等相关头。这个在前后端分离的项目里最容易遇到,但在JSP同域名场景下基本不用考虑。

6. 常见问题排查与避坑经验

6.1 报错413/400/500的排查顺序

分片上传功能联调时,报错类型比较集中,按下面的顺序排查基本能解决。

报错类型常见原因排查方法
413 Request Entity Too LargeNginx的client_max_body_size限制,或Tomcat maxPostSize看请求是不是被代理层拦了,改代理配置
400 Bad Request缺少参数、Part为空、请求格式不对打印请求参数,确认formData字段名一致
500 Internal Server Error保存目录不存在、磁盘满、权限问题查看服务端日志,确认上传目录可写且空间足够
404 Not FoundServlet映射路径不对确认@WebServlet注解的路径与前端请求路径完全一致

一个非常典型的现场是:本地开发好好的,一上测试环境就413。原因百分之九十是测试环境Nginx的client_max_body_size没调,或者后端配置里的maxRequestSize设小了。

6.2 合并后文件损坏怎么办

合并出来的文件打不开、播放失败、解压报错,这基本就是分片顺序或者分片内容的问题。

第一步检查顺序。我已经在前面强调了文件名补零和数字排序,这里再补一句:合并不但要按文件名排序,还要确认总的分片数没有缺失。如果总共100片,只合并了98片,文件当然完整不了。前端totalChunks参数传过来之后,服务端合并前最好校验一下实际分片文件数是否等于totalChunks。

第二步检查分片内容。如果某个分片在传输过程中被代理或者网络分层截断,保存下来的.part文件大小可能不等于CHUNK_SIZE。正常的2MB分片,除最后一片外都应该正好是2MB。可以写个脚本遍历临时目录,检查所有.part文件大小,把不符合的找出来重新上传。出现这种情况,一般是服务端保存逻辑有问题,或者是代理层对请求体做了特殊处理,比如压缩、篡改。

第三步检查合并代码。看合并时是不是用了字符流(FileWriter/Writer)而不是字节流(OutputStream/FileOutputStream)。字节流能处理二进制,字符流会把字节转换搞坏。所以合并一定要用InputStream和OutputStream。

6.3 大文件计算MD5太慢的优化方案

浏览器端计算整个文件的MD5,在文件特别大时确实是一个瓶颈。2GB的文件完整计算可能要十几秒甚至更久,用户在这段时间里看着页面卡死,体验很差。

有三个优化思路。第一个是用Web Worker,把MD5计算放到后台线程,主线程照常响应用户操作,界面上只显示“正在校验文件”。第二个是抽样计算,不是读取全部字节,而是只取文件开头的若干个字节、中间的若干个字节、结尾的若干个字节混合计算。抽样的MD5不能保证绝对唯一,但用于续传和秒传已经够用。第三个是改用Web Crypto的SHA-256,它由浏览器底层实现,计算速度比纯JS的MD5库快很多,但需要在支持Visual Crypto的环境下用,老浏览器不支持,而且计算大文件时同样要分批处理,避免一次性把文件读进内存。

我的建议是:优先用Web Worker + SparkMD5做完整计算,这个方案兼容性最好,代码也成熟。如果文件经常超过1GB,再考虑抽样方案。

6.4 分片临时文件的清理策略

上传中断、用户关掉页面、传输失败,这些情况都会在服务端留下.part分片文件。如果不管它们,磁盘迟早被填满。我常用的清理策略有三个。

第一是在合并成功后立刻删除临时目录,这一点在合并Servlet里已经实现了。第二是提供一个“取消上传”接口,前端检测到用户手动取消时调用,服务端直接删除对应fileId的临时目录。第三是定时清理,写一个简单的后台任务,扫描上传目录,找出最后修改时间超过24小时的所有目录,直接删除。定时任务不需要很复杂,可以用ServletContextListener在应用启动时起一个ScheduledExecutorService,每天扫一次。

这里要注意,定时清理时如果直接删除正在上传中的分片目录,会导致用户上传报错。所以定时任务里要判断目录最后修改时间,超过24小时没动静的目录基本可以认定是死数据,删掉不影响正在进行的上传。

6.5 多用户并发上传同一文件时的冲突处理

有一个很微妙的场景:两个用户同时上传内容相同的一个文件。因为内容相同,MD5算出来一样,fileId也一样,服务端临时目录就会指向同一个路径。两个用户的分片同时写入同一个目录,看起来各写各的.part文件,似乎没冲突,但如果两个用户并发触发合并,或者其中一个用户上传的同时另一个用户的定时清理把临时目录删了,就会出问题。

处理办法是给fileId加一个userId前缀,或者服务端在收到上传分片请求时,用fileId + "_" + sessionId作为实际临时目录名,这样每个用户有自己的独立目录,互不影响。查询状态和合并的时候,也用同一个带sessionId的目录名。秒传功能不受影响,因为秒传走的是合并完成后记录的fileId映射表,跟临时目录名无关。

还有更极端的场景:同一个人在多台设备上同时上传同一个文件。这种情况用sessionId区分也行,因为sessionId不同,目录不同,各自都能正常完成。只在秒传映射表层面可能出现同时插入记录,这种在数据库里加个唯一索引或者应用层加锁就能解决。

最后说两个我自己一直保持的习惯。一个是临时目录和最终目录分层,磁盘满了排查时一眼就能定位是上传中的残留还是已经入库的文件,清理策略也好写。另一个是合并文件时永远用缓冲区循环读写,别图省事用Files.readAllBytes(),文件一上GB立刻给你颜色看。这套分段上传方案我在JSP项目里跑了几轮,稳定性和体验都远好过一次性上传。你如果第一次落地,建议先跑通串行版本,再上并发和秒传,一步步来,踩坑也能踩得明白。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 12:06:18

SpringBoot Thymeleaf项目实现URL驱动弹窗方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 12:05:28

Forest Pack 7 完全指南:从安装部署到核心参数与避坑实践

简介&#xff1a;这份PDF是3ds Max森林树木植物散布插件Forest Pack 7的官方帮助文档&#xff0c;面向建筑可视化、游戏开发与影视特效领域的三维设计师&#xff0c;尤其适合需要在大场景中批量布置植被、人群、建筑与碎石等元素的中高级用户。文档系统梳理了从安装、论坛支持、…

作者头像 李华
网站建设 2026/10/13 12:04:44

SolidWorks Routing界面英文怎么办?三步汉化及库文件属性修改指南

简介&#xff1a;面向Solidworks Routing用户的界面汉化操作指南&#xff0c;专门解决管路设计模块默认显示英文、切换中文失效等常见问题。文档从中文语言包安装状态检查讲起&#xff0c;依次说明系统选项、文件位置及CHS文件夹指向等关键配置步骤&#xff0c;同时补充Routing…

作者头像 李华
网站建设 2026/10/13 12:04:44

Excel多源数据清洗与高级筛选:从零搭建可复用动态看板

半年前我在处理一份四十万行销售流水时&#xff0c;连续加了三个夜班。表面问题是Excel卡顿&#xff0c;深层问题是数据源太杂&#xff1a;系统导出的订单表、手工维护的客户表、供应商提供的分类表&#xff0c;字段名五花八门&#xff0c;同一含义写成“商品名称”“产品名”“…

作者头像 李华
网站建设 2026/10/13 12:03:47

WinForm嵌入WPF控件:ElementHost桥接与避坑指南

简介&#xff1a;面向C#桌面开发者的示例工程&#xff0c;展示如何在Winform应用中承载并刷新WPF DataGrid控件&#xff0c;解决跨框架UI集成的常见需求。资源以ElementHost为互操作核心&#xff0c;完整涵盖WPF用户控件设计、DataGrid列定义与样式配置、DataContext数据绑定、…

作者头像 李华
网站建设 2026/10/13 12:03:33

Agent 架构实战:从五组件与 ReAct 循环到企业级 Agent OS 落地

做 Agent 的同行大概都有过这种时刻&#xff1a;Demo 里把 ReAct 循环跑通&#xff0c;信心满满觉得 Agent 架构不过如此&#xff1b;等接到企业内部需求&#xff0c;才发现模型答对了问题&#xff0c;系统却做不对事。我这两年一直在折腾 Agent 架构&#xff0c;从脚本串流程一…

作者头像 李华