1. 文件夹断点续传的痛点到底是什么
文件夹断点续传这件事,十个人问我九个人是同一个场景:网页上要传一个几十GB的文件夹,里面有几千个文件,传的过程中网络一抖、浏览器一崩,前面全部白干。如果你正在做一个JSP网页,需求里写着“支持文件夹断点续传”,那你这篇文章基本就是这个功能的完整落地流程。
先说清楚一个容易被忽略的事实:传统JSP网页里的文件上传,走的是 <form enctype="multipart/form-data"> 这种表单提交方式,服务端用 Servlet 的 Part 或者 Commons FileUpload 去接。这种模式天然不支持断点,因为整个请求体是一次性提交的,浏览器把文件流一股脑往外发,中途断了就断了,服务器那边拿到的是一堆不完整的内容,无法拼回去,也没有任何恢复的入口。
那文件夹上传呢?又比单文件复杂一个层级。单文件断点续传只需要记录“这个文件传到第几字节了”,文件夹断点续传要记录的东西就多了:哪个文件传完了、哪个文件传了一半、传到哪个分片、文件夹结构怎么保留、下次进来如何识别同一个文件夹。麻烦归麻烦,但架构思路是明确的。
JSP网页本身其实不参与断点续传的核心逻辑,它更像一个容器,真正干活的是前端JavaScript和后端的Servlet接口。所以这篇文章我按实际开发顺序来讲:先从需求角度分析方案选型,再拆前端实现、后端实现,最后给出一套可复用的代码结构和常见坑的排查思路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:文件级续传和分片级续传哪个适合你
2.1 文件级断点续传:思想简单,但只适合小文件场景
文件级断点续传的思路是:文件夹里每个文件独立上传,每个文件有一个“已上传大小”的标记,传输中断后,下次从标记位置继续写入。
听起来很理想,但它有一个隐藏前提——你要能从一个文件的中间位置开始续传。对于HTTP协议来说,最标准的做法是使用Range头。后端返回 Accept-Ranges: bytes,前端用 XHR.setRequestHeader('Range', 'bytes=start-') 发起一个范围请求,服务器从这个位置开始读取文件流返回给客户端。
但这个方案放在“网页上传”这个方向上不成立。因为网页上传时,数据流向是客户端到服务器,Range头是服务器返回给客户端用的,适用于下载续传。上传方向没有标准协议支持断点续传,你不能像下载那样告诉服务器“我从第100字节开始给我接着收”,除非你自己实现一套专门的接口协议。
所以文件级断点续传在实际Java Web项目里,基本被改造成下面这种玩法:前端先查一下服务器上这个文件已经存了多大,然后用 File.slice() 切出剩余部分的Blob,POST到服务器,服务器用 RandomAccessFile 的 seek() 方法跳到指定位置继续写。
看起来也不复杂,但坑在于:一个文件只切一刀,如果文件本身很大,单次请求的数据量依然很大,中间任何一次网络抖动都可能导致请求体不完整,而这种“半截请求体”对服务器来说是最恶心的——HTTP协议没有给你判断请求体是否完整的机制,只能靠你主动在参数里带上总长度来比对。
2.2 分片级断点续传:大文件场景下的唯一靠谱解
分片级断点续传的核心是把文件切成固定大小的碎块,比如每片4MB,然后一片一片上传。每一片都是独立的HTTP请求,互不依赖。传输中断后,只需要找出哪些分片没传上去,重新传这几片就行了,其他分片原样保留。
切分片的根本原因在于:把大请求拆成多个小请求,每个小请求的成功或失败都是独立可判定的。网络抖动只影响当前这一片,不会把整个文件拖下水。Java Web实际项目里,分片上传基本上成了大文件上传的标准方案。
切多大合适?我在生产项目里一般取2MB到8MB之间。太小了会浪费请求的开销——每个分片都要带上文件编号、分片序号这些元数据,网络往返也多;太大了又失去了分片的意义,一个4MB和40MB的网络请求,失败概率完全不是一个数量级。实测下来,4MB分片在普通办公网络环境下表现最稳定。
分片上传要解决的三个核心问题:
- 前端如何把一个File对象切成多个Blob分片
- 后端如何接收并暂存这些分片
- 所有分片上传完成后,如何无损地合并成完整文件
第一个问题用 Blob.prototype.slice() 就能解决,第三个问题的核心是保证合并顺序正确。中间分片的暂存位置,JSP项目里通常就放在服务器本地磁盘,用临时目录按会话组织,这也是最简单稳定的做法。
2.3 秒传:不是断点续传,但和它强绑定
秒传是另一个话题,但凡是做大文件上传的,一定会把秒传和断点续传一起做。秒传的原理是:上传前先计算文件的MD5或SHA-1值,发给服务器查询。服务器存了一个文件指纹库,如果发现这个文件已经存在,直接返回“上传成功”,前端就不用真传了,体验上就是秒传。
秒传和断点续传经常被放在一起说,原因是它们的实现走了同一条路:都需要先对文件做标识,都需要一个查询接口来获取“这个文件已经有什么了”。做断点续传时顺手把秒传的逻辑加上,成本不高,但收益很直观——很多用户反复上传同一个资料包场景下,能省下大量带宽和服务器存储。
我建议的方案是:前端计算整个文件夹所有文件MD5太大不现实,会卡死浏览器。折中做法是计算每个文件的MD5,文件级做秒传,分片级做断点续传。两者配合,形成一个比较完整的上传体系。
3. 前端实现:用HTML5和JavaScript把文件夹变成分片流
3.1 文件夹选择:webkitdirectory和webkitRelativePath是核心
前端要拿到文件夹里的文件列表,现阶段还是绕不开webkit前缀。
html复制<input type="file" id="folderPicker" webkitdirectory multiple />
webkitdirectory 属性让文件选择框变成“选择文件夹”模式,用户选整个文件夹后,input的 files 属性会返回文件夹下所有文件的File对象列表。每个File对象上有一个 webkitRelativePath 属性,保存着该文件相对于所选文件夹的路径,比如 "项目文档/图片/logo.png",这给了你还原文件夹结构的能力。
拿到文件列表后,先过滤掉空目录。浏览器不会返回空目录,这是好事,说明不用担心目录结构的占位问题。
javascript复制const files = Array.from(fileInput.files).filter(f => f.size > 0);
为什么不传空文件?因为0字节文件对断点续传来说没有意义,后端在创建目录结构时顺手把空文件建出来就行。
3.2 文件标识:文件名不靠谱,用哈希组合
要做断点续传,就必须能回答“这个文件是不是上次那个文件”。用文件名?不行,同名文件太常见了。要同时考虑 相对路径 + 文件大小 + 最后修改时间 的组合,这样碰撞概率就低很多了。
javascript复制function buildFileIdentity(file, relativePath) {
return {
fileId: md5(relativePath + file.size + file.lastModified),
name: file.name,
relativePath: relativePath,
size: file.size,
lastModified: file.lastModified
};
}
fileId 可以用MD5或者任何哈希算法,只要是字符串就行。注意这里不需要对整个文件内容做加密,只是生成一个标识符而已,别和秒传里的文件MD5混了。
3.3 分片切割与上传:队列式还是并发式
拿到File对象后,用 slice() 方法切成多个分片:
javascript复制const CHUNK_SIZE = 4 * 1024 * 1024; // 4MB
function splitFile(file) {
const chunks = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + CHUNK_SIZE, file.size);
chunks.push({
file: file,
index: chunks.length,
start: start,
end: end,
blob: file.slice(start, end)
});
start = end;
}
return chunks;
}
上传时用XMLHttpRequest或fetch都行,但要注意:fetch对上传进度的支持不如XHR好。XHR的 upload.onprogress 事件能拿到 loaded 和 total,方便做进度条。所以我建议用XHR上传,省事。
每个分片的上传接口用FormData携带二进制数据:
javascript复制function uploadChunk(uploadUrl, fileIdentity, chunkInfo) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('fileId', fileIdentity.fileId);
formData.append('fileName', fileIdentity.name);
formData.append('relativePath', fileIdentity.relativePath);
formData.append('totalSize', fileIdentity.size);
formData.append('chunkIndex', chunkInfo.index);
formData.append('chunkCount', totalChunks);
formData.append('chunkSize', CHUNK_SIZE);
formData.append('file', chunkInfo.blob, fileIdentity.name + '.part' + chunkInfo.index);
const xhr = new XMLHttpRequest();
xhr.open('POST', uploadUrl, true);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
// 更新当前分片的上传进度
}
};
xhr.onload = () => {
if (xhr.status === 200) {
try {
const resp = JSON.parse(xhr.responseText);
if (resp.code === 0) {
resolve(resp);
} else {
reject(new Error(resp.message));
}
} catch (e) {
reject(e);
}
} else {
reject(new Error('HTTP Error: ' + xhr.status));
}
};
xhr.onerror = () => reject(new Error('Network Error'));
xhr.send(formData);
});
}
并发数控制是个容易忽略的点。一次性把所有分片全部发出去,服务器受不了,浏览器也容易卡死。建议用简单的并发控制,一次只跑3到5个分片:
javascript复制async function uploadAllChunks(chunks, fileIdentity) {
const CONCURRENT = 3;
let index = 0;
const tasks = [];
const worker = async () => {
while (index < chunks.length) {
const idx = index++;
await uploadChunk('/upload/chunk', fileIdentity, chunks[idx]);
// 每传完一片,更新整体进度
}
};
for (let i = 0; i < CONCURRENT; i++) {
tasks.push(worker());
}
await Promise.all(tasks);
}
3.4 断点恢复的关键:先查询再上传
断点续传的“断点”在哪记录?前端内存里不靠谱,页面一刷新就没了;localStorage只能记个简单的进度状态,但分片是否上传成功,最终判断标准在服务端——服务端磁盘上有什么才是真正有效的。
所以前端在开始上传之前,要先调用后端的一个查询接口,告诉后端“我要上传这个文件”,后端查一下临时目录,返回“这个文件已经有哪些分片了”。前端把已存在的分片从待上传列表里剔除,只传剩下的。
javascript复制async function getUploadedChunks(fileIdentity) {
const resp = await fetch('/upload/status?fileId=' + fileIdentity.fileId);
const data = await resp.json();
return data.uploadedChunks; // 返回已存在的分片序号数组
}
这里有个细节:后端判断分片是否已存在,推荐用“分片大小 + 分片序号”来校验,而不是只看文件是否存在。如果上一个分片传了一半就被中断了,磁盘上可能留下一个不完整的文件,这种半截分片必须清除重传。
4. 后端实现:Servlet接口设计与分片合并逻辑
4.1 三个核心接口:上传分片、查询状态、合并文件
JSP项目的后端,最直接的方式就是用Servlet接口。Java Web项目里Servlet依旧是基础,Spring Boot项目也可以按同样的路径设计。
接口一:上传分片
code复制POST /upload/chunk
参数:
fileId 文件唯一标识
fileName 原始文件名
relativePath 相对路径
totalSize 文件总大小
chunkIndex 分片序号(从0开始)
chunkCount 分片总数
file 分片二进制内容
接口二:查询已上传状态
code复制GET /upload/status?fileId=xxx
返回:
{
"uploadedChunks": [0, 1, 2, 5, 6],
"merged": false
}
接口三:合并分片
code复制POST /upload/merge
参数:fileId
这三个接口的职责边界要清晰,不要混在一个Servlet里。我见过不少项目为了省事,把查询和上传写在一个方法里,用参数区分,最后代码乱成一团,没法维护。
4.2 分片接收与暂存目录设计
分片接收后不能直接写到最终目标位置,否则一次网络错误留下的残片会被当成有效文件。正确做法是先把所有分片存到一个临时目录,目录结构推荐这样组织:
code复制临时根目录/
上传会话ID/或fileId/
0.part
1.part
2.part
...
file.meta(可选,存文件元信息)
临时目录的根路径用 System.getProperty("java.io.tmpdir") 或者配置一个专门的目录都行。磁盘空间要关注,传大文件时临时目录会占很多空间,最好定时清理超过24小时没有完成合并的临时目录。
Servlet接收分片的代码,用原生的Part接口就能搞定:
java复制@WebServlet("/upload/chunk")
@MultipartConfig(maxFileSize = 10 * 1024 * 1024, maxRequestSize = 50 * 1024 * 1024)
public class ChunkUploadServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
String fileId = req.getParameter("fileId");
String fileName = req.getParameter("fileName");
String relativePath = req.getParameter("relativePath");
int chunkIndex = Integer.parseInt(req.getParameter("chunkIndex"));
Part filePart = req.getPart("file");
// 分片暂存根目录
File chunkDir = new File(getChunkRootDir(), fileId);
if (!chunkDir.exists()) {
chunkDir.mkdirs();
}
// 保存分片到临时文件
File chunkFile = new File(chunkDir, chunkIndex + ".part");
try (InputStream in = filePart.getInputStream();
FileOutputStream out = new FileOutputStream(chunkFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
writeJson(resp, "{\"code\":0,\"message\":\"success\"}");
}
}
注意 @MultipartConfig 的 maxFileSize 要设置得比分片大小大一些,不然大分片直接被容器拒收了。maxRequestSize要大于单次请求的总大小,但同时要保证不超出服务器内存承受范围。
4.3 分片校验:不完整的分片宁可不要
分片保存前,最好做两层校验。
第一层是大小校验。由于每个分片是固定大小(除了最后一片),后端可以校验传入的 chunkIndex 对应的期望大小,如果文件实际大小和期望不符,返回错误并要求重传。
java复制long expectedSize = chunkSize;
if (chunkIndex == chunkCount - 1) {
expectedSize = totalSize - (long) chunkIndex * chunkSize;
}
if (filePart.getSize() != expectedSize) {
// 分片大小不匹配,删除并返回错误
writeJson(resp, "{\"code\":1,\"message\":\"chunk size mismatch\"}");
return;
}
如果不做这个校验,网络传输中分片内容被截断,即使你成功写入了part文件,合并出来的最终文件也一定是损坏的。这种损坏在合并阶段很难排查,往往要到用户下载后打开文件才发现。
第二层是可选的重试机制。前端如果发现接口返回“chunk size mismatch”,应该自动重传当前分片,而不是继续下一个,否则最终文件必然损坏。
4.4 合并算法:从分片序列到完整文件
合并分片是整个链路里最关键的一步。核心逻辑很简单:按分片序号的顺序,把各个part文件内容依次写入目标文件。
java复制@WebServlet("/upload/merge")
public class ChunkMergeServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String fileId = req.getParameter("fileId");
String fileName = req.getParameter("fileName");
String relativePath = req.getParameter("relativePath");
String originalFileName = req.getParameter("originalFileName");
File chunkDir = new File(getChunkRootDir(), fileId);
if (!chunkDir.exists()) {
writeJson(resp, "{\"code\":1,\"message\":\"chunk dir not found\"}");
return;
}
// 按文件名排序,0.part, 1.part, 2.part...
File[] chunkFiles = chunkDir.listFiles((dir, name) -> name.endsWith(".part"));
if (chunkFiles == null || chunkFiles.length == 0) {
writeJson(resp, "{\"code\":1,\"message\":\"no chunks found\"}");
return;
}
Arrays.sort(chunkFiles, new Comparator<File>() {
@Override
public int compare(File f1, File f2) {
int i1 = Integer.parseInt(f1.getName().replace(".part", ""));
int i2 = Integer.parseInt(f2.getName().replace(".part", ""));
return Integer.compare(i1, i2);
}
});
// 目标文件,按相对路径组织到目标目录
File targetFile = new File(getTargetRootDir(), relativePath);
File parentDir = targetFile.getParentFile();
if (parentDir != null && !parentDir.exists()) {
parentDir.mkdirs();
}
try (FileOutputStream fileOut = new FileOutputStream(targetFile)) {
byte[] buffer = new byte[8192];
for (File chunkFile : chunkFiles) {
try (FileInputStream chunkIn = new FileInputStream(chunkFile)) {
int len;
while ((len = chunkIn.read(buffer)) != -1) {
fileOut.write(buffer, 0, len);
}
}
}
}
// 合并成功后删除临时分片目录
deleteDirectory(chunkDir);
writeJson(resp, "{\"code\":0,\"message\":\"merge success\"}");
}
}
合并的时候有几个细节要注意。
排序一定不能用默认的字符串排序,否则 10.part 会排在 2.part 前面。用数字解析后排序是稳妥的。
合并时的文件名,最好用前端传来的原始文件名,但要处理路径穿越问题。如果 relativePath 里包含了 ../,后端必须过滤掉,否则用户上传一个精心构造的文件名,可能把文件写到服务器的任意目录,这是严重的安全漏洞。简单处理方式就是过滤掉所有 .. 和以 / 开头的路径,或者直接把它当成一个纯文件名来存储。
合并后的校验也很重要。合并完成后,对比一下最终文件大小是否等于分片大小的总和,不一致就说明有分片缺失或重复。建议在合并代码里加上这个校验逻辑。
4.5 秒传的实现:MD5指纹查询
秒传功能的接口实现不复杂,与断点续传共用文件存储结构。前端计算完文件MD5后,调用查询接口:
code复制GET /upload/check?md5=xxx&size=xxx
返回:
{ "exists": true, "filePath": "/2024/05/xxx.pdf" }
后端维护一个文件指纹表,结构大致是:
| 字段 | 说明 |
|---|---|
| md5 | 文件MD5值 |
| size | 文件大小 |
| file_path | 服务端存储路径 |
| create_time | 首次上传时间 |
| ref_count | 引用计数,用于删文件时判断 |
实际项目中,我习惯把秒传做成“文件已经在服务器上就跳过上传,返回一个文件访问路径”,避免同一个文件被存储多份。
5. 文件夹结构保留与前端体验优化
5.1 目录结构还原的两种方案
文件夹上传的目录结构还原,有两种常见实现方式。
第一种:前端在每次上传分片时,把相对路径(如 src/main/java/Test.java)作为参数传给后端,后端在合并时按相对路径创建目录结构。这种方式实现简单,但有一个问题:如果同一次会话中上传的多个文件有相同的相对路径前缀,后端要保证父目录只创建一次,不能重复创建,否则可能产生冲突。
第二种:后端在上传完成后,生成一个清单文件(manifest),里面记录了所有文件的相对路径和大小。这种方式适合“打包下载”或“云端目录”类的需求,展示给用户时按清单来还原目录结构。
我推荐第一种为主,第二种做辅助。第一种让文件在服务器上就是真实目录结构,方便后续直接用文件系统访问;第二种用于展示进度和校验完整性。
5.2 进度显示:分片级进度和文件夹级进度
文件夹上传的进度条和单文件上传的进度条不一样。单文件是“整个文件传了百分之几”,文件夹要拆成两个维度:
- 文件维度:当前传到哪个文件了,这个文件里传了多少
- 文件夹维度:整个文件夹已完成的文件数/总文件数
前端维护一个全局任务列表,每个文件有自己的状态:待上传、上传中、已完成、失败。文件夹总进度 = 所有文件的大小之和 / 已成功传出的大小之和。
计算方式:
javascript复制function computeFolderProgress(fileList) {
const total = fileList.reduce((sum, f) => sum + f.size, 0);
const uploaded = fileList.reduce((sum, f) => sum + (f.uploadedSize || 0), 0);
return Math.round(uploaded / total * 100);
}
这里的 uploadedSize 需要在前端每个分片成功后累加。注意,从服务端拿到的已上传分片信息也要累加进去,否则刷新页面后进度条会从0开始,显得很怪。
5.3 前端断点恢复的完整流程
断点恢复的完整流程,我自己在项目里跑通的一个成熟流程如下:
- 页面加载时,检查localStorage里是否有待恢复的上传任务记录
- 有的话,读取任务记录,拿到之前上传的文件列表和fileId
- 调用后端
/upload/status接口,查询每个文件已上传的分片列表 - 前端把已存在的分片跳过,只传缺失的部分
- 全部上传完成后,调用
/upload/merge合并所有文件
这里有一个很容易踩的坑:localStorage的记录里,File对象不能直接保存,保存的是文件元信息(名字、大小、路径、修改时间)。而用户刷新后,上一次选择的File对象已经丢了,要让用户重新选择同一个文件夹。重新选择后,用 ${fileId} 匹配判断是否是同一个文件。
判断代码长这样:
javascript复制function isSameFile(savedFileInfo, newFile) {
return savedFileInfo.fileId === buildFileIdentity(newFile, newFile.webkitRelativePath).fileId;
}
如果匹配不上,说明用户换了个文件夹或文件被改动过,这种情况建议重置上传状态,避免出现数据错乱。
6. 常见问题与排查技巧实录
6.1 分片上传完但合并失败
现象:前端显示所有分片上传成功,但触发合并接口后报错“chunk dir not found”或“no chunks found”。
排查思路:分片文件是否真的落盘了?临时目录会不会被系统清理了?我遇到过的真实场景是:临时目录用的是系统tmp目录,而服务器上配置了定时清理tmp目录的cron任务,上传时间超过清理阈值后分片被删了。解决办法是把上传临时目录配置到专门的目录,并设置合理的清理策略。
还有一次是分片上传接口返回了成功,但服务器磁盘满了,文件实际没写入成功。排查时发现接口的 IOException 被吞掉了,前端永远看到成功。解决办法是上传接口里捕获所有异常并返回非0的code,前端对非成功code要明确报错。
6.2 合并后的文件损坏
现象:合并成功,文件大小也对,但打开文件提示格式错误或中途数据缺失。
排查思路:
- 分片顺序是否搞错了?这是最常见的原因。排序时用了字符串排序而不是数字排序。
- 分片内容是否被截断?检查每个part文件的大小是否符合预期。
- 是不是中途有分片重复上传?有可能同一个分片文件被写了两次,但第二次写入时覆盖了第一次的内容。一般不会导致文件大小不对,但内容可能被破坏了。
验证方法:合并完成后,对文件做一次MD5计算,和前端计算的MD5比对。不一致就说明合并过程出了问题。
6.3 中文文件名乱码
现象:上传的文件名包含中文,保存后变成乱码,或者合并时文件不存在。
排查思路:这属于老生常谈的编码问题。前端用FormData提交时,文件名参数是通过HTTP参数传递的。后端取参数时如果没有设置 req.setCharacterEncoding("UTF-8"),很容易出现乱码。建议在Servlet的doPost开头显式设置编码,同时确认Tomcat的URIEncoding配置为UTF-8。
java复制req.setCharacterEncoding("UTF-8");
resp.setCharacterEncoding("UTF-8");
resp.setContentType("application/json;charset=UTF-8");
另外,Part的 getSubmittedFileName() 方法在Tomcat 8.5以下版本对中文支持有坑,建议直接用前端传的文件名参数,而不是从Part中获取。
6.4 并发上传时服务端内存或者文件句柄耗尽
现象:用户同时上传大量文件,服务器报OutOfMemoryError或者Too many open files。
排查思路:大多数情况下是后端代码没做好资源管理。比如读取Part的InputStream时没有正确关闭,或者合并时每个分片都用FileInputStream打开却没关闭。建议写上传代码时,所有流的关闭都放到try-with-resources里,不要依赖手动finally。
另外,前端并发数控制也很关键。如果前端一次把所有分片都发出去,即使是4MB的分片,几百个并发请求也会把服务器打崩。我的经验是前端并发数控制在3-5比较合适,既不会太慢,也不会压垮服务器。
6.5 跨域问题导致上传失败
现象:前端页面在A域名,后端接口在B域名,浏览器报跨域错误。
排查思路:JSP项目如果前后端分离或者部署在不同域名下,就需要处理CORS。Servlet可以通过在响应头里加 Access-Control-Allow-Origin 来处理,也可以使用Filter统一加。
java复制resp.setHeader("Access-Control-Allow-Origin", "*");
resp.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
resp.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
要注意的是,XHR上传分片时请求头里带了自定义内容类型,浏览器会发OPTIONS预检请求。Servlet的doOptions方法要正确处理,否则预检失败,分片根本发不出去。
7. 技术演进:Spring Boot和MinIO下的断点续传思路
7.1 Spring Boot项目怎么做
如果你的项目基础不是传统的Servlet而是Spring Boot,实现思路完全一致,只是把Servlet替换成Controller。
java复制@RestController
@RequestMapping("/upload")
public class ChunkUploadController {
@PostMapping("/chunk")
public Result uploadChunk(@RequestParam("fileId") String fileId,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam(value = "file", required = false) MultipartFile file) {
// 逻辑同上
}
@GetMapping("/status")
public Result getStatus(@RequestParam("fileId") String fileId) {
// 查询已上传分片列表
}
@PostMapping("/merge")
public Result merge(@RequestParam("fileId") String fileId) {
// 合并分片
}
}
Spring Boot的好处是内置了文件大小配置和异常处理机制,写起来比原生Servlet清爽。在 application.yml 里配置 spring.servlet.multipart.max-file-size 和 max-request-size,保证能接收整个分片。
7.2 MinIO是否支持断点续传
热词里有人问“minio支持断点续传吗”,这里统一说明一下。MinIO作为对象存储,它支持的是Multipart Upload(分片上传),也就是你先把一个对象切成多个Part分别上传,最后用CompleteMultipartUpload指令把它们组合成一个对象。这和我们在JSP网页里讲的“分片断点续传”本质上是一套东西,只不过MinIO把分片的状态管理放到对象存储服务端了,不需要我们自己在磁盘上维护part文件。
如果你的项目用了MinIO,断点续传就可以简化成:前端分片,调用MinIO的InitiateMultipartUpload拿到uploadId,后续分片带上uploadId上传,任何一个分片传失败都可以单独重传该分片。MinIO的Java SDK对这类操作封装得已经很完善了,比自己在磁盘上管理分片要省心很多。
但要注意一点:MinIO只管对象的存储,不管“文件夹结构”和“前端断点恢复”的完整逻辑。用户选择了文件夹,前端仍然要自己遍历文件、给每个文件生成fileId、记录每个文件的uploadId和已上传分片列表。MinIO解决的是服务端分片存储的复杂性,前端断点续传的状态管理并没有帮你省掉。
7.3 未来方向:客户端直传和断点续传的云端化
再聊一个延伸话题。JSP网页做断点续传,最直接的方案就是客户端传到你的后端,再向后端存储落地。但当用户基数变大、上传文件变大,这种方案会逐渐暴露出两个问题:后端带宽成了瓶颈,后端磁盘成了瓶颈。
所以不少团队在断点续传的基础之上,演进出了一种“客户端直传对象存储”的方案:后端提供一个临时上传凭证(比如MinIO的PresignedUrl),前端把分片直接传到对象存储。服务端只用维护文件和分片的状态,不用过一遍流量。
这种方案的断点续传状态记录,也从“服务端磁盘上有哪些part文件”变成了“对象存储里有哪几个Part”。前端断点恢复时,还是调用后端的查询接口,只是分片暂存地变成了对象存储。
我个人觉得,中小型JSP项目完全可以先把本地磁盘方案做稳定,不要一开始就上对象存储直传,那会引入桶策略、签名、CORS、分片生命周期管理一堆概念,复杂度会明显超标。技术在变,但断点续传的核心问题没变:前端搞清楚“缺什么传什么”,后端搞清楚“有什么缺什么”。这个思路不变,无论底层存储怎么换,代码改动都在可控范围。
8. 实操心得与最后的经验传递
这套方案我在实际项目里落地了好几次,踩过的坑零零散散,挑几个最值得说的。
分片大小不要盲目跟风。网上很多人说5MB、10MB,但你需要根据实际网络环境测试。内网环境下50MB分片都没问题,公网弱网环境下1MB分片都不一定稳。建议做成可配置项,部署后根据日志和用户体验调优。
前端断点恢复的时间点,建议放在用户点“上传”按钮时而不是页面加载时。用户可能只是打开了页面没打算继续上次传输,自动恢复会造成不必要的服务器请求。
后端临时分区和正式存储区要做到权限隔离。临时分片目录设置成不可直接访问,只允许内部程序读取,防止用户猜到分片URL后直接下载别人的半成品文件。
合并一定要做完整性校验。哪怕只是简单地比对文件大小,也能拦截90%的损坏事故。想更严谨就再加上MD5比对,但这个会额外消耗服务器CPU,大文件夹场景下要考虑性能。
最后说一个很实际的体会:文件夹断点续传这个需求,难点不在任何单个技术点,而在几个模块的串联。前端要管文件解析、分片、并发、状态记录,后端要管分片接收、临时文件管理、合并、状态查询。任何一环出了问题,用户感知到的就是“传不上去了”。所以写代码的时候,一定要把日志打全。分片上传成功、合并成功、某个分片跳过,都要有清晰的日志输出,不然后期排查会让你怀疑人生。
我在实际项目里还会做一件事:把上传任务的关键节点生成一个任务ID,前端每次请求都带上任务ID,后端通过任务ID把日志串起来。这样用户报问题时,只要把任务ID给我,我就能在日志里看到这个任务从创建到合并的完整过程,定位问题效率能提高好几倍。这个习惯建议你也能养成。
