做过Web文件上传的人应该都有类似的体会:单体小文件还好,一旦遇到动辄几GB的视频素材、成百上千个文件的文件夹,用普通的multipart上传几乎必挂——要么超时,要么内存爆掉,要么传了一半网络抖动又得从头再来。我自己在给团队搭内部素材管理系统的时候,就被这个需求磨了很久。标题里提到的“JAVA网页上的文件夹结构分块上传与断点续传”,本质上就是在浏览器端把一个大文件切成若干块,逐块上传到Java后端,然后再按顺序合并,同时记录进度以便断点续传;如果是文件夹,还需要保留相对路径结构。这篇文章我会把从方案设计、前端实现到Spring Boot后端的完整落地过程讲清楚,包括怎么设计分块大小、怎么组织临时目录、怎么避免传完又合并出问题,以及那些不太容易在文档里翻到的坑。
需要说明的是,这里给的是基于常见实践的通用方案,不依赖任何特殊平台,适合大多数Java Web项目直接参考。适合正在做文件上传模块、或者被大文件传输折磨过的后端、全栈开发同学。
1. 整体方案设计与核心逻辑拆解
1.1 分块、断点续传、秒传三者是什么关系
很多人容易把分块上传和断点续传混为一谈,其实它们是三层能力:
- 分块上传:把文件按固定大小切成多个块,分别请求上传。这是基础,一切其他能力都建立在这上面。
- 断点续传:在某一块上传失败后,下次点击上传时,跳过已经存在的分块,只补传缺失的块。
- 秒传:上传前先计算文件的唯一标识(一般是内容哈希),如果服务器上已经有相同文件,直接跳过上传,返回成功。
一句话概括:分块是手段,续传是容错机制,秒传是体验优化。三者通常一起出现,因为实现路径高度重叠——分块信息天然就是“断点”的记录方式,而文件唯一标识又是判断“是否已存在”的关键。
从架构上看,我建议把整个流程拆成四个接口:
- 上传前查询:前端把文件哈希发过来,后端返回“该文件是否已存在、已存在哪些分块”。
- 分块上传:接收前端传来的单个分块数据,写入临时目录。
- 分块校验:可选接口,用于确认某个分块是否完整。
- 合并文件:前端确认所有分块上传完毕后,请求后端把临时分块合并成完整文件。
这样的设计有一个很直接的好处:前端可以随时暂停,后端不需要维护复杂状态机,所有状态都体现在“已上传分块”上。哪怕用户关掉浏览器重新打开,只要文件哈希不变,就能从已传的分块继续。
1.2 文件夹结构如何保留,临时目录如何组织
文件夹上传的核心难点不是“传”,而是“结构”。浏览器端通过input[webkitdirectory]拿到的File对象带一个webkitRelativePath属性,比如"素材/2025/宣传片/片头.mp4"。但如果只按文件名上传,到了服务端就变成一堆平铺文件,目录层级全丢了。
我的处理方式是:在上传元数据里额外传一个relativePath字段,后端把它当作虚拟路径保存。临时目录按“文件唯一标识”隔离,而不是按“用户”或“上传会话”隔离,这样同一个文件即使是不同人重复上传,分块也能共用,秒传逻辑自然就通了。
具体目录组织逻辑:
code复制{临时根目录}/{md5值}/
chunks/
chunk_00001
chunk_00002
...
{原始文件名}/{相对路径的目录结构}/
片头.mp4
字幕.srt
注意合并时的落盘方式:不要直接把分块拼成一个临时文件后再移动,而是按relativePath逐级创建目录,把合并好的文件直接写到目标路径,这样文件夹结构在落盘那一刻就已经恢复了。如果后续要把文件搬到对象存储,再循环遍历这个目录逐个上传即可。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 分块大小怎么定,不是越小越好
分块大小是整套方案里最重要的参数,没有之一。分块太小,比如1MB,一个2GB文件就有2048个分块,每个分块都是一次完整HTTP请求,光请求开销就大得惊人。分块太大,比如50MB,一旦网络抖动丢了,重传成本太高,而且浏览器端读取分块时的内存峰值也会上去。
我在实际项目里常用的配置是5MB到20MB之间,具体取值看业务场景:
| 场景 | 推荐分块大小 | 原因 |
|---|---|---|
| 内网传输、带宽充裕 | 20MB | 减少请求数量,吞吐率更高 |
| 公网传输、网络不稳 | 5MB | 单块失败成本低,续传粒度细 |
| 移动端或弱网环境 | 2MB~5MB | 避免长时间连接中断导致整块失败 |
如果你的文件绝大多数在200MB以内,我建议直接定10MB,这个值在大多数场景下兼顾了请求数量和重传成本,也便于计算。分块总数控制在几十到几百这个量级最舒服,后端合并时也不至于打开太多文件句柄。
另外还有一个容易被忽略的地方:最后一块的大小通常小于分块大小,这是正常的,前端代码里需要兼容这个边界,不能默认每块大小都一样。
2.2 文件唯一标识的计算:MD5全量算还是抽样算
分块上传的续传机制依赖一个稳定且唯一的标识把“同一文件”关联起来。最可靠的做法是计算整个文件的MD5或SHA-1,但大文件全量计算会占用大量时间,尤其是浏览器端,可能卡顿几秒甚至几十秒,用户感知非常明显。
我试过两种策略:
- 全量MD5:准确无误,但对大文件不友好。2GB文件在前端用JS算MD5,实测大概要5到10秒,期间页面会卡。
- 抽样哈希:只取文件头部256KB、中间256KB、尾部256KB拼接后计算MD5。速度快,基本不卡,但理论上存在极小概率的碰撞误判(两个不同文件抽样部分相同)。对于内部系统、素材管理这种场景,我倾向抽样哈希,因为在“识别同一文件”的语义下,它已经足够用了,而且还要配合文件大小做二次校验,误判率可以忽略。
这里有一个关键点:文件大小必须参与哈希计算。我在工程里会把抽样后的MD5和文件总长度拼接为fileId,格式类似md5(抽样内容) + "_" + size。这样即使抽样发生极端碰撞,大小不同也能区分。
2.3 前端并发控制:别把所有分块一窝蜂发出去
分块上传天然适合并发,但并发数不是越多越好。浏览器对同一域名的HTTP连接数是有限制的,HTTP/1.1下Chrome默认6个并发连接,强行开20个并发反而会造成排队和资源抢占。而且后端如果用的是Tomcat默认线程池,大量并发上传请求可能拖垮其他接口的响应。
我建议前端采用控制并发数的方式上传分块,并发量控制在3到6个之间。这里用简单的信号量机制就能控制,不需要引入额外依赖。如果用的是HTTP/2,并发数可以适当放宽到8到10个,但考虑到大多数内网环境还是HTTP/1.1,保守一点没坏处。
提示:不要把“并发上传分块”当成“队列上传分块”。前者能利用网络带宽,后者是串行,在2GB文件场景下串行上传会让人等到怀疑人生。
2.4 暂停、取消与续传的前端状态机
既然要做断点续传,前端就应该有一个清晰的上传状态机。我在项目里定义的状态有:pending、uploading、paused、completed、error。用户点暂停后,前端会取消尚未发出的分块请求,但已经发送的请求仍然可能在后端落盘,这是正常的,不影响续传。
续传的逻辑很直接:页面加载后,用户选择文件,前端先计算fileId,然后请求后端的已传分块列表接口,得到uploadedChunks: [0, 1, 2, 5, 6]这类索引集合,上传时跳过这些索引即可。注意要处理“分块已上传但校验失败”的情况,这个在后端查询接口中一并处理。
3. 实操过程与核心环节实现
3.1 表结构设计:分块状态放在哪里
我试过把分块状态放在Redis里,重启后没问题,但如果是分布式部署或者进程重启,Redis数据还在,临时文件目录也还在,其实也能工作。不过更稳妥的是放在MySQL里,一个主文件表、一个分块明细表,两张表就能挂住所有状态。
表结构大致这样:
sql复制CREATE TABLE upload_file (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
file_id VARCHAR(128) NOT NULL COMMENT '文件唯一标识',
file_name VARCHAR(255) NOT NULL,
relative_path VARCHAR(1024) DEFAULT NULL COMMENT '文件相对路径,文件夹上传时使用',
file_size BIGINT NOT NULL,
total_chunks INT NOT NULL,
chunk_size BIGINT NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-上传中 1-已合并 2-已失效',
target_path VARCHAR(1024) DEFAULT NULL COMMENT '合并后的存储路径',
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_file_id (file_id, relative_path)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE upload_file_chunk (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
file_id VARCHAR(128) NOT NULL,
chunk_index INT NOT NULL,
chunk_size BIGINT NOT NULL,
storage_path VARCHAR(1024) NOT NULL COMMENT '分块临时文件路径',
uploaded TINYINT NOT NULL DEFAULT 0,
upload_time DATETIME NOT NULL,
UNIQUE KEY uk_file_chunk (file_id, chunk_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
分块明细表必须加(file_id, chunk_index)唯一索引,防止前端重复提交同一分块时产生重复记录。这张表就是断点续传的“记忆”,查询已传分块时只需要:
sql复制SELECT chunk_index FROM upload_file_chunk WHERE file_id = ? AND uploaded = 1
3.2 前端核心实现:文件遍历、分块与并发上传
前端我用原生File API加一点axios就能实现,不依赖重量级框架。文件夹遍历的关键是webkitRelativePath,所有子文件都通过一个input框拿到。
javascript复制// HTML: <input type="file" id="fileInput" webkitdirectory multiple />
async function handleFiles(fileList) {
const files = Array.from(fileList);
for (const file of files) {
const relativePath = file.webkitRelativePath || file.name;
await uploadFileWithChunks(file, relativePath);
}
}
function createChunks(file, chunkSize) {
const chunks = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
chunks.push(file.slice(start, end));
start = end;
}
return chunks;
}
async function uploadFileWithChunks(file, relativePath) {
const chunkSize = 10 * 1024 * 1024; // 10MB
const chunks = createChunks(file, chunkSize);
// 抽样哈希 + 文件大小生成 fileId(示意)
const sampleBlob = await sampleHashBlob(file);
const hash = await md5(sampleBlob);
const fileId = `${hash}_${file.size}`;
// 先查询已上传的分块
const { data } = await axios.get('/api/upload/chunks', {
params: { fileId }
});
const uploadedMap = {};
data.uploadedChunks.forEach(idx => uploadedMap[idx] = true);
// 并发控制,同一时间最多并发4个
const poolSize = 4;
let cursor = 0;
const workers = Array.from({ length: poolSize }, async () => {
while (cursor < chunks.length) {
const idx = cursor++;
if (uploadedMap[idx]) continue;
const chunk = chunks[idx];
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileId', fileId);
formData.append('chunkIndex', idx);
formData.append('totalChunks', chunks.length);
formData.append('relativePath', relativePath);
formData.append('fileName', file.name);
formData.append('fileSize', file.size);
await axios.post('/api/upload/chunk', formData);
}
});
await Promise.all(workers);
// 所有分块传完后触发合并
await axios.post('/api/upload/merge', {
fileId,
fileName: file.name,
relativePath,
fileSize: file.size,
totalChunks: chunks.length
});
}
上面这段代码里用cursor让多个Worker协程共享任务索引,这是控制并发最轻量的方式。sampleHashBlob的作用是从文件头、中、尾取三块拼成一个Blob,具体实现就是用file.slice切片再Promise.all拼到一起,不再展开。
注意这里没有处理失败重试,实际工程里需要给每个分块上传包一层重试逻辑,比如失败后最多重试3次。我就遇到过网络波动导致某个分块上传失败返回500,如果不重试,整个文件就得从头来,那就失去断点续传的意义了。
3.3 后端核心实现:接收分块与查询已传分块
后端我用Spring Boot实现,核心接口三个。第一步是接收分块的接口,这个接口要做的事包括:校验fileId、chunkIndex合法性,把分块文件写入临时目录,然后更新数据库记录。
java复制@RestController
@RequestMapping("/api/upload")
public class FileUploadController {
@Resource
private UploadService uploadService;
@PostMapping("/chunk")
public Result<?> uploadChunk(
@RequestParam("file") MultipartFile file,
@RequestParam("fileId") String fileId,
@RequestParam("chunkIndex") Integer chunkIndex,
@RequestParam("totalChunks") Integer totalChunks,
@RequestParam("relativePath") String relativePath,
@RequestParam("fileName") String fileName,
@RequestParam("fileSize") Long fileSize) throws IOException {
uploadService.saveChunk(file, fileId, chunkIndex, relativePath);
return Result.ok();
}
@GetMapping("/chunks")
public Result<?> uploadedChunks(@RequestParam("fileId") String fileId) {
List<Integer> chunks = uploadService.listUploadedChunks(fileId);
return Result.ok(new ChunkQueryResponse(chunks));
}
@PostMapping("/merge")
public Result<?> merge(@RequestBody MergeRequest request) throws IOException {
String targetPath = uploadService.mergeFile(request);
return Result.ok(targetPath);
}
}
在saveChunk方法里,我建议先写临时文件,再更新数据库。顺序很重要:如果先更新数据库再写文件,数据库显示该分块已上传,但文件可能还没落盘,此时其他地方查询“已传分块”就会误判,导致后续合并时缺块。
临时文件的路径规则我用的是:/data/upload_tmp/{fileId}/{chunkIndex}.part。分块上传完成后,文件会直接写到这个路径并强制落盘。有人会问为什么不用UUID命名分块文件,其实用chunkIndex更直观,后续合并时按索引顺序读文件即可,不用再解析文件名。
3.4 后端核心实现:合并文件与校验
合并接口需要做三件事:检查所有分块是否齐全、检查每个分块是否完整、按顺序合并并清理分块临时文件。
java复制public String mergeFile(MergeRequest request) throws IOException {
File tmpDir = new File(UPLOAD_TMP_DIR, request.getFileId());
String relativePath = request.getRelativePath();
File targetFile = locateTargetFile(relativePath, request.getFileName());
long totalSize = 0L;
try (FileOutputStream fos = new FileOutputStream(targetFile)) {
for (int i = 0; i < request.getTotalChunks(); i++) {
File chunkFile = new File(tmpDir, i + ".part");
if (!chunkFile.exists()) {
throw new IllegalStateException("分块缺失: " + i);
}
try (FileInputStream fis = new FileInputStream(chunkFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = fis.read(buffer)) != -1) {
fos.write(buffer, 0, len);
totalSize += len;
}
}
}
}
if (totalSize != request.getFileSize()) {
throw new IllegalStateException("合并后文件大小不匹配");
}
// 合并成功,清理分块文件与临时目录
deleteQuietly(tmpDir);
uploadService.markMerged(request.getFileId(), targetFile.getAbsolutePath());
return targetFile.getAbsolutePath();
}
合并不是简单地把分块文件内容接起来,还要注意以下几点:
FileOutputStream合并时必须按chunkIndex从小到大顺序读取,基础的文件流方式没问题,但如果追求性能,更推荐使用FileChannel.transferTo,在大文件场景下比字节流快很多。- 合并完成后必须校验文件大小。前端传的
fileSize可以用来做第一道校验,更严的话可以再计算合并后文件的MD5与前端抽样哈希做比对,但全量比对成本较高,按需取舍。 - 合并成功后要及时删除分块临时文件,否则临时目录会越来越大,磁盘会被撑爆。我见过有的项目上线半年后才发现临时目录有几十GB的垃圾分块。
locateTargetFile这里要特别小心,如果relativePath包含../这类路径,直接拼到目标目录会引发目录穿越漏洞。合理的做法是用Path.normalize()后检查是否还在允许的根目录内,不在就抛异常。
3.5 Spring Boot配置与线程池设置
分块上传对Spring Boot的multipart配置有硬性要求。默认的max-file-size是1MB,max-request-size是10MB,如果没调大,所有分块上传请求都会被Spring直接拦截,返回MaxUploadSizeExceededException。
建议配置:
yaml复制spring:
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
这个max-file-size要留足余量,如果分块大小是10MB,配到50MB足够。注意有些版本还需要给Tomcat本身调maxSwallowSize,否则超过Servlet容器默认限制时,Tomcat会直接丢弃连接。
至于上传接口,我不建议用默认的Tomcat线程池直接扛高并发。给上传接口单独配一个线程池,核心线程数和最大线程数根据业务量评估,一般4到8个上传线程就够用了,这样避免上传请求把整个应用的线程池占满,影响其他接口响应。
3.6 文件夹上传的合并与落盘策略
合并接口对于文件夹上传,并不是把一个文件合并到指定位置就够了,而是要按relativePath创建目录结构。我推荐的做法是:
假设上传的文件是素材/2025/宣传片/片头.mp4,目标根目录是/data/files,合并时:
java复制Path root = Paths.get("/data/files").toAbsolutePath().normalize();
Path target = root.resolve(request.getRelativePath()).normalize();
if (!target.startsWith(root)) {
throw new SecurityException("非法路径");
}
Files.createDirectories(target.getParent());
Files.copy(fileStream, target);
这样整个目录树会随着文件逐个合并自动生成,不需要额外维护“文件夹”实体,一个空文件夹(没有文件)在上传后自然丢失,这是符合大多数业务预期的。
4. 常见问题与排查技巧实录
4.1 大文件上传导致的内存溢出与超时
大文件上传最常见的两个错误,一个是OutOfMemoryError,一个是连接超时。
内存溢出通常发生在后端一次性读取整个文件或前端一次性把整个文件读到内存里。前端要注意file.slice()产生的分块是只读引用,不要把它转成ArrayBuffer再上传,这样会突然多出一份内存副本。后端合并时切不要用Files.readAllBytes(),要用流式写入。
连接超时更多出现在Nginx层。如果项目用了Nginx做反向代理,默认的proxy_read_timeout是60秒,如果某个分块上传在弱网下超过60秒还没传完,Nginx就会返回504。需要在Nginx配置里调整:
nginx复制location /api/upload/ {
client_max_body_size 100m;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
这里的client_max_body_size也要大于分块大小,否则请求体超过限制会返回413。
4.2 断点续传失效的隐藏原因
断点续传失效有很多隐蔽原因,我排过最久的一个坑是:前端查询已传分块用的是fileId,上传时也传了fileId,但后端保存分块记录的fileId字段被数据库的VARCHAR长度截断了。文件大、哈希字符串长的时候,前几位相同,后面的字符被截掉,导致不同文件映射到了同一个fileId上,续传时查询到的分块列表张冠李戴。
解决方案是统一规范fileId的长度,数据库字段定128位,生成fileId时固定格式,比如md5(抽样内容)取32位十六进制,加上下划线再加数字长度,这样永远不会超长。还要在代码里加单元测试,验证不同文件生成的fileId一定不同。
另一个坑是分块上传成功但数据库记录失败。前端收到超时或网络错误后认为上传失败,发起重试,但后端其实已经写入分块了。如果后端不幂等,就把同一个分块重复插入,数据库唯一索引会报错,导致前端怎么重试都失败。解决方法是后端在插入分块记录时先查再插,或者用INSERT ... ON DUPLICATE KEY UPDATE做幂等。
4.3 并发上传同一文件的race condition问题
如果同一个文件被用户多点了几次上传,或者两个用户上传同一个文件,后端会收到来自多个请求对相同fileId和chunkIndex的并发写入。如果只是写入不同分块还好,最危险的是两个合并请求同时触发,两个线程同时读分块文件合并,会出现文件内容错乱或者“File not found”异常。
应对方式有两种:
- 在合并接口加分布式锁,锁的key就是
fileId。简单场景用ReentrantLock配合ConcurrentHashMap本地锁就行,分布式环境用Redis锁。 - 在合并前检查
upload_file表的status字段,只有status = 0且合法变化到status = 1时才执行合并,否则直接返回已合并的结果。
我推荐两者结合:前端的按钮也做禁用,后端仍然要加锁,因为防住正常用户容易,防住脚本和重复请求需要后端兜底。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 上传返回413 | Nginx client_max_body_size 过小 |
调大到分块大小的2倍以上 |
上传返回500 MaxUploadSizeExceeded |
Spring multipart 配置过小 |
修改 max-file-size、max-request-size |
| 合并时提示分块缺失 | 分块记录已写入但临时文件未落盘被清理 | 先写文件后更新数据库 |
| 合并后文件损坏 | 合并时未按索引顺序读取 | 按 chunkIndex 排序后再合并 |
| 续传后文件大小不对 | fileId 冲突或 fileSize 被截断 |
规范 fileId 格式,校验 fileSize |
| 文件夹层级丢失 | 只传文件名没传 relativePath |
前端解析 webkitRelativePath,后端按相对路径落盘 |
| 上传慢到怀疑人生 | 前端串行上传,没有并发控制 | 用信号量或Worker池控制并发上传 |
| 大文件计算哈希卡死UI | 全量MD5计算耗时过长 | 抽样哈希,或放到 Web Worker 里计算 |
5. 工具选型与扩展方向
5.1 自研方案与对象存储分片上传的取舍
聊完自研实现,再来说说为什么没有直接推MinIO或OSS的分片上传。MinIO是支持分片上传的(MinIO Server端对应S3的Multipart Upload接口),如果项目里的文件最终都要进对象存储,完全可以用S3 SDK在Java后端生成预签名URL,前端直接上传分片到对象存储,再把uploadId和分片ETag列表提交给后端,让后端调completeMultipartUpload完成合并。这套方案在网络架构上减轻了应用服务器的带宽压力,但实现复杂度和排错成本也高不少,对团队的前后端配合要求更高。
我的建议是:
- 文件量不大、用户并发不高、内网部署:自研临时目录方案完全够用,代码可控,排查方便。
- 文件量大、面向公网、需要CDN加速:优先考虑对象存储的分片上传,别自己造轮子了。
- 两者都需要的场景:可以先用自研方案做文件夹结构采集和临时落盘,再定一个异步任务把文件同步到对象存储。
5.2 后续可扩展的能力:秒传、限速与回调通知
如果分块上传和断点续传已经稳定跑起来,下一步值得做的扩展有三块。
第一是秒传。秒传的核心判断是fileId是否已存在,如果upload_file表里已有相同fileId且状态为已合并,就直接返回“秒传成功”。注意秒传不能只看文件大小和文件名,必须看内容哈希,否则很容易出现同名不同内容误判。
第二是限速。内网系统一般不需要,但公网场景下多个用户抢占带宽会互相拖垮,可以在前端做滑动窗口限速,或者在后端按用户做令牌桶限速,控制上传速率。
第三是回调通知。文件合并完成后,往往需要触发后续处理流程,比如视频转码、压缩、内容审核。合并接口在做完文件合并后,可以发出一个Spring事件,异步执行后续任务,避免合并接口长时间阻塞。
我在实际使用中还有一个建议:文件上传界面不要只显示总进度,要显示“当前正在上传的文件夹/文件名 + 文件级进度 + 总进度”。用户看到卡住时能明确知道卡在哪个文件上,这对排查问题也很有帮助。
踩过几次坑之后,我最大的体会是:分块上传和断点续传这套东西,代码量其实不大,难的是把分块大小、并发数、临时目录、幂等校验这些细节一次性想清楚。按照上面这套方案搭下来,我的经验是2GB以内的文件夹上传基本能做到稳定、可续传、不挨骂。后面如果你在实现中遇到具体问题,欢迎按这个方案的术语去定位——fileId、chunkIndex、merge、relativePath,只要这四个关键词能对得上,排查思路就能切对方向。
