搞过大文件上传的兄弟都知道,真正的痛点从来不是“能不能传”,而是“传一半断了怎么办”“传了几个小时突然超时怎么办”“服务器内存被撑爆了怎么办”。我在实际项目里被这些问题折磨过好几轮,后来彻底换成JAVA后端配合JSP页面做分块上传加断点续传,才算把这块硬骨头啃下来。这篇文章就把我用过的方案、踩过的坑、以及可以直接抄作业的代码完整拆开讲透,适合正在做大文件上传功能、被超时和内存问题卡住的Java Web开发者参考。
1. 为什么大文件上传总让人头疼
先聊清楚一个问题:为什么大文件上传不能像普通小文件那样,一个表单直接提交完事?因为HTTP协议本身是无状态的,服务端在接到完整请求之前,需要把整个请求体都“吃”进去。对于几百MB甚至几个GB的文件,这条路根本走不通。
1.1 传统上传的三个致命伤
第一个是超时。无论Tomcat还是Nginx,中间任何一层都有超时时间。一个1GB的文件,按家庭宽带5MB/s的速度也要200秒,这还没算网络抖动。一旦超过服务器默认的响应超时时间,连接就被掐断,整个上传前功尽弃。我在某项目中就遇到过,用户传到90%突然断掉,只能从头再来,气得直接投诉。
第二个是内存。传统的MultipartFile方式虽然能把文件写盘,但在Servlet 3.0之前的实现里,文件内容会先进内存缓冲区。文件一大,直接导致OutOfMemoryError。即使Servlet 3.1支持了Part接口,大文件依然会让I/O压力集中在一个时刻爆发。
第三个是失败成本。传输层一旦出错、网络闪断、浏览器崩溃,整个文件都报废了。没有断点续传,就没有任何容错空间。
1.2 分块上传的核心思路
分块上传的本质很简单:把一个大文件切开成若干小块,每个小块独立上传,全部传完后在服务端合并成完整文件。
举个例子,一个100MB的文件,切成10块,每块10MB。每块都是一次普通的HTTP请求,服务端各自保存到临时目录。这样做的好处立竿见影:
- 单次请求体变小,超时风险大幅降低;
- 每块上传完成即落盘,内存占用稳定;
- 哪块上传失败就重传哪块,不需要重传整个文件;
- 可以并行上传多个分块,充分利用带宽。
这在思路上就像搬一块巨型石板,你非要一个人扛着走800米,大概率半路摔跤。切成几块,几个人轮流搬,摔了一块也就损失一小块。
1.3 断点续传解决的痛点
断点续传是分块上传的进阶版。核心逻辑是:每一块上传成功后,服务端记录下来。下次用户再传同一个文件时,先发一个请求问服务端“哪些块我已经传过了”,前端拿到清单后只上传缺失的块。
比如文件被切成了50块,上次传到第37块时断了。断点续传之后,前端一查,得知前37块都在,那就直接从第38块继续,根本不用再碰前面的数据。
这就是“断点”二字的含义——不是重新传,而是在上一次中断的位置接着传。实现这个能力,前端和后端各承担一半工作:前端负责“查缺”和“续传”,后端负责“记录”和“校验”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分块上传的整体设计与关键参数
方案听起来简单,落地时有一堆细节必须提前定下来,否则后面全是麻烦。
2.1 分块大小怎么定
分块大小是第一个要拍板的参数,直接影响上传速度和服务压力。我给一个实际可用的经验范围:10MB到20MB之间比较合适,网络环境差就取小值,服务端吞吐强就取大值。
分块太小,比如1MB,分块数量会爆炸。一个1GB的文件要切1024块,发起上千次HTTP请求,请求本身的握手开销就把收益吞掉了。分块太大,比如100MB,又回到大请求体超时的问题上。
我通常会这样算:目标单块上传时长控制在10秒至30秒之间。用户带宽是10MB/s,选10MB到20MB的分块正好落在10到20秒区间。如果目标用户是弱网环境,比如移动网络,分块降到2MB到5MB更稳妥。
前端代码里用Javascript的标准方式切分:
javascript复制const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
let start = 0;
let end = Math.min(file.size, start + CHUNK_SIZE);
const blob = file.slice(start, end);
File.slice()不过是给你一个指向文件中某段数据的Blob对象,并没有真正拷贝数据,开销很小。
2.2 文件唯一标识的生成
断点续传必须知道“当前这个文件”和“之前那个文件”是不是同一个。只靠文件名不行,因为同名文件可能内容不同。我见过有人直接用文件名做标识,结果用户传了个同名但内容不同的文件,服务端错误地认为所有分块都已经传过,直接跳过,最后合并出一个损坏文件。
靠谱的做法是用“文件名+文件大小+最后修改时间”拼接后做MD5。这样不需要消耗大量CPU去计算整个文件的MD5,又能基本保证文件身份的唯一性。如果文件校验要求极其严格,可以再额外计算首块和尾块的MD5做二次校验。
我在实际项目里用的是这样一个字符串拼接规则:
javascript复制const fileId = md5(file.name + file.size + file.lastModified);
md5这里可以引入现成的JavaScript库,比如SparkMD5。注意这里计算的不是整个文件的MD5,而是元信息字符串的MD5,性能开销完全可以忽略。
2.3 后端需要接收哪些参数
无论用Servlet还是Spring MVC,后端接收分块的接口必须拿到下面这几个关键参数:
| 参数 | 作用 |
|---|---|
| fileId | 文件的唯一标识,用于区分不同文件 |
| chunkIndex | 当前分块的索引,从0开始 |
| totalChunks | 总块数,用于校验和合并 |
| chunkSize | 每块大小,用于计算写入偏移量 |
| fileSize | 文件总大小,用于最终校验 |
| fileName | 原始文件名,用于合并后的命名 |
后端拿不到这些信息,就不知道当前的块属于哪个文件、应该放在哪个位置、什么时候应该合并。
这里有个容易忽略的坑:前端的chunkIndex是从0开始还是从1开始,全公司要统一。我在项目中因为前后端索引定义不一致,导致合并出来的文件多了一段或少了一段,排查了大半天才发现是0和1的错位。
2.4 临时目录与文件命名规范
服务端接收分块后,不能直接覆盖写到最终文件上,必须放到一个临时目录里。我建议的目录结构是:
text复制/tmp/upload/
{fileId}/
0.chunk
1.chunk
2.chunk
...
也就是先按fileId建一个文件夹,里面按块索引命名保存。这样做的好处是:
- 不同文件互不干扰;
- 分块管理清晰,一眼看出哪些块已传;
- 清理任务可以按文件夹粒度删除。
需要注意的是,这个临时目录绝不能放在应用的classpath里,也不能放在会被打包进WAR包的位置。我习惯放在操作系统级的临时目录,或者通过配置文件显式指定一个独立磁盘路径。
3. 后端实现:Servlet接收分块与合并
后端我用过两种写法,一种是纯Servlet,一种是Spring MVC的Controller。原理完全一致,我先把纯Servlet的写法讲清楚,因为这样最不依赖框架版本,讲完原理你移植到Spring里也非常容易。
3.1 分块接收的核心代码
假设项目用的是Servlet 3.0以上版本,可以使用@MultipartConfig注解,配合Part接口获取上传的分块数据。核心逻辑就是:把当前块的数据写到临时目录对应的文件中。
java复制@WebServlet("/upload/chunk")
@MultipartConfig(maxFileSize = 20 * 1024 * 1024, maxRequestSize = 20 * 1024 * 1024)
public class ChunkUploadServlet extends HttpServlet {
private static final String UPLOAD_BASE_DIR = "/data/upload-temp";
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String fileId = req.getParameter("fileId");
int chunkIndex = Integer.parseInt(req.getParameter("chunkIndex"));
int totalChunks = Integer.parseInt(req.getParameter("totalChunks"));
String fileName = req.getParameter("fileName");
Part part = req.getPart("file");
File dir = new File(UPLOAD_BASE_DIR, fileId);
if (!dir.exists()) {
dir.mkdirs();
}
File chunkFile = new File(dir, chunkIndex + ".chunk");
try (InputStream in = part.getInputStream()) {
Files.copy(in, chunkFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
// 检查是否所有分块都上传完毕
if (isAllChunksUploaded(dir, totalChunks)) {
mergeChunks(dir, fileName, totalChunks);
}
resp.setStatus(HttpServletResponse.SC_OK);
resp.getWriter().write("{\"success\": true}");
}
private boolean isAllChunksUploaded(File dir, int totalChunks) {
for (int i = 0; i < totalChunks; i++) {
if (!new File(dir, i + ".chunk").exists()) {
return false;
}
}
return true;
}
}
这段代码里最核心的是一行Files.copy,它把输入流直接写到了目标分块文件。我用REPLACE_EXISTING做覆盖,这样同一个块重复上传时不会因为文件已存在而报错,断点续传时重复提交一个块也能正常返回成功。
3.2 合并分块的实现细节
合并动作不宜在上传接口里同步做太久,但如果是单机内部系统,分块数量不大,直接在检测到全部块齐全后同步合并也够用。我给出一个稳健的合并方法:
java复制private void mergeChunks(File dir, String fileName, int totalChunks) throws IOException {
File targetFile = new File(UPLOAD_BASE_DIR + "/complete", fileName);
try (FileOutputStream fos = new FileOutputStream(targetFile)) {
for (int i = 0; i < totalChunks; i++) {
File chunkFile = new File(dir, i + ".chunk");
if (!chunkFile.exists()) {
throw new IOException("Missing chunk: " + i);
}
Files.copy(chunkFile.toPath(), fos);
}
}
// 合并完成后清理临时分块目录
deleteDir(dir);
}
这里有个关键点:合并时必须严格按照chunkIndex的顺序遍历,不能依赖上传顺序。因为前端可能并发上传多个分块,服务端收到分块的顺序完全不确定。用索引顺序合并是最稳妥的。
另一个细节是最终文件的存储路径要和应用目录分离,我习惯放到一个独立的/data/upload-complete目录下,方便配置磁盘和备份策略。
3.3 并发合并冲突的处理
如果前端用并发上传,可能出现一种情况:第10块正在写,第9块也正在写,两个线程同时都检查到“所有分块都齐了”,然后同时触发合并,第二个合并线程会读到不完整的文件或者抛文件占用异常。
我的解决办法是加一个原子布尔位或者锁文件:
java复制private final ConcurrentHashMap<String, Object> lockMap = new ConcurrentHashMap<>();
// 在合并前获取锁
Object lock = lockMap.computeIfAbsent(fileId, k -> new Object());
synchronized (lock) {
if (isAllChunksUploaded(dir, totalChunks)) {
mergeChunks(dir, fileName, totalChunks);
}
}
lockMap.remove(fileId);
这样做保证同一文件的合并只发生一次。注意锁对象从ConcurrentHashMap里取,合并完要清理掉,否则文件多了锁对象会越来越多。
4. 断点续传:前端如何查询已上传分块
后端保存分块的同时,必须提供一个查询接口,让前端知道哪些分块已经存在。否则前端不知道从哪里继续传。
4.1 检查接口的设计
检查接口的路径我习惯设计成/upload/check,前端带上fileId和totalChunks发起请求。后端遍历临时目录下的分块文件,返回一个布尔数组或者已存在的块索引列表。
java复制@WebServlet("/upload/check")
public class CheckServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String fileId = req.getParameter("fileId");
File dir = new File(UPLOAD_BASE_DIR, fileId);
List<Integer> uploaded = new ArrayList<>();
if (dir.exists()) {
File[] files = dir.listFiles();
if (files != null) {
for (File f : files) {
String name = f.getName();
if (name.endsWith(".chunk")) {
uploaded.add(Integer.parseInt(name.replace(".chunk", "")));
}
}
}
}
resp.setContentType("application/json;charset=UTF-8");
Map<String, Object> result = new HashMap<>();
result.put("uploaded", uploaded);
result.put("fileId", fileId);
resp.getWriter().write(new ObjectMapper().writeValueAsString(result));
}
}
这里我返回的是“已上传的块索引列表”,而不是“缺失列表”。因为前端可能用Set记录,更加直观。
4.2 前端续传的逻辑
拿到已上传列表后,前端生成待上传块数组,过滤掉已经存在的块。核心代码如下:
javascript复制async function initUpload(file) {
const fileId = md5(file.name + file.size + file.lastModified);
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const res = await fetch(`/upload/check?fileId=${fileId}&totalChunks=${totalChunks}`);
const data = await res.json();
const uploadedSet = new Set(data.uploaded);
const tasks = [];
for (let i = 0; i < totalChunks; i++) {
if (uploadedSet.has(i)) {
continue; // 跳过已上传的块
}
const start = i * CHUNK_SIZE;
const end = Math.min(file.size, start + CHUNK_SIZE);
const blob = file.slice(start, end);
const formData = new FormData();
formData.append('file', blob, file.name);
formData.append('fileId', fileId);
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
// 保存任务,用于并发控制
tasks.push({ index: i, data: formData });
}
await uploadChunksWithRetry(tasks, uploadedSet);
}
这里最让我踩坑的是uploadedSet的类型判断。后端返回的是JSON数组,前端解析后是数组,但有人直接用if (uploadedSet.has(i)),忘了转成Set,结果每个块都认为不存在,全量重传了一遍。这种低级错误一定要在代码审查时盯住。
4.3 并发上传与失败重试
我不建议一次性把所有分块全部并发发出,几十个请求同时打到服务器,会造成带宽争抢和连接阻塞。更稳妥的做法是控制并发数,比如同时最多3到4个请求在飞。
用简单的并发池写法:
javascript复制async function uploadChunksWithRetry(tasks, uploadedSet) {
const MAX_CONCURRENT = 3;
let cursor = 0;
async function worker() {
while (true) {
const task = tasks[cursor++];
if (!task) break;
for (let retry = 0; retry < 3; retry++) {
try {
const resp = await fetch('/upload/chunk', {
method: 'POST',
body: task.data
});
if (resp.ok) {
uploadedSet.add(task.index);
break;
}
} catch (e) {
if (retry === 2) {
alert('分块上传失败,请检查网络');
}
}
}
}
}
const workers = new Array(MAX_CONCURRENT).fill(0).map(() => worker());
await Promise.all(workers);
}
这个写法把每块的重试次数限制在3次,减少了偶发网络抖动导致的失败。但要注意重试的时候FormData不能复用,因为浏览器可能已经把流消费掉了,所以每次重试前都要重新构建FormData。如果按照上面的结构在循环里构建task.data,重试时复用的是同一个对象,在部分浏览器里会出问题。更稳妥的做法是重试时重新构建一次。
5. 完整实操示例:一个可运行的分块断点续传Demo
前面讲原理和代码片段,这里把它们拼起来,给你一个可以直接跑到本地的完整Demo。我从项目里抽出来简化过一版,结构如下:
text复制src/
main/
java/
com/upload/
ChunkUploadServlet.java
CheckServlet.java
MergeServlet.java // 其实是复用ChunkUploadServlet里的合并逻辑
webapp/
index.jsp
5.1 前端JSP页面代码
JSP页面我主要用来输出HTML和JavaScript,上传逻辑本身全部走Ajax,不需要表单跳转。
jsp复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
<title>大文件分块上传</title>
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
</head>
<body>
<input type="file" id="fileInput" />
<button onclick="startUpload()">开始上传</button>
<div id="progress">0%</div>
<script>
const CHUNK_SIZE = 10 * 1024 * 1024;
function md5(str) {
return SparkMD5.hash(str);
}
async function startUpload() {
const file = document.getElementById('fileInput').files[0];
if (!file) return;
const fileId = md5(file.name + file.size + file.lastModified);
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadedSet = await fetchUploaded(fileId);
const tasks = buildTasks(file, fileId, totalChunks, uploadedSet);
await uploadWithConcurrency(tasks, uploadedSet, file);
}
async function fetchUploaded(fileId) {
const resp = await fetch(`/upload/check?fileId=${fileId}`);
const data = await resp.json();
return new Set(data.uploaded);
}
function buildTasks(file, fileId, totalChunks, uploadedSet) {
const tasks = [];
for (let i = 0; i < totalChunks; i++) {
if (uploadedSet.has(i)) continue;
const start = i * CHUNK_SIZE;
const end = Math.min(file.size, start + CHUNK_SIZE);
tasks.push({
index: i,
formData: buildFormData(file, fileId, i, totalChunks, start, end)
});
}
return tasks;
}
function buildFormData(file, fileId, chunkIndex, totalChunks, start, end) {
const blob = file.slice(start, end);
const formData = new FormData();
formData.append('file', blob, file.name);
formData.append('fileId', fileId);
formData.append('chunkIndex', chunkIndex);
formData.append('totalChunks', totalChunks);
formData.append('fileName', file.name);
return formData;
}
async function uploadWithConcurrency(tasks, uploadedSet) {
const MAX_CONCURRENT = 3;
let cursor = 0;
let completed = 0;
const total = tasks.length;
async function worker() {
while (true) {
const task = tasks[cursor++];
if (!task) break;
for (let retry = 0; retry < 3; retry++) {
try {
const resp = await fetch('/upload/chunk', {
method: 'POST',
body: task.formData
});
if (resp.ok) {
uploadedSet.add(task.index);
completed++;
updateProgress(completed, total);
break;
}
} catch (e) {
if (retry === 2) {
console.error('块上传失败', task.index);
}
}
}
}
}
const workers = new Array(MAX_CONCURRENT).fill(0).map(() => worker());
await Promise.all(workers);
if (completed === total) {
alert('全部上传完成');
}
}
function updateProgress(completed, total) {
document.getElementById('progress').innerText =
Math.round(completed / total * 100) + '%';
}
</script>
</body>
</html>
这里为了避免重复上传已经存在的块时进度显示不准确,我在进度统计中只用“本次实际需要的任务数”做分母,这样续传场景下的百分比也比较合理。
5.2 后端配置与启动要点
后端Servlet代码前文已经给出。这里提醒三个部署配置:
Tomcat的maxPostSize默认是2MB,不修改的话分块上传请求会被拒掉。我一般设置为-1,表示不限制:
xml复制<Connector port="8080" protocol="HTTP/1.1"
maxPostSize="-1"
maxSwallowSize="-1" />
如果前面还有Nginx做反向代理,必须同时调整Nginx的请求体大小限制。默认client_max_body_size只有1MB,分块上传虽然单块只有10MB,但如果忘了配置,还是会被Nginx挡掉。我一般在http或server块里设置:
nginx复制client_max_body_size 50m;
理论上这里设成20MB就够了,但我会留出一定余量,因为HTTP请求还有头部和form字段的额外开销。
5.3 合并触发与最终文件校验
合并动作是在所有分块到位后自动触发的。但模块之间可能潜在一个问题:如果最后一小块上传不是最后一个到达的,那么“分块全部存在”的检查就会在更早的某一块上传完成时触发。没关系,只要我在合并前做一次完整的块完整性检查,哪怕多线程并发检查到条件成立,也会被锁机制挡住,最终只有一次合并成功。
合并成功后再校验文件大小,如果和目标大小不一致,说明中间有块损坏或者索引错乱,直接删除临时目录,让前端全量重传更省事。
java复制if (targetFile.length() != fileSize) {
targetFile.delete();
deleteDir(dir);
resp.getWriter().write("{\"success\": false, \"error\": \"merge_failed\"}");
return;
}
这个校验非常有必要。我记得有一次用户的磁盘快满了,分块写文件时空间不足,IO写入实际是失败的,但Java的Files.copy没有立刻抛异常,导致某个分块文件是0字节。没有最终大小校验的话,合并出来的文件就是损坏的。
6. 常见问题与排查技巧实录
这个功能我用在不同项目里,踩过的坑五花八门。我把最高频的几类问题整理成清单,你遇到类似情况可以直接对照。
6.1 上传请求返回413或504
413一般是Nginx或Tomcat的请求体大小限制所致。排查步骤很简单:先绕过Nginx直接访问后端,如果正常,问题就在Nginx的client_max_body_size。如果依然异常,检查Tomcat的maxPostSize。
504超时则要看两个层面:Nginx的proxy_read_timeout默认60秒,上传10MB分块在带宽不差的情况下通常不会超过30秒,但如果转发的后端处理太慢,或者用户带宽很低,需要适当调大,比如300秒。
6.2 合并出来的文件打不开或体积不对
先判断是不是索引错乱。打开合并后的文件,如果中间有一段乱码或者缺失,多半是合并时块顺序不对。我前面强调过,合并一定要按chunkIndex遍历,不能按目录里文件修改时间的顺序。
还有一种隐蔽情况:前端切块时end计算溢出。start + CHUNK_SIZE可能超过file.size,所以必须用Math.min(file.size, start + CHUNK_SIZE),否则最后一个块会多出空字节。
6.3 断点续传没有生效,每次都全量重传
优先检查fileId是否稳定。file.lastModified在有些浏览器里是undefined,导致MD5结果每次都不同,续传自然永远不会命中。兼容写法是:
javascript复制const lastModified = file.lastModified || file.lastModifiedDate?.getTime() || 0;
另外,检查后端check接口返回的uploaded列表是否正确。用浏览器开发者工具看一眼返回的JSON,比反复全量重传后瞎猜高效得多。
6.4 临时文件不断堆积
分块上传是个多阶段操作,用户传了一半退出、刷新页面、关闭浏览器,临时分块就会残留。我建议做两层清理:
一层是合并成功后的即时删除,逻辑上合并完了临时目录就没用了。
另一层是定时的扫描清理。用一个定时任务扫描临时目录,删除超过24小时或48小时没有更新过的fileId目录。这个时间要根据业务场景调整,太长浪费磁盘,太短可能误删正在上传的会话。
6.5 跨域问题
如果前端页面和后端接口不在同一个域名下,分块上传会触发跨域请求。这里要注意fetch的请求里含有文件数据,会走预检请求,后端必须允许OPTIONS请求返回正确的CORS头,并且允许Content-Type: multipart/form-data。
我踩过的坑是只配置了Access-Control-Allow-Origin,忘了配置Access-Control-Allow-Headers,导致前端一直报跨域错误,折腾了半小时才发现是配置不全。
6.6 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 请求返回413 | 服务器请求体大小限制 | 调整Nginx client_max_body_size、Tomcat maxPostSize |
| 请求返回504 | 代理超时 | 调大 proxy_read_timeout |
| 合并后文件体积不对 | 最后一个块切分越界 | 使用 Math.min 截断 |
| 合并后文件乱码 | 块顺序错乱 | 按 chunkIndex 顺序合并 |
| 续传失效 | fileId 不稳定 | 兼容 lastModified 为空的情况 |
| 临时目录暴涨 | 中途退出未清理 | 合并后删除 + 定时清理 |
7. 最后再补充一点实际体会
如果你只是内部系统用,分块大小、并发数、重试次数这些参数不必做到极致的自适配,固定值完全够用。但如果你做的是面向外部用户的产品,一定要考虑弱网环境,分块尺寸要适当调小,并且一定要做失败重试和续传,否则用户流失会非常明显。
我自己在项目里最大的收获是:文件上传功能不能等到上线前才开始调优,因为涉及的前端分块逻辑、后端存储策略、代理配置、临时目录清理,全部是联调阶段才能暴露问题。最好从一开始就按“可断点、可续传、可重试、可校验”这套标准来做,后面才不会反复返工。踩过几次坑之后你会发现,这些代码写起来并不难,难的是把所有边界情况都提前想到。
