做Java网页开发这些年,文件上传这块我踩过的坑确实不少。尤其是超大附件,动辄几个G的安装包、素材压缩包、音视频文件,直接走普通表单提交,十有八九传不完:请求超时、内存溢出、中途断连、进度条卡死,用户只能咬牙从头再来。今天我把处理超大附件分段上传与断点续传的一套完整方案整理出来,从设计思路到前后端代码,再到实战中的坑,一次性讲清楚。这套方案适用于自建服务器、文件存储服务,也能迁移到对象存储的分片上传场景,适合正被大文件上传折磨的Java后端开发人员和全栈工程师参考。
1. 先看透超大附件直传的痛点,再谈分段上传
1.1 大文件直传为什么总是失败
很多人第一次接触大文件上传时,第一反应是“不让传就调大限制”。于是Spring Boot里把 spring.servlet.multipart.max-file-size 和 max-request-size 拉到10G,Nginx的 client_max_body_size 也同步放大。结果呢?文件小的时候没事,一旦到了几个G,问题接踵而至。
第一个是请求超时。浏览器和服务端之间隔着一层层代理,Nginx默认的 proxy_read_timeout 通常只有60秒,Tomcat默认的连接超时也不长。大文件传输过程中,只要某个环节卡了那么一下就断,整个请求直接失败。用户看到“上传失败”四个字,只能重新来。
第二个是内存压力。Java后端处理上传时,MultipartFile 虽然走的是临时文件落盘,但框架在解析请求、转换数据时依然要吃大量内存。并发上来之后,多个大文件同时上传,堆内存瞬间被打满,常见的 java.lang.OutOfMemoryError: Java heap space 就是这么来的。很多服务器配置不高,几个并发大文件就能把进程拖死。
第三个是重传代价太高。大文件一旦失败,全部重来,带宽、时间、用户耐心全部被消耗掉。这也是直传模式最让人头疼的地方——失败不可怕,可怕的是没有“恢复能力”。
1.2 分段上传解决的是什么
分段上传(也叫Chunk Upload)思路很直接:把一个大文件在前端切成若干个固定大小的分片,一片一片传给后端。每片都是独立的HTTP请求,独立成功或失败。所有分片传完后,后端再按顺序拼接成完整文件。
它能解决三件事:把“一个很长的请求”拆成“很多个短请求”,每个请求控制在几秒到十几秒内返回,规避超时问题;每片内存占用可控,避免大文件整体读入堆内存;失败时只需要重传失败的那一片,而不是整个文件。配合断点续传,用户中断后再次上传,只要文件标识不变,已经传完的分片可以直接跳过,体验完全是两个档次。
分段上传也不是万能的。它解决不了服务器磁盘空间不足、带宽瓶颈、文件校验缺失这些基础问题。分段只是把大任务拆小了,合并后的完整性校验、临时分片的磁盘清理仍然要自己做。想清楚这一点,后续设计才不会跑偏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分段上传与断点续传的方案设计与核心细节
2.1 分片大小怎么定
分片大小是方案设计里最先要拍板的事。片太大等于没拆,片太小请求数量惊人。假设一个21G的文件,按不同分片大小看请求数量:
| 分片大小 | 分片数量 | 单次失败重传代价 | 适用场景 |
|---|---|---|---|
| 1MB | 约21504个 | 很低,但请求太多,网络往返开销大 | 极不稳定网络 |
| 5MB | 约4301个 | 较低,请求数量可接受 | 通用外网 |
| 10MB | 约2151个 | 中等,数量与代价平衡 | 推荐,通用场景 |
| 50MB | 约431个 | 较高,单次失败重传代价大 | 内网高带宽 |
我在项目里用得最多的是10MB。原因是外网环境下,10MB的单个请求传输时间通常在几秒到几十秒之间,既能避开代理超时,又不会因为分片太多导致HTTP请求开销过大。内网带宽足、网络稳定,可以放大到50MB甚至100MB,降低请求数量。另外还要考虑浏览器对同域并发连接数有限制,现代浏览器一般在6个左右,分片太小会导致大量排队,实际吞吐反而上不去。
2.2 续传的关键:文件唯一标识
断点续传实现的前提,是能够判断“这次上传的文件”和“上次没传完的文件”是不是同一个。浏览器本地文件通常用 文件大小 + 最后修改时间 来区分,为了更稳妥,也可以把文件名拼接进去,生成一个字符串,然后做哈希得到 fileId。
不要用文件名直接当标识。同名文件内容可能完全不一样,用户稍作修改再传一次,就会和后端残留的旧分片混在一起,导致合并后文件损坏。我用的是类似这样的生成规则:
javascript复制const raw = file.name + '_' + file.size + '_' + file.lastModified;
const fileId = md5(raw);
前端在开始上传前,先调用后端接口查询这个 fileId 下已传完的分片索引,然后跳过这些分片,只传缺失的部分。这就是断点续传的全部秘密——不是魔法,而是“记录 + 跳过”。
2.3 前后端的分工与交互流程
整个上传流程,前后端的职责要划分清楚,否则很容易写出乱糟糟的接口。
前端负责:读取文件、计算 fileId、切片、查询已上传分片、控制并发上传、展示进度、在失败后重试缺失分片、全部完成后触发合并。
后端负责:接收单个分片、按 fileId 和分片索引存储、提供一个查询接口返回已上传分片索引、提供一个合并接口把所有分片拼成完整文件、合并时校验文件完整性、补充清理临时分片。
交互流程看起来是这样的:
- 用户选择文件,前端计算
fileId。 - 前端调用查询接口,拿到已上传分片索引列表。
- 前端切片,跳过已上传的分片,按并发控制批量上传缺失分片。
- 全部完成后,前端调用合并接口。
- 后端合并分片并返回最终文件信息。
3. 后端实现:接口设计、分片存储与合并逻辑
3.1 分片存储结构如何设计
后端落地分片时,我建议用这个目录结构:
text复制/data/upload/temp/{fileId}/{chunkIndex}.part
/data/upload/temp/{fileId}/.meta
每个 fileId 一个目录,目录里放分片文件,.meta 记录文件元数据(原文件名、总大小、总分片数、可选的MD5)。合并完成后直接删除整个目录,非常干净。
这里有个安全细节必须注意:fileId 不能完全信任前端传参,否则用户传一个 ../../xxx 之类的值,就可能触发目录穿越漏洞。后端拿到 fileId 后要校验格式,我习惯用正则 [a-zA-Z0-9_-]{1,64} 约束,不合法直接拒绝。
chunkIndex 同样要校验,必须是 0 ~ totalChunks - 1 范围内的整数。不要图省事直接把前端传的索引拼到文件路径里,要对它做数字类型转换和范围判断。
3.2 接口设计
后端只需要三个接口,很简单:
| 接口 | 方法 | 作用 |
|---|---|---|
/upload/chunk/info |
GET | 查询某 fileId 已上传的分片索引 |
/upload/chunk |
POST | 上传单个分片 |
/upload/merge |
POST | 合并所有分片 |
3.3 查询已上传分片
查询接口的核心逻辑是扫描目录下的 .part 文件,解析出索引号:
java复制@GetMapping("/upload/chunk/info")
public Result chunkInfo(@RequestParam("fileId") String fileId) {
if (!isValidFileId(fileId)) {
return Result.error("参数不合法");
}
Path dir = Paths.get(UPLOAD_DIR, fileId);
List<Integer> uploaded = new ArrayList<>();
if (Files.exists(dir)) {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) {
for (Path path : stream) {
String name = path.getFileName().toString();
if (name.endsWith(".part")) {
// 文件名如 0.part / 15.part
uploaded.add(Integer.parseInt(name.substring(0, name.length() - 5)));
}
}
} catch (IOException e) {
return Result.error("查询分片信息失败");
}
}
Collections.sort(uploaded);
return Result.success(uploaded);
}
这段代码不复杂,但要注意两个点。第一,parseInt 要放在try-catch里,防止目录里出现非纯数字前缀的文件。第二,返回给前端的列表建议排序,前端拿到后可以直接切成Set做去重判断,也不用自己再排一次。
3.4 单个分片上传实现
单分片上传接口是整个流程里调用最频繁的,性能必须稳。参数包括 fileId、chunkIndex、totalChunks、file(分片的MultipartFile)。有些方案还会传 chunkSize、totalSize,这些统一写在 .meta 里更好,接口参数越少越省心。
java复制@PostMapping("/upload/chunk")
public Result uploadChunk(@RequestParam("fileId") String fileId,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks,
@RequestParam("file") MultipartFile chunk) {
if (!isValidFileId(fileId) || chunkIndex < 0 || chunkIndex >= totalChunks) {
return Result.error("参数不合法");
}
Path dir = Paths.get(UPLOAD_DIR, fileId);
Path target = dir.resolve(chunkIndex + ".part");
try {
if (Files.notExists(dir)) {
Files.createDirectories(dir);
}
// 已存在同索引分片且大小一致,直接跳过(幂等)
if (Files.exists(target) && Files.size(target) == chunk.getSize()) {
return Result.success("分片已存在");
}
// 写临时文件,再原子改名,避免并发读写出错
Path tmp = dir.resolve(chunkIndex + ".tmp");
try (InputStream in = chunk.getInputStream()) {
Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING);
}
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
writeMetaIfAbsent(dir, chunk);
return Result.success();
} catch (IOException e) {
log.error("保存分片失败, fileId={}, chunkIndex={}", fileId, chunkIndex, e);
return Result.error("分片保存失败");
}
}
这里的幂等处理很重要。前端并发重试、网络抖动后的重复请求,都可能让同一个 chunkIndex 被提交两次。如果后端直接覆盖,虽然结果一样,但高并发下有可能读到半个文件。我的做法是:先写 .tmp,写完后 move 成 .part。move 是原子操作,同一索引的分片只会有一个完整版本被看到。
writeMetaIfAbsent 的逻辑是把原文件名、总大小、总分片数等元数据记录到 .meta 文件里,只有第一次上传时写,后续分片到达时如果 .meta 已存在就跳过。合并环节再读取它,这样前端在调用合并接口时甚至不需要再传任何元参数。
3.5 分片合并实现
合并接口的职责是把指定 fileId 目录下的所有分片按索引拼接成完整文件。合并顺序一旦错乱,文件就是坏的。用Java的文件通道按顺序写入,性能没问题:
java复制@PostMapping("/upload/merge")
public Result merge(@RequestParam("fileId") String fileId) {
Path dir = Paths.get(UPLOAD_DIR, fileId);
if (!Files.isDirectory(dir)) {
return Result.error("分片目录不存在");
}
// 读取元数据
FileMeta meta = readMeta(dir);
if (meta == null) {
return Result.error("元数据缺失,无法合并");
}
List<Integer> indexes = listChunkIndexes(dir);
// 检查分片是否齐全
if (indexes.size() != meta.getTotalChunks()) {
return Result.error("分片不完整,已上传 " + indexes.size() + "/" + meta.getTotalChunks());
}
Path finalFile = Paths.get(FILE_DIR, generateFinalFileName(meta.getOriginalName()));
try (FileChannel out = FileChannel.open(finalFile,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
for (int i = 0; i < meta.getTotalChunks(); i++) {
Path part = dir.resolve(i + ".part");
if (Files.notExists(part)) {
return Result.error("分片缺失: " + i);
}
try (FileChannel in = FileChannel.open(part, StandardOpenOption.READ)) {
long size = in.size();
long transferred = 0;
while (transferred < size) {
transferred += in.transferTo(transferred, size - transferred, out);
}
}
}
} catch (IOException e) {
log.error("合并失败, fileId={}", fileId, e);
return Result.error("合并失败");
}
// 合并成功后清理临时分片目录
deleteDir(dir);
return Result.success(meta.getOriginalName(), finalFile.toString());
}
合并时我用了两层校验:合并前检查分片数量对不对,合并过程中再逐个确认分片文件存在。虽然第一层已经遍历了索引,但两层更稳,能防御目录被外部程序干扰导致的异常。
合并完成后,可以顺手校验最终文件大小是否等于 meta.getTotalSize()。如果用户中途换过文件导致分片混乱,大小校验能在第一时间暴露问题。至于MD5全量校验,实测对超大文件非常耗时,我建议放在异步任务里做,不阻塞本次合并响应。
3.6 后端相关配置
Spring Boot环境下,几个关键配置别漏:
properties复制spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=100MB
server.tomcat.max-swallow-size=100MB
这里有个容易理解错的点:max-file-size 不是限制整个大文件,而是限制单次请求里单个文件的大小。因为我们传的是10MB分片,所以设成20MB完全够用。max-request-size 是单次请求所有内容的总大小,前端一次只传一个分片,设成100MB已经很宽裕。
如果服务器前面挂了Nginx,client_max_body_size 也要同步调大。默认值是1MB,不调整的话分片一到Nginx就被拒了。建议设置成和 max-request-size 一致或略大。
4. 前端配合:切片、并发上传与断点续传
4.1 文件切片与fileId计算
前端用 File 对象的 slice 方法就能切片,不需要额外引第三方库。示例:
javascript复制const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
const file = inputElement.files[0];
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
async function handleUpload(file) {
const raw = `${file.name}_${file.size}_${file.lastModified}`;
const fileId = md5(raw); // 假设已引入 spark-md5 或自定义hash
// 1. 先查已上传分片
const resp = await fetch(`/upload/chunk/info?fileId=${fileId}`);
const { data: uploadedIndexes } = await resp.json();
const uploadedSet = new Set(uploadedIndexes);
// 2. 只上传缺失分片
const tasks = [];
for (let i = 0; i < totalChunks; i++) {
if (uploadedSet.has(i)) {
continue;
}
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
formData.append('file', blob, `${file.name}.part${i}`);
tasks.push({ index: i, data: formData });
}
// 3. 并发上传
await runWithConcurrency(tasks, 6);
// 4. 合并
await fetch('/upload/merge?fileId=' + fileId, { method: 'POST' });
}
file.slice 的第二个参数是结束位置(不含),最后一个分片可能小于 CHUNK_SIZE,Math.min 就是为了兜底这个边界。
4.2 前端并发控制
一次性把几千个请求全发出去,浏览器扛不住,服务器也扛不住。我写了个简单的并发控制函数,控制同时只有6个分片在传:
javascript复制async function runWithConcurrency(tasks, limit) {
const pool = new Set();
for (const task of tasks) {
const p = uploadOne(task).finally(() => {
pool.delete(p);
});
pool.add(p);
if (pool.size >= limit) {
await Promise.race(pool);
}
}
await Promise.all(pool);
}
async function uploadOne(task) {
const resp = await fetch('/upload/chunk', {
method: 'POST',
body: task.data
});
const result = await resp.json();
if (result.code !== 200) {
throw new Error(`分片 ${task.index} 上传失败`);
}
}
用 Promise.race 实现“满了就等其中一个完成再追加下一个”的效果。并发数我固定设成6,和浏览器的同域连接数保持一致,避免排队浪费。
4.3 断点续传的用户体验处理
断点续传在前端的核心表现就是第2步的 uploadedSet。用户上次传了一半中断了,重新选同一个文件时,fileId 相同,查询接口返回已传完的索引列表,循环时直接 continue 跳过。用户看到的现象就是“进度从上次的55%继续跑”,而不是从0开始。
进度条计算也要细心。如果按“分片数量”算,每个分片大小一致时没问题,但最后一个分片通常偏小,严格按数量算会导致最后1%卡很久。我推荐按字节数算:
javascript复制let uploadedBytes = uploadedSet.size * CHUNK_SIZE;
for (const task of runningTasks) {
task.data.get('file').size; // 这里需要在分片任务里记录分片大小
}
const percent = Math.min(100, uploadedBytes / file.size * 100);
在实际项目中,我习惯在每个task里带上分片的大小,进度更新时累加已完成的字节数。这样进度条平滑得多,用户也不会因为有“最后1%等半天”的错觉而跑去刷新页面。
4.4 上传异常后的恢复
单分片失败后,不要直接抛错终止,更不要无脑重试。我的策略是:每个分片最多重试3次,每次间隔1秒;重试仍然失败,则暂停整个任务,给出“网络异常,是否继续上传”的提示。用户点击继续后,重新查询一次已上传分片,继续跑未完成的部分。这套逻辑配合前面的 uploadedSet,天然支持断点续传,代码改造成本极低。
5. 实战中的排查记录与优化建议
5.1 上传时报 Out OfMemoryError
这个话题我在开头提了一次,这里再说细一点。如果已经做了分段上传,仍然出现 java.lang.OutOfMemoryError,重点检查两处:一是并发数,后端如果没控制并发,前端一次传几十个分片,每个分片解析和转存都会占用内存,堆可能被一次性打满;二是分片大小,如果设了50MB甚至100MB的分片,单请求内存消耗会成倍上升。建议在Spring Boot的 WebMvcConfigurer 里注册一个 TomcatProtocolHandlerCustomizer 调整线程池,或者更简单地,用信号量限制上传接口的并发:
java复制private final Semaphore uploadSemaphore = new Semaphore(8);
@PostMapping("/upload/chunk")
public Result uploadChunk(...) {
if (!uploadSemaphore.tryAcquire()) {
return Result.error("上传繁忙,请稍后重试");
}
try {
// 原有逻辑
} finally {
uploadSemaphore.release();
}
}
5.2 分片全部传完但合并总是失败
这种问题我遇到多半是分片数量校验不过。前端传的 totalChunks 是它自己切的,如果用户在上传过程中替换了文件,fileId 也没变(恰巧元数据一样),分片新旧混杂,合并出来大概率是坏的。我的处理方式是在 .meta 里记录 totalSize,合并完成后比对最终文件大小。不一致就删除合并产物并提示重新上传。
另外还有个小概率问题:索引文件缺失。分片目录被外部工具当缓存清掉了,或者磁盘写满时部分分片写入失败但前端以为成功。遇到这类问题,直接写个巡检接口,把已上传索引和完整索引范围比对,自动重传缺失分片即可。
5.3 长任务与连接超时
合并超大文件时,如果分片数量上万,服务端合并操作可能超过几十秒,此时前端的长连接容易断。解决方案有两个:一是合并接口用异步任务,先返回“合并中”,前端轮询任务状态;二是合并放到消息队列或者单独的线程池里执行,回调通知结果。自建服务器场景下,我的经验是分片数量在5000以内直接同步合并基本没问题,再大就要做异步了。
5.4 临时分片的磁盘占用与清理
分段上传最容易被忽略的是临时分片占用大量磁盘。用户传了一半不传了,或者传完合并成功却没清理,都会把服务器的磁盘塞满。我的做法是:
- 合并成功后,立刻删除临时目录。
- 每日凌晨跑一个定时任务,扫描临时目录,找出修改时间超过24小时的分片目录,直接删除。
- 元数据
.meta里记录最后的更新时间,方便定时任务做精确判断。
这个清理任务看起来不起眼,但没有它,跑上一个月,几TB磁盘都可能被“半截”分片占满。
6. 兜底与取舍:几个补充建议和个人体会
先聊点非代码层面的东西。整个方案落地后,有几个取舍值得提前想清楚。第一个是全量校验问题。大文件做MD5是很有诱惑力的完整性方案,但对超大附件来说,前端的MD5计算会卡住主线程很久,后端的全量校验会让合并接口变得很慢。实际项目中我通常只做“分片数量 + 文件大小”两层校验,已经能挡住绝大多数问题。如果必须做内容级校验,建议用Web Worker在后台算MD5,并且只用于分片而非整个文件。
第二个是接口鉴权。文件上传接口最容易被人拿来当攻击入口,必须接统一的登录态校验,并且服务端强校验文件后缀和MIME类型。分片目录本身不要暴露到静态资源访问路径下,合并后的文件目录也要避免用户通过URL猜测文件名直接读取。
第三个是文件最终命名。合并后的文件名如果直接用用户传的原始名,存在两个问题:中文名和特殊字符可能导致下载乱码,同名文件会互相覆盖。我在实际项目中用 UUID + 扩展名 做存储名,原始文件名放到数据库字段里,下载时再通过接口把 Content-Disposition 设回原始名。这一招能省掉很多邪门问题。
最后聊一下这套方案的扩展。如果后续文件上了对象存储,思路完全一致:对象存储本身支持Multipart Upload,前端仍然切分片,后端每收到一个分片就调用对象存储的UploadPart接口,合并对应CompleteMultipartUpload。也就是说,你现在理解的这套“分段上传 + 续传 + 校验 + 清理”的骨架,迁移到OSS、COS等平台上时依然成立,只是把本地分片目录换成了对象存储的中间状态而已。
搞超大附件上传这几年,我最大的体会是:方案不复杂,复杂的是把边界情况想清楚。分片丢失、重复提交、文件替换、磁盘占满、服务重启,每个环节都值得做一层防御。分段上传不是银弹,但配上一套完整的幂等、校验、清理机制之后,它在绝大多数自建服务器场景下都非常能打。如果你也在设计类似的功能,可以先从10MB分片、6并发、三层防御这套起步,跑通后再根据实际环境调整参数,少走很多弯路。
