去年接了一个内部管理系统的改造,用户要求网页端能直接上传21G的视频素材包。刚听到这个需求的时候,我以为把后台上传大小限制调一下就完事了,结果一测试就翻车:浏览器直接卡死、Nginx报413、Java后端直接OutOfMemoryError: Insufficient memory。后来老老实实把整个上传链路改成了分段上传加断点续传,才算把这个问题彻底解决。
这个场景在现在太常见了:大文件网盘、视频平台、数据标注后台、企业内部素材库,几乎都要面对超大附件的上传需求。而HTTP协议本身对上传大小没有硬性限制,真正的瓶颈在于Web容器、代理层、浏览器内存和网络稳定性。这篇文章就把我实现超大附件分段上传和续传的全过程拆开讲一遍,包括方案选型、前端切片、后端接收、分片合并、进度续传,以及我踩过的各种坑。适合正在做Java网页开发、被大文件上传折磨过的后端工程师和全栈开发者参考。
1. 先想清楚:为什么传统上传扛不住超大附件
1.1 一个“21G文件”逼出来的改造
传统的文件上传,前端拿一个 <input type="file">,后端用 MultipartFile 接收,看起来很简单。但文件一上G,问题就全冒出来了。
首先是浏览器端。用FormData一次性把整个文件塞进请求体,文件越大内存占用越高。以Chrome为例,一次请求会先把文件读进内存再发送,21G的文件直接能把浏览器标签页干崩。就算勉强发出去,请求体在网络上传输20多分钟,中间随便抖一下,整个请求就断了,用户只能从头再来。
其次是服务端。Spring Boot默认的 spring.servlet.multipart.max-file-size 只有1MB,加上 max-request-size 也只有10MB。就算手动调大到30G,Tomcat在处理超大请求体时,会尝试把内容缓存到内存或临时文件,内存一不够就报 OutOfMemoryError: Insufficient memory,而且这种错误一旦出现,往往整个应用都会被拖垮。
第三个问题是不可恢复性。普通上传是一次性请求,失败就是失败,没有“我传了一半继续传”的概念。大文件传输最大的痛点不是传输本身,而是“白传”。一旦失败,前面几个G的流量全部浪费。用户面对21G的素材包,传了3个小时突然断网,那种挫败感是体验上最致命的。
所以做超大附件上传,核心要解决三件事:减少单次传输的资源消耗、把大任务拆成可重试的小任务、让每一部分都能独立确认状态。分段上传和断点续传就是围绕这三件事展开的。
1.2 分段上传和续传的本质
分段上传的思路很直观:把一个大文件按照固定大小切成若干块,比如21G的文件切成2048个10MB的分片,每次只上传其中一个分片。每个分片是一次独立的HTTP请求,成功或失败互不影响。
这么做的好处在于:
- 单次请求体很小,内存占用可控,不再需要一次性读入整个文件。
- 某个分片失败只需要重传那个分片,而不是整个文件。
- 可以多个分片并发上传,充分利用带宽,比单线程快得多。
断点续传则是分段上传之上的状态管理。浏览器端在发起上传前,先通过文件内容生成一个唯一标识,然后去服务端查询这个文件已经传了哪些分片。已传过的分片直接跳过,只传剩余的部分。
两者的关系可以这样理解:分段上传解决了“怎么传”的问题,断点续传解决了“传了一半怎么接着传”的问题。分段是基础,续传是加分项。如果只做分段不做续传,用户刷新页面还是要从头传,只是比传统方式稍微好一点;但只有把续传做出来,才真正解决超大文件上传的核心体验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:断点续传不是玄学,是这四件事
2.1 谁来做分段:前端切片才是正解
实现分段上传有两条路:前端用 File.slice() 切片,或者后端在接受完整文件后自己分块。对于网页上传场景,前端切片是绝对的主流。
后端分块听起来好像能省前端的事,但仔细想就会发现逻辑反了:后端要分块,必须先把整个文件接收完,那么HTTP请求体仍然是超大文件,前面的内存、超时、失败重传问题一个都没解决。后端分块适合的场景是服务端内部处理大文件,比如从对象存储下载到本地再切片处理,而不是用户上传场景。
前端切片用的是HTML5的 File API,File对象继承自Blob,有 slice() 方法,可以按字节范围截取文件的一部分:
javascript复制const file = input.files[0];
const chunkSize = 10 * 1024 * 1024; // 10MB
const totalChunks = Math.ceil(file.size / chunkSize);
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const chunk = file.slice(start, end);
// 上传这个chunk
}
这里有几个容易被忽略的细节。file.slice() 拿到的是Blob对象,传给FormData时可以正常使用,不需要转成File。切片的次数和文件大小、分片大小有关,21G文件按1MB切片会有两万多个分片,按50MB切片只有400多个,这个数量会直接影响请求次数和服务端文件句柄数。
另外要注意,切片是在内存中拿着文件的引用,不是真的把文件复制了一份,所以不用担心切片导致内存翻倍。真正吃内存的是把切片读取成ArrayBuffer或DataURL的过程,上传时用FormData直接传Blob就没这个问题。
2.2 分片大小怎么定:平衡请求数与重传成本
分片大小没有标准答案,需要根据网络环境、服务器承受能力、失败重试代价来权衡。我见过有人用1MB的分片,也有人用50MB的分片,关键是算清楚账。
先说分片过大。假设分片是100MB,用户带宽上行10Mbps,理论上传一个分片要80秒。如果传了90秒突然断网,这个分片就要重传,白费了90秒的流量和用户时间。而且分片越大,单次请求占用后端内存和带宽越久,并发起来之后服务器压力也大。
再说分片过小。分片1MB的话,21G文件要传两万多次请求,每次请求都有HTTP头部开销,而且服务端要为每个分片创建文件、记录状态,请求量大会给数据库和磁盘带来压力。另外分片越小,碎片文件越多,合并时打开文件的次数也越多。
我实际使用的经验值,局域网或内网环境可以用20MB到50MB分片,公网环境推荐5MB到10MB。为什么公网要小一点?因为公网延迟高、丢包概率大,分片太大容易重传;同时要限制前端并发数,避免把公网出口带宽打满导致其他业务受影响。
以10MB分片为例,21G文件切2100个分片,并发4路上传,假设有效上行带宽10Mbps(约1.25MB/s),全部传完大约需要10MB × 2100 / 1.25MB/s ≈ 16800秒,大概4.7小时。如果用户带宽大,比如50Mbps上行,时间就缩短到1小时左右。这是单个文件的物理极限,分段上传只是让这个过程中断时不需要从头再来,并不能突破带宽瓶颈。
2.3 续传靠什么认人:文件身份标识设计
断点续传最关键的一步是服务端要能识别“用户传的是同一个文件”。最常用的方案是用文件的MD5值作为唯一标识。
前端在选完文件后,用Web Worker读取文件内容,计算出一个全局唯一的MD5。这个MD5会伴随整个上传过程:上传分片时带上它,服务端根据它创建目录保存分片;查询进度时也带上它,服务端根据它返回已上传分片列表;合并文件时同样以它作为主键。
为什么不直接用文件名加文件大小?因为不同用户可能传同名文件,不同时间可能传同大小不同内容的文件,这两个信息不足以唯一定位。MD5的有效性在于,内容相同则MD5相同,内容不同则MD5大概率不同,可以准确判断“之前传的是不是同一个文件”。
这里有一个常见做法:用 MD5 + 文件大小 作为复合标识,MD5用于识别内容,文件大小用于快速校验完整性,顺便防MD5冲突。实际部署中再给这个复合标识加一个内部文件ID,前端传MD5,服务端生成UUID作为存储目录名,避免直接用MD5作为路径导致信息泄露。
大文件算MD5很耗时。21G文件用 SparkMD5 全量计算,大概要几分钟甚至更久,期间页面会卡顿,所以一定要放到Web Worker里跑。如果业务上对断点续传要求不高,也可以退而求其次,用 文件大小 + 文件名 + 首次上传时间 生成一个弱标识,牺牲准确性换取速度。我建议在内部系统里保留全量MD5,因为合并后还需要用它做完整性校验,这一步省不了。
2.4 MD5到底要不要算
很多人纠结断点续传要不要做MD5,因为大文件算MD5太慢了。我的结论是:必须算,但可以优化计算策略。
MD5在这里有两个作用:一是断点续传的标识,二是合并后校验文件的完整性。第二个作用尤其重要。分段上传过程中,网络传输可能损坏数据,多个分片合并后可能少块、错序,没有MD5校验,你根本不知道最终得到的文件是否完整可用。
优化策略有两个方向。第一,前端用增量计算代替全量读取。SparkMD5支持增量计算,可以配合分片过程,在切片的同时边读边算,而不是先单独花几分钟算完再上传。第二,如果文件实在太大,可以用“快速校验 + 抽样校验”替代全量校验:上传时先根据文件大小和其他元数据判断,合并后只校验分片数量、分片大小、文件总大小,不计算MD5;在后台异步任务中对合并后的文件做全文MD5,发现问题再告警。
我个人的经验是:内网系统可以牺牲一点点上传前的等待时间,用全量MD5换取后续的绝对可靠;公网产品则优先用文件大小加元数据做弱校验,再在后台异步做全文校验,避免用户在上传前等太久。
3. 从零实现一个分段上传+断点续传
3.1 前端切片与并发控制
前端是整个上传流程的驱动者,负责切片、上传、进度展示、续传触发。我用原生JavaScript写最核心的部分,方便理解;实际项目里如果用Vue或React,逻辑是一样的,只是把状态放到框架里管理。
首先定义基础参数和状态:
javascript复制const CHUNK_SIZE = 10 * 1024 * 1024; // 分片大小 10MB
const CONCURRENCY = 4; // 并发数
const file = document.getElementById('fileInput').files[0];
let fileId = ''; // 文件唯一标识,由MD5决定
let uploadedChunks = new Set(); // 已上传的分片序号
然后计算文件的MD5标识,这里用Web Worker演示简化版,实际用SparkMD5:
javascript复制// worker.js
importScripts('spark-md5.min.js');
self.onmessage = (e) => {
const file = e.data;
const spark = new SparkMD5.ArrayBuffer();
const chunkSize = 10 * 1024 * 1024;
let currentChunk = 0;
const totalChunks = Math.ceil(file.size / chunkSize);
function readNext() {
const reader = new FileReader();
const start = currentChunk * chunkSize;
const end = Math.min(file.size, start + chunkSize);
reader.readAsArrayBuffer(file.slice(start, end));
reader.onload = (event) => {
spark.append(event.target.result);
currentChunk++;
if (currentChunk < totalChunks) {
readNext();
} else {
self.postMessage({ md5: spark.end(), totalChunks });
}
};
}
readNext();
};
拿到MD5后,先去服务端查一次进度:
javascript复制const progressRes = await fetch(`/api/upload/progress?fileId=${md5}`);
const progressData = await progressRes.json();
uploadedChunks = new Set(progressData.uploadedChunks || []);
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const pendingChunks = [];
for (let i = 0; i < totalChunks; i++) {
if (!uploadedChunks.has(i)) {
pendingChunks.push(i);
}
}
接着用简单的方式控制并发数。核心思路是维护一个任务队列,每次最多同时执行 CONCURRENCY 个上传任务:
javascript复制async function uploadPendingChunks(chunks, file) {
let index = 0;
async function worker() {
while (index < chunks.length) {
const chunkIndex = chunks[index++];
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(file.size, start + CHUNK_SIZE);
const chunkBlob = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunkBlob);
formData.append('fileId', md5);
formData.append('chunkIndex', chunkIndex);
formData.append('totalChunks', totalChunks);
await fetch('/api/upload/chunk', {
method: 'POST',
body: formData
});
uploadedChunks.add(chunkIndex);
// 更新进度条
updateProgress(uploadedChunks.size, totalChunks);
}
}
const workers = Array.from({ length: CONCURRENCY }, () => worker());
await Promise.all(workers);
}
这个方案的优点是没有引入额外的框架依赖,用最简单的方式实现了并发控制。缺点是没有做失败重试,fetch抛异常时整个worker退出。实际项目中,每个分片至少要重试三次,并且要记录失败的分片,最后统一处理。稍后的坑位章节我会专门讲。
全部上传完成后,调用合并接口:
javascript复制const mergeRes = await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId: md5, fileName: file.name, totalChunks })
});
到这里,前端的主流程就闭环了:选文件 -> 算MD5 -> 查进度 -> 并发上传未完成分片 -> 触发合并。
3.2 后端接收分片接口
后端我使用Spring Boot实现。先解决配置问题,分片上传时单次请求体大小只需覆盖一个分片,所以把multipart配置调成适合分片的大小即可,不需要再调大到几十G。
properties复制spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=25MB
接收分片的接口设计得尽量简单,参数固定,方便前端对齐:
java复制@RestController
@RequestMapping("/api/upload")
public class ChunkUploadController {
@PostMapping("/chunk")
public Result uploadChunk(@RequestParam("file") MultipartFile file,
@RequestParam("fileId") String fileId,
@RequestParam("chunkIndex") Integer chunkIndex,
@RequestParam("totalChunks") Integer totalChunks) throws IOException {
// 分片临时目录:/data/upload/tmp/{fileId}/
Path chunkDir = Paths.get("/data/upload/tmp", fileId);
Files.createDirectories(chunkDir);
// 分片文件名:chunk_0001、chunk_0002,用索引保证排序
String chunkFileName = String.format("chunk_%05d", chunkIndex);
Path chunkPath = chunkDir.resolve(chunkFileName);
// 保存分片到磁盘
file.transferTo(chunkPath.toFile());
// 更新上传记录表,记录已上传的分片号
uploadRecordService.markChunkUploaded(fileId, chunkIndex, totalChunks);
return Result.success();
}
}
这里有几个关键决定。分片文件名用 chunk_%05d 格式,按照0补齐到5位数,是为了在合并时直接按字符串排序就能得到正确的分片顺序。保存分片用 file.transferTo(),Spring会处理InputStream的关闭,比手动 Files.copy(file.getInputStream(), ...) 更省心。
uploadRecordService 负责维护上传状态。我推荐用一张表记录分片上传情况,字段至少包括:fileId、chunkIndex、uploadStatus、createTime。每次上传分片时先查这条记录,如果已经有成功记录就直接返回成功,避免前端重复上传。这里有一个并发场景要特别注意:如果前端开了多个并发,不同分片同时写入同一张表,需要用 fileId + chunkIndex 做唯一索引,用INSERT ... ON DUPLICATE KEY UPDATE或者先查再插的方式保证数据一致。
3.3 进度查询与续传触发
进度查询接口是断点续传的核心通信点。前端选完文件、算完MD5之后,会带着fileId来问服务端:这个文件我传了哪些分片?
java复制@GetMapping("/progress")
public Result getProgress(@RequestParam("fileId") String fileId) {
// 查询已上传的分片序号列表
List<Integer> uploadedChunks = uploadRecordService.getUploadedChunks(fileId);
// 查询文件是否已完成合并
UploadRecord record = uploadRecordService.findByFileId(fileId);
boolean merged = record != null && record.getMerged();
return Result.success(new HashMap<String, Object>() {{
put("uploadedChunks", uploadedChunks);
put("merged", merged);
}});
}
前端拿到 uploadedChunks 后,把这些分片从待上传列表里剔除,只传剩下的,就实现了续传。
这里有个边界情况:如果查询时发现 merged=true,说明文件已经合并过了,前端可以直接提示用户秒传完成,无须重新上传。这个逻辑在网盘系统里很常见:同一份文件,别人传过了,你直接传MD5校验一下,就能秒传。
续传还有一个隐藏的坑:临时分片目录可能被运维清理掉,但数据库里的记录还在。比如用户传了一半,隔了两天再回来续传,临时文件被定时任务清了,前端查到了已上传分片列表,跳过这些分片,但合并时找不到分片文件,导致合并失败。解决方法是:合并前校验分片文件是否存在,缺失的返回给前端重新上传;或者进度查询时就去检查磁盘文件是否真实存在,只返回真实存在的分片。
3.4 合并分片与服务端校验
合并接口的任务是把临时目录里的分片按顺序拼接成完整文件。我用流式拷贝,避免一次性把整个文件读入内存:
java复制@PostMapping("/merge")
public Result mergeChunks(@RequestBody MergeRequest request) throws IOException {
String fileId = request.getFileId();
int totalChunks = request.getTotalChunks();
Path chunkDir = Paths.get("/data/upload/tmp", fileId);
Path targetDir = Paths.get("/data/upload/files");
Files.createDirectories(targetDir);
// 完整文件以 fileId + 原文件名 命名
String targetFileName = fileId + "_" + sanitizeFileName(request.getFileName());
Path targetPath = targetDir.resolve(targetFileName);
// 逐个分片写入目标文件
try (OutputStream out = Files.newOutputStream(targetPath)) {
for (int i = 0; i < totalChunks; i++) {
Path chunkPath = chunkDir.resolve(String.format("chunk_%05d", i));
if (!Files.exists(chunkPath)) {
throw new IllegalStateException("分片缺失: " + i);
}
Files.copy(chunkPath, out);
}
}
// 合并后清理临时分片
deleteRecursively(chunkDir);
return Result.success();
}
合并时要注意一个细节:分片大小不一定是完全均等的,最后一个分片通常小于 CHUNK_SIZE。所以合并时不能假设每个分片都是固定大小,直接用 Files.copy 把整个分片文件内容写完,而不是手动计算每个分片应该写多少字节。
服务端校验方面,至少要验证两件事:分片数量是否等于totalChunks,以及分片目录是否存在且完整。如果是全量MD5方案,合并完成后后端再算一次MD5,与前端上传前计算的MD5做对比,不一致就删除文件返回失败。这一步能发现合并过程中文件的损坏,也能发现前端传了错误的内容。
最后不要把校验逻辑放在合并后进行异步处理。我曾经图省事用 @Async 做合并后MD5校验,结果有一次分片数据确实损坏了,用户看到“上传成功”,打开文件却是坏的,这体验比失败更糟。校验必须和合并处于同一个同步流程里,校验不过就返回明确错误,让用户重新上传。
4. 实战中那些坑,我替你踩过了
4.1 并发上传把服务器打挂了
第一次上线时只做了分段,没控制并发。前端的forEach循环直接一次性把所有分片请求发出去了,2100个分片同时打过来,后端Tomcat线程池瞬间被打满,数据库连接池也耗尽,整个系统不可用,连登录请求都进不来。
排查下来有三个层面的问题。第一是前端并发数没有限制,浏览器对同一域名的并发连接数本身有限制,Chrome大约6个,但配合HTTP/2之后可以被放大;第二是后端接口没有任何限流和队列处理,所有请求扎堆处理;第三是分片保存的磁盘IO成为瓶颈,大量并发写同一目录导致性能下降。
解决措施是层层设卡。前端用前面提到的并发控制,把同时上传的分片数限制在4到8个;后端增加线程池队列,设置合理的最大连接数和拒绝策略;分片保存的临时目录按fileId分目录,避免所有分片都写在同一个目录下。经过这三层限制,服务器负载从崩溃状态降到平稳状态。
经验是:分段上传的并发数不是越大越好,带宽有限的情况下并发太高只会增加排队和重试,4到6路并发在多数场景下是性价比最好的区间。
4.2 合并后文件损坏,MD5对不上
有段时间用户反馈文件上传成功但解压报错,查到最后是分片合并时顺序错乱导致的。
原因很有意思:前端用并发上传,每个分片到达服务端的时间是乱序的。如果合并接口不是读取磁盘上的分片文件,而是去数据库查询分片记录后组装,遇到数据库记录存在但实际写入顺序不对的场景,就会出现文件损坏。或者是合并时用分片的初始大小去计算偏移量,遇到最后一个分片小于标准大小的情况,就会把文件截断或拼接错位。
我的解决方式是干脆不用数据库顺序,完全依赖磁盘分片文件的命名排序。每个分片保存时用 chunk_%05d 命名,合并时先对目录下的分片文件做排序,再逐个写入目标文件。排序规则是字符串排序,因为索引数字补零后顺序天然正确。
合并完成后,用MD5和文件总大小双重校验。文件大小校验可以即时做:所有分片大小之和应该等于前端传来的文件大小,差一个字节都说明有问题。MD5校验放在最后一道,全量比对通过才给前端返回成功。
如果项目里用对象存储而不是本地磁盘,把分片合并策略换成OSS的multipart upload API或者MinIO的composeObject,道理是相通的:分片上传时记录好顺序,合并时按顺序组合。
4.3 OutOfMemoryError: Insufficient memory
之前接手的同事把上传接口写成这样:直接用 MultipartFile.getBytes() 拿到整个文件的byte数组,再存到服务端。大文件一上传,JVM堆内存瞬间被撑爆,报出 java.lang.OutOfMemoryError: Insufficient memory。
分段上传之后,interface层的单次请求只处理一个分片,理论上不存在内存溢出的问题。但如果代码写得不对,还是有踩坑空间:
- 合并分片时,不要用
Files.readAllBytes(targetFile)去读整个文件再写。21G的文件读进内存直接炸。用流式拷贝,每次最多读8KB到64KB,内存占用恒定。 - 前端计算MD5时,不要在主线程一次性
readAsArrayBuffer整个文件。按分片读取,每次只读一个分片的大小,配合SparkMD5增量计算。 - 后端接收分片时,注意事项到Spring的multipart配置。如果
max-file-size不调大,分片稍微大一点就会被拒;如果调太大,又等于允许超大请求体打进来。分片方案下这个值保持略大于分片大小即可。
内存问题还有一个隐蔽来源:分片上传时,如果前端把每个切片都转成了Base64 DataURL,内存占用会增大30%以上。尽量用二进制FormData上传,不要做无谓的编码转换。
4.4 常见问题速查表
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| Nginx返回413 Request Entity Too Large | Nginx client_max_body_size 未调到分片大小以上 |
nginx.conf中设为 client_max_body_size 30m;,改完reload |
| 分片上传成功但合并后文件损坏 | 分片顺序混乱或漏了分片 | 分片文件用补零序号命名,合并前校验分片数量并排序 |
| 上传过程中浏览器崩溃 | 文件切片时读取大块数据到内存 | 使用 File.slice 配合FormData直接上传,避免转Base64 |
| 断点续传找不到已上传分片 | 临时目录被清理,或数据库与磁盘状态不一致 | 查询进度时同时检查磁盘文件是否存在,合并前再校验一次 |
| 合并后的文件MD5和前端不一致 | 传输过程中数据损坏 | 大文件建议合并后做全量MD5校验,失败则自动清理重传 |
| 用户上传过程中刷新页面 | 前端状态丢失 | 每次请求都带fileId,下次进入页面先查进度再续传 |
| 磁盘空间不足 | 多个大文件同时上传,临时文件占满磁盘 | 设置临时目录配额,定时清理超过7天的分片文件,合并后立即删除分片 |
排查这类问题,我习惯在服务端加一个简单的上传日志:每个分片到达都记录fileId、chunkIndex、大小、耗时。前端也打日志,记录了自己发了哪些分片、收到什么响应。两边日志一比对,很多问题一眼就能定位。
结合我自己的经验,写到最后再分享一个观点:做分段上传和断点续传,不要一上来就追求架构的复杂度。先把前端切片、后端接收、查询进度、按序合并这四个环节跑通,就已经能解决90%的场景。剩下的并发优化、秒传、一致性校验,都是在基础流程上逐步叠加的。最重要的是把状态记录清楚,让系统在任意时刻都能回答“这个文件传了多少、还差哪些”,断点续传的体验自然就出来了。
