做Java Web开发的人,迟早会遇到一个老大难问题:页面要上传大文件怎么办?早年做某内部管理系统的附件功能时,业务方直接丢来一个要求——“我们要上传2GB的视频文件,你们系统能不能支持?”我用最普通的文件上传接口试了一下,几十MB的文件还凑合,到了几百MB,要么浏览器白屏,要么服务器直接报错,要么用户等半天最后告诉你“上传失败,请重试”。后来我把“JAVA中JSP如何实现网页大文件分块上传与断点续传”这套方案真正落地,问题才彻底解决。
这篇文章就是基于那套完整实现整理出来的。核心思路是:前端把大文件切成很多小块,逐块上传到后端,后端接收后做临时存储,全部传完再合并成一个完整文件。配合“查询哪些块已经传过”的接口,就能实现断点续传——用户网络中断后重新上传,已传过的分块直接跳过,不用从头再来。这个方案适合做Web管理系统、教育平台、协同办公工具,或者任何需要上传大文件的Java项目,尤其适合正在用JSP做后台页面、并搭配Servlet/SpringMVC处理请求的团队。
1. 为什么普通上传方案搞不定大文件
1.1 普通上传的三座大山
普通表单上传大文件,本质上是一次性把整个文件通过HTTP请求体送到服务端。这里面至少有三座大山:
第一座是请求体限制。Tomcat默认对POST表单有大小限制,maxPostSize默认只有2MB,虽然这只是处理application/x-www-form-urlencoded的限制,但很多服务器、网关层都有自己的上传大小上限。Spring的MultipartResolver如果没配置好,默认也有1MB或几MB的限制。用户第一次传300MB文件直接被拒,后台日志里根本看不出原因。
第二座是连接时长。上传一个2GB文件,就算网速再快也要几分钟。如果走Wi-Fi、4G/5G网络,网络抖动一多,连接随时断。HTTP连接一旦断开,已经传的那些字节就全部作废,用户只能重新开始。
第三座是用户体验。浏览器没有原生的上传进度条,开发时要么用XHR自己写,要么依赖一些上传组件。普通上传过程中,用户完全不知道传了多少、还要多久,等到超时才知道失败,体感非常差。
这三座大山叠加起来,普通上传方案在“超过200MB”这个场景基本就崩了。与其去不断调大服务器限制,不如换一个更稳定的思路。
1.2 分块上传的本质:把大问题拆成小问题
分块上传的核心逻辑并不复杂,可以用一个生活比喻来理解:一个人要把一整面书架搬上六楼,连搬带扛很容易半路体力不支。但如果把书架拆成一块块木板,分几次搬上去,最后再在六楼拼接成一个完整书架,每一趟的负担都小很多,掉一块板子也只需要单独补运那一块。
落到技术上就是:
- 前端读取文件,用File对象的slice方法把文件切成多个二进制分块。
- 每个分块作为一个独立请求上传到后端,分块大小可以自己定,比如10MB一块。
- 后端收到分块后,先保存在临时目录,并且记录“这个文件已经收到第几块”。
- 所有分块传完后,后端按顺序合并分块,生成完整的原始文件。
断点续传就是在分块上传基础上加一个“状态查询”能力:前端上传前先问后端“这个文件目前已经有哪些块了”,后端返回已收到的分块序号,前端跳过这些块,只上传缺失的块。这样用户充其量只需要重传中断前没传完的那一小部分,而不是整个大文件。
这套方案解决的不只是技术问题,还有用户心态问题。用户看到“重新上传时秒跳过几百个已有分块”,心里是踏实的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计与技术选型解析
2.1 JSP在这个方案中扮演什么角色
先纠正一个常见认知:JSP本身不擅长处理文件上传的IO操作,它是视图层技术,更适合去渲染页面。但在标题这个场景里,很多人手头就是老项目——JSP页面配上Servlet,没有前后端分离的工程结构。这种情况下,JSP完全可以承担上传页面的展示和交互逻辑。
我的做法是:JSP负责输出上传页面,包括文件选择控件、上传按钮、进度条、隐藏的fileId字段;页面里的JavaScript负责切片、发请求、处理进度;后端用一个UploadServlet或者SpringMVC的Controller接收分块和查询状态;合并也由后端完成。也就是说,JSP是入口,但不是核心逻辑所在地。
如果项目里有条件升级到前后端分离,前端用Vue或者原生JS,后端用SpringBoot,总体思路不变。核心的分块、断点续传、后端存储合并逻辑,才是技术核心。
2.2 技术选型:什么场景用什么工具
前端切片,首选HTML5的File API。File.slice()方法兼容性已经非常成熟,主流浏览器都支持,移动端也有不错的表现。上传部分可以用原生XMLHttpRequest,也可以用axios。老项目不想引太多依赖,原生XHR完全够用,还能直接用upload.onprogress拿到上传进度。
后端接收分块,用Servlet的Part接口,或者用Spring的MultipartFile,本质都是multipart/form-data解析。分块保存时直接用FileOutputStream写文件,不需要把分块读进内存再操作,这样对大文件友好得多。
分块状态存储,单机部署可以用ConcurrentHashMap配合本地文件记录,集群部署必须用Redis。这里有一个很典型的取舍:本地文件方案最简单,部署一台机器时很稳;一旦上多台服务器,用户第一次请求落在A机器,第二次落在B机器,B机器查不到状态,续传就失效了。所以做生产系统,分块状态放Redis是正路。
2.3 接口设计:一个完整上传流程需要哪些能力
分块上传至少需要三个核心接口,再加上一个可选接口:
上传分块接口:接收文件本体信息(fileId、fileName、fileSize、chunkSize、totalChunks)和当前分块(chunkIndex、chunkData)。这个接口的参数建议用multipart/form-data传递,简单直观。
查询已上传分块接口:前端传fileId,后端返回一个已上传分块序号列表。返回格式可以是JSON数组,例如[0,1,2,3,5,6,7],前端看到数字4不在里面,就知道只需要传第4块。
合并触发接口:前端把所有分块传完后,调这个接口触发合并。后端检查分块完整性,按顺序合并成最终文件,然后返回最终文件访问路径。
可选接口是取消上传,用于清理临时分块和状态记录,防止磁盘垃圾堆积。
fileId的生成很关键。我建议:用户选择文件后,前端先在本地生成一个唯一ID,如果是从某个业务表单进入的上传页面,还可以把fileId和业务主键绑定。在整个上传周期内,fileId保持不变。下次续传时,前端仍然用同一个fileId去查询、上传。如果每次刷新页面都生成新fileId,断点续传等于没做。
3. 核心细节解析与实操代码落地
3.1 前端切片与断点续传的核心实现
前端是整个分块上传的发起者。实现上不复杂,但要注意并发控制和重试逻辑。下面是一个原生JavaScript的实现示例,不依赖任何框架:
javascript复制const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_CONCURRENT = 3; // 同时最多上传3个分块
let file = null;
let fileId = '';
let chunks = [];
// 1. 用户选择文件后,初始化分块信息
function initUpload(selectedFile) {
file = selectedFile;
fileId = generateUUID(); // 或者从后端获取,绑定业务ID
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
chunks = [];
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);
chunks.push({ index: i, data: blob, uploaded: false });
}
}
// 2. 查询已上传分块,跳过它们
async function queryUploadedChunks() {
const resp = await fetch(`/upload/query?fileId=${fileId}`);
const uploadedIndexes = await resp.json(); // 例如 [0,1,2,5,6]
const uploadedSet = new Set(uploadedIndexes);
chunks.forEach((chunk, index) => {
if (uploadedSet.has(index)) {
chunk.uploaded = true;
}
});
}
// 3. 上传单个分块,带重试
async function uploadChunk(chunk) {
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('chunkIndex', chunk.index);
formData.append('totalChunks', chunks.length);
formData.append('fileSize', file.size);
formData.append('fileName', file.name);
formData.append('chunkData', chunk.data, `chunk-${chunk.index}`);
const resp = await fetch('/upload/chunk', {
method: 'POST',
body: formData
});
if (!resp.ok) {
throw new Error(`Chunk ${chunk.index} upload failed`);
}
chunk.uploaded = true;
}
// 4. 控制并发,依次拉取未上传分块
async function startUpload() {
let cursor = 0;
async function worker() {
while (cursor < chunks.length) {
const chunk = chunks[cursor];
cursor++;
if (chunk.uploaded) continue;
for (let attempt = 0; attempt < 3; attempt++) {
try {
await uploadChunk(chunk);
break;
} catch (e) {
console.error(e);
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
}
}
}
}
const workers = [];
for (let i = 0; i < MAX_CONCURRENT; i++) {
workers.push(worker());
}
await Promise.all(workers);
// 全部完成后触发合并
await fetch(`/upload/merge?fileId=${fileId}`, { method: 'POST' });
}
// 5. 进度展示(在uploadChunk中使用XHR时更直观)
// 这里用fetch实现简单,真实项目中可用fetch无法获取上传进度,
// 需要用XMLHttpRequest的upload.onprogress。
上面这段代码隐含了一个问题:fetch拿不到上传进度。要展示进度条,就要用XMLHttpRequest,它的upload对象有onprogress事件,可以拿到loaded和total。很多博主写示例都直接fetch完事,但真实项目里进度条几乎都是刚需。我的建议是封装一个XHR上传函数:
javascript复制function uploadChunkWithProgress(chunk, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('chunkIndex', chunk.index);
formData.append('chunkData', chunk.data);
xhr.open('POST', '/upload/chunk');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
onProgress(chunk.index, percent);
}
};
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error('network error'));
xhr.send(formData);
});
}
chunk.uploaded这个标记也很关键。一旦某个分块成功,就把它标记为true,并发worker会自动跳过,避免了重复上传已经完成的分块。
3.2 后端Servlet接收分块与状态记录
后端接收分块,我推荐直接用Java Servlet的Part机制,因为Servlet 3.0之后就原生支持multipart/form-data解析,不需要额外依赖。代码如下:
java复制@WebServlet("/upload/chunk")
@MultipartConfig(maxFileSize = 20 * 1024 * 1024L, maxRequestSize = 21 * 1024 * 1024L)
public class ChunkUploadServlet extends HttpServlet {
private static final Map<String, UploadState> STATE_MAP = new ConcurrentHashMap<>();
private static final String UPLOAD_DIR = "/data/upload_temp/";
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String fileId = req.getParameter("fileId");
String chunkIndexStr = req.getParameter("chunkIndex");
String totalChunksStr = req.getParameter("totalChunks");
String fileName = req.getParameter("fileName");
String fileSizeStr = req.getParameter("fileSize");
Part chunkPart = req.getPart("chunkData");
int chunkIndex = Integer.parseInt(chunkIndexStr);
int totalChunks = Integer.parseInt(totalChunksStr);
long fileSize = Long.parseLong(fileSizeStr);
// 1. 初始化或获取状态
UploadState state = STATE_MAP.computeIfAbsent(fileId,
k -> new UploadState(fileId, fileName, fileSize, totalChunks));
state.setFileName(fileName);
state.setTotalChunks(totalChunks);
state.setFileSize(fileSize);
// 2. 保存分块文件
File chunkFile = new File(UPLOAD_DIR, fileId + "_" + chunkIndex + ".part");
try (InputStream in = chunkPart.getInputStream();
FileOutputStream out = new FileOutputStream(chunkFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
// 3. 更新已上传分块记录
state.addUploadedChunk(chunkIndex);
resp.setContentType("application/json");
resp.getWriter().write("{\"code\":0,\"msg\":\"ok\",\"index\":" + chunkIndex + "}");
}
}
这段代码有几个细节值得注意:
UPLOAD_DIR是分块临时目录,生产环境应该配置成独立磁盘分区,最好是SSD,可以提升合并时的读取速度。分块文件名使用fileId加下划线加分块序号,这种命名让目录按文件名排序时天然有序,合并时直接按文件名顺序读取即可,不需要额外维护索引。
UploadState使用ConcurrentHashMap保存,保证并发线程安全。分块上传会有多个并发请求同时打到后端,如果状态用HashMap,可能出现部分分块记录丢失的情况。实际排查中我就遇见过类似问题,前端明明传了两三个块,后端却只记录到一块,后来才发现是HashMap的可见性问题。换用ConcurrentHashMap之后,问题消失。
@MultipartConfig maxFileSize要略大于单个分块大小。前面设了10MB分块,后端maxFileSize可以设置为20MB,留出一定的余量,防止网络库或者浏览器端因为某些原因多传几个字节导致整体被拒。
3.3 合并文件与校验逻辑
当前端调用合并接口时,后端要做四件事:检查状态、合并分块、校验大小、清理临时文件。
java复制@WebServlet("/upload/merge")
public class ChunkMergeServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String fileId = req.getParameter("fileId");
UploadState state = ChunkUploadServlet.STATE_MAP.get(fileId);
if (state == null) {
writeError(resp, "文件状态不存在,无法合并");
return;
}
// 1. 检查分块是否齐全
List<Integer> sortedIndexes = state.getUploadedChunks().stream().sorted().collect(Collectors.toList());
if (sortedIndexes.size() != state.getTotalChunks()) {
writeError(resp, "分块不完整,当前已有 " + sortedIndexes.size() + "/" + state.getTotalChunks());
return;
}
String tempDir = "/data/upload_temp/";
File finalFile = new File("/data/upload_final/", state.getFileName());
try (FileOutputStream fos = new FileOutputStream(finalFile)) {
for (int i = 0; i < state.getTotalChunks(); i++) {
File chunkFile = new File(tempDir, fileId + "_" + i + ".part");
if (!chunkFile.exists()) {
writeError(resp, "缺少分块文件: " + chunkFile.getName());
return;
}
try (FileInputStream fis = new FileInputStream(chunkFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = fis.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
}
}
}
// 2. 校验最终文件大小
if (finalFile.length() != state.getFileSize()) {
writeError(resp, "文件大小校验不通过");
finalFile.delete();
return;
}
// 3. 清理分块临时文件与状态
for (int i = 0; i < state.getTotalChunks(); i++) {
new File(tempDir, fileId + "_" + i + ".part").delete();
}
ChunkUploadServlet.STATE_MAP.remove(fileId);
writeSuccess(resp, finalFile.getAbsolutePath());
}
}
合并是IO密集操作,最容易出现的问题是“边合并边删除分块”。一定要在合并完成、大小校验通过后再清理分块,否则合并失败时,分块也没了,用户只能从头传,体验会很差。
关于MD5校验,如果系统对文件完整性要求高,可以在前端计算整个文件的MD5,后端合并后再整体算一次,比对一致才算成功。但这里有个大坑:对1GB以上的文件,前端计算MD5需要加载整个文件,可能耗掉几百MB内存,手机端尤其容易卡死。很多项目的实际做法是:用分块内容级的校验兜底,同时靠文件大小校验,不再单独算全文件MD5。除非业务场景对数据完整性要求极高,否则不建议对大文件做前端全量MD5。
3.4 JSP页面如何组织上传界面
JSP页面是整个方案的入口。一个典型的上传页面包含:文件选择input、开始上传按钮、进度条容器、隐藏的fileId输入框。
html复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
<title>大文件上传测试</title>
</head>
<body>
<h2>大文件分块上传 / 断点续传</h2>
<input type="file" id="fileInput" />
<button id="uploadBtn" onclick="startUpload()">开始上传</button>
<div id="progressBar" style="width:0%;height:20px;background:#4caf50;"></div>
<span id="progressText">0%</span>
<%-- 如果业务需要,后端可以在JSP渲染时写入fileId --%>
<input type="hidden" id="fileIdInput" value="<%=request.getAttribute("fileId") != null ? request.getAttribute("fileId") : "" %>" />
<script src="upload.js"></script>
</body>
</html>
JSP里最需要注意的就是Web应用的上下文路径。如果你的应用部署在http://localhost:8080/upload-demo/下面,前端fetch请求路径就需要写成/upload-demo/upload/chunk,不能直接写/upload/chunk。很多初学者在这里踩坑。解决办法是把这个路径动态注入到页面里:
javascript复制let contextPath = '<%=request.getContextPath()%>';
fetch(contextPath + '/upload/chunk', { ... });
etContextPath方法拿到部署路径,这样不管部署在根路径还是子路径下,都不用改代码了。这个细节在开发环境往往没问题,一上生产就暴露,属于典型的必踩坑。
4. 实操过程记录与参数调优
4.1 一套可直接运行的落地步骤
我这里整理了完整的实操过程,可以直接照着做:
- 创建一个标准的Java Web工程,确认Servlet版本支持@WebServlet注解和@MultipartConfig注解,如果用的是Servlet 2.5,就需要在web.xml里面配置multipart-config。实际操作简单,建议直接用Servlet 3.1以上版本。
- 新建JSP页面,摆好上传控件和进度条,引入前端JS脚本。
- 编写ChunkUploadServlet,实现分块接收和状态记录。注意@MultipartConfig的maxFileSize要略大于分块大小。
- 编写ChunkQueryServlet,实现查询已上传分块列表接口。
- 编写ChunkMergeServlet,实现合并、校验、清理逻辑。
- 在web.xml或注解中配置好上传临时目录。生产环境建议使用绝对路径,把上传数据放到应用部署目录之外,防止重新部署时文件被清空。
- 部署到Tomcat,选择一个大文件测试。先用100MB文件验证基本流程,再用1GB以上文件验证稳定性和效率。
测出问题的顺序也很典型。我最初用100MB文件测试,基本一次通过。换到2GB文件后,发现合并时等待时间很长,查看磁盘IO才发现临时目录和最终目录都在同一块普通硬盘上,后来把临时目录放到SSD,合并速度提升明显。这类问题不在功能层面,但绝对是生产环境的真问题。
4.2 分块大小与并发数怎么定
分块大小直接决定上传效率和续传粒度。太小,分块数量过于庞大,请求开销上升,后端要处理很多小文件,状态记录也会堆积;太大,又回到“传到一个大块中途失败需要重传这一大块”的窘境,而且单个请求内存占用高。
以一个5GB的文件为例:
- 如果分块大小是10MB,总共有512块。假设上传带宽是10MB/s,理论上全部传完需要约8.5分钟。中断丢失最多10MB的数据。
- 如果分块大小是20MB,总共有256块。同样带宽下需要约8.5分钟(总量不变),但中断最多丢失20MB。
- 如果分块大小是1MB,总共有5120块。请求数量会非常大,前端任务队列压力和后端小文件IO压力都明显上升,不建议。
实际项目中,我倾向默认10MB到20MB。网络环境差(弱网、跨网)的场景,可以把分块调小到5MB;内网环境或者专线带宽高,可以用20MB甚至50MB。
并发数方面,同时上传的请求不是越多越好。浏览器对同一个域名有并发连接数限制,太高的并发会把带宽全部打满,反而影响每块的上传速度。我建议3到5个并发,配合分块大小10MB,能在速度和稳定性之间取得一个不错的平衡。
这里可以给一个参考表格:
| 网络环境 | 分块大小推荐 | 并发数推荐 |
|---|---|---|
| 内网/专线 | 20MB | 5 |
| 普通宽带/Wi-Fi | 10MB | 3 |
| 手机4G/5G弱网 | 5MB | 3 |
| 公网跨区域 | 5MB - 10MB | 2 - 3 |
需要说明,这组参数不是拍脑袋定的,而是根据实际测试结果做的一个推荐值。带宽充足且延迟低的内网,加大并发和分块能提高吞吐;公网跨地域传输,延迟高、容易抖动,小分块加低并发反而更稳。
4.3 服务端配置避坑清单
很多新手把代码写好了,一跑发现还是上传失败,问题往往出在服务端配置上。我整理了几个高频配置点:
Tomcat的maxPostSize和maxSwallowSize。Tomcat对请求体有一些默认限制,如果没配置好,大请求会被拒绝或者连接被重置。在conf/server.xml的Connector里,可以显式设置maxPostSize="0",表示不限制大小。不过这种方式要和分块方案配合,不能直接用普通上传方式传大文件。
Spring应用如果用MultipartFile接收,必须显式配置spring.servlet.multipart.max-file-size和max-request-size。默认值是1MB,不调大的话,分块上传也会直接报FileSizeLimitExceededException。建议max-file-size设置为你的分块大小加一个余量(比如10MB分块配20MB),max-request-size同理。
Nginx反向代理如果部署在前端,client_max_body_size默认只有1MB,必须调大,否则请求会在Nginx层直接返回413。经验值是设置成和你后端允许的maxRequestSize一致。
磁盘空间这块经常被忽略。大文件上传必然产生临时分块文件,多个用户同时上传时,临时目录的占用是“分块大小乘以分块数量”的累加。我曾经遇到过一个生产事故:临时目录所在分区只有50GB,几个同事同时上传了好几个GB的文件,分块还没合并,磁盘就满了,导致所有上传全部失败,最后只能半夜去扩容。所以上线前,至少预留两个大文件容量的磁盘空间,并且要把临时目录和最终目录分开,方便单独监控。
5. 常见问题与排错速查
5.1 分块传着传着就“消失”了
症状:前端显示某一块上传成功,后端查状态时却没有这个块。排查下来,多数原因是后端状态保存使用了非线程安全的集合。并发上传分块时,多个请求同时写状态,有的写操作被覆盖,就会导致“丢了”一块。
处理方案:后端状态集合换成支持并发读写的结构,比如ConcurrentHashMap配合ConcurrentSkipListSet;如果状态存本地文件,写入时要加锁,或者以“每个分块对应一个状态文件”的方式规避并发问题。
另外还有一种情况是分块文件写入时没有flush,进程异常退出后,文件虽然存在但内容不完整。写入分块时,建议在关闭流之前调用flush,并且写完后可以校验一下分块文件长度是否和前端声明的分块大小一致。
5.2 断点续传为什么总是从零开始
最典型的原因是fileId每次加载页面都重新生成。用户选好文件,前端初始化出一个fileId,传到一半没传完。用户刷新页面,前端又生成一个新fileId,查询接口自然返回空列表,所有分块都要重传。
解决方案是:同一份文件在同一个业务场景中要使用固定的fileId。入口页面从后端获取fileId时,后端可以按业务主键(比如某条外键记录)去生成或者复用fileId,保证刷新页面后fileId不变。如果纯粹是临时上传,也可以在前端用localStorage缓存一份“fileName + 文件大小 + fileId”的映射,刷新时先查缓存。但这种方式只对同一个浏览器有效,跨浏览器、跨设备就失效了。
还有一种隐蔽原因:文件被用户修改过。用户第一次选择文件传了一半,然后重新选择了同一路径下的另一个文件,虽然文件名一样,但内容、大小可能变了。fileId不变,分块数据却对不上,合并时大小校验通过不了。所以前端在上传前,如果检测到文件大小变化,就应该强制重新生成fileId。
5.3 合并时文件损坏或者文件大小不对
合并出的文件打不开、文件大小对不上,排查方向主要有两点:
第一,分块是否乱序。合并时如果按文件名排序,一定要确保分块序号是定长零填充。比如分块序号1写成了“1”,序号10写成了“10”,按字符串排序时,10会排在2前面,导致文件顺序错乱。正确做法是把序号格式化成长度一致的编号,比如“0001”“0002”“0010”,或者合并时按数字排序而不是字符串排序。
第二,分块是否重复或者缺少。前端可能会有重试机制,一个分块上传超时,用户手动点了一次重试,结果同一个分块被上传了两次。如果后端用“覆盖写”的方式保存分块文件,倒是没啥问题;如果用“追加写”的方式,分块文件里就有了两份数据,即使大小校验通过,最终合并出的文件也可能是坏的。因此后端保存分块时,应该使用覆盖写,不能用追加写。
5.4 上传速度上不去,长时间停在同一个分块
前端并发数是3,带宽也有富余,但速度就是上不去。这种情况排查顺序建议是:先看单个分块的上传耗时,再抓包看是不是频繁重传。常见原因有几个:
- 后端写了部分数据但没有及时flush,客户端要等整个分块传输完才拿到响应,响应缓慢会拖慢整个链路。后端写分块时,输出流要适时flush。
- 前端重试间隔过短,网络抖动时疯狂重试,反而占满了连接。重试策略建议用指数退避:第一次等1秒,第二次等2秒,第三次等4秒,最多等8秒。
- 浏览器对同一域名并发连接有限制,3个并发可能已经够了,调成10个反而因为资源竞争导致速度下降。
如果用的是Nginx反向代理,还要检查proxy_buffering,有些版本对大请求体的缓冲策略会影响上传体验。简单起见,可以在location配置中关闭上传路径的缓冲,或者调整proxy_request_buffering。
5.5 常见问题速查表
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 上传请求返回413 | Nginx或Tomcat请求体大小限制 | 调大client_max_body_size、maxPostSize等配置 |
| 分块上传成功,但状态丢失 | 后端状态集合线程不安全 | 换ConcurrentHashMap,或引入Redis存储状态 |
| 刷新页面后续传失效 | fileId每次重新生成 | 固定fileId,按业务主键或本地缓存复用 |
| 合并后文件损坏 | 分块顺序不对或重复写入 | 定长编号、按数字排序,分块文件覆盖写 |
| 上传进度一直不变 | 网络问题或后端flush不及时 | 检查后端OutputStream.flush,调整重试策略 |
| 单个分块上传很慢 | 分块过大或并发过高 | 调小分块,降低并发,观察带宽占用 |
| 上传完成后磁盘爆满 | 临时分块未清理 | 合并成功后清理分块,定时任务扫描残留文件 |
| 跨机器续传失败 | 状态存储在单机内存 | 迁移到Redis,并且分块文件用共享存储 |
表格里的每一条,我都在真实项目中碰到过或者帮别人排查过。这里再补充一个自检习惯:上传完成后,主动检查一下临时目录里的分块文件是否已经清空,如果没有,多半是合并逻辑或清理逻辑出了问题。清理动作可以放在合并成功之后,同时再写一个定时任务,扫描超过24小时未合并的临时文件并自动删除,防止异常情况堆积垃圾文件。
我个人在实际操作中还有一个体会:分块上传这类功能,光靠后台日志很难排查问题,一定要在前端把每个分块的上传状态打印出来,尤其是失败时能区分是HTTP层失败、网络层失败还是后端返回的错误。我在开发调试阶段,会把每个分块的index、耗时、重试次数输出到控制台或者页面调试面板上,问题定位能快很多。
最后再分享一个小技巧:合并接口尽量设计成幂等的——前端连续调用两次合并接口,也不应该把文件合并出问题。简单做法是合并开始时先查一下最终文件是否已经存在,如果存在且大小正确,直接返回成功。这个细节在弱网环境下很重要,因为前端很有可能发出了合并请求,但响应超时,用户又点了一次“完成”,幂等设计能避免二次合并的潜在问题。
