大文件上传这个需求,凡是做过几年后端的人基本都会遇到一两次。我印象最深的是以前给一个内部协作平台做升级,用户经常要上传好几个GB的工程打包文件,那时候方案很粗暴,一个大请求直接把整个文件往服务器扔。结果就是传了四十多分钟,中途Wi-Fi闪一下断掉,进度归零,用户崩溃,运维半夜被电话叫醒。后来把断点续传做进去,问题才真正解决。
所谓断点续传,核心思路其实不复杂:把大文件切成若干个分片,逐块上传,服务端单独记录每个分片的状态,中断后只重传未传完的那几块,而不是从头再来。听起来简单,但真正落地时牵扯到分片大小、校验合并、并发控制、临时目录清理、对象存储适配等一系列问题。这篇文章我会把整个链路拆开讲一遍,从HTTP上传的瓶颈到Spring Boot后端实现,再到前端Worker、秒传、MinIO适配这些进阶玩法,目的就是让你看完能直接在自己的项目里动手做一版,而不是只停留在“我知道要分片”这个层面。
1. 大文件上传为什么不能只靠“一个大请求把文件甩给服务器”
1.1 一次2GB文件上传失败后的复盘
先讲个真实场景。某个下午,一位设计师同事要把一个压缩包传到共享盘,文件正好2GB出头。我们当时的实现是用浏览器FormData对象直接把文件放进请求体,后端用Commons FileUpload接收,整个流程走的是同步接口。
传到一半,内存占用飙升,Tomcat那边先是报警,然后请求直接超时断开,前台上传进度条卡在63%。更尴尬的是,服务端检查了一遍,磁盘上根本没有落地的文件,等于白传了半小时。
那次复盘得出的结论很直接:一个大请求上传大文件,链路里全是隐患。首先是内存,很多Servlet容器处理上传时默认会先把请求体缓冲到内存,文件稍微大一点就触发OutOfMemory。然后是超时,无论是Tomcat的连接超时还是Nginx的proxy_read_timeout,都是给普通请求设计的,一个持续半小时的上传请求很容易被掐断。最后是重传成本,网络抖动、用户误关页面、公司断网,任何一个环节出问题,前功尽弃。
1.2 HTTP上传的隐藏瓶颈不只是带宽
很多人以为大文件上传慢是带宽不够,带宽只是其中一环。真正的瓶颈往往在链路中间。
第一个瓶颈是请求体缓冲。Spring Boot内嵌Tomcat默认的maxSwallowSize、maxPostSize如果不去调,大文件直接会被拦截。Nginx层还有一个client_max_body_size,默认才1MB,不设置的话文件根本到不了后端。
第二个瓶颈是连接时间限制。移动端用户切个网络,TCP连接就断了,HTTP请求是面向短连接的,天然不适合长时间传输。
第三个瓶颈是重复传输的成本。单个大请求的方式里,服务端无法知道用户传到了哪个字节,客户端也不清楚服务端收到了多少。链路只要断一次,所有已传输的数据全部作废。这在大文件场景下是致命的,因为重传不只是一次,可能反复出现,用户耐心和满意度会迅速归零。
1.3 断点续传到底解决的是哪几类问题
把问题梳理清楚之后,断点续传的意义就很明确了。它实际上解决的是三类痛点:
- 中断恢复:网络断掉之后,客户端能从已传完的分片继续,而不是从0开始。
- 多路并发:把文件切成多个分片后,可以同时发起多个上传请求,充分利用带宽,缩短总时间。
- 服务端可控性:分片方式下,服务端可以逐个校验、逐块存储,内存占用小,也便于后续做秒传、进度查询这类功能。
所以断点续传不是“炫技”,而是大文件上传场景下的工程必需品。下面我开始拆解具体实现方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 断点续传落地方案:分片、进度记录、校验机制全拆解
2.1 总体架构:三个核心模块怎么配合
一套完整的断点续传方案,至少包含三个模块。
- 分片模块:在前端把文件切块,给每一块编号,记录总块数、当前块号、文件唯一标识。
- 状态模块:服务端记录每个分片的上传状态,“文件已传了哪些块”这个信息必须持久化,不能放在内存里。
- 合并模块:所有分片传完后,服务端按照顺序把它们合并回完整文件,并做完整性校验。
配合关系是客户端先把文件元信息(文件名、大小、分片大小、分片总数、文件MD5)发给服务端,服务端拿到后返回一个文件上传任务的ID,之后客户端逐块发送,服务端逐块保存并更新状态,最后客户端发一个合并请求,服务端完成合并。
这里有一个关键点:文件唯一标识怎么生成。最常见的是用文件内容的MD5或SHA-1值,这样还能顺带做秒传。但大文件算全量MD5很耗时,我一般会在前端用抽样策略快速生成一个近似哈希,后端在最终合并完成后再精确计算一次全量MD5,保证结果准确。
2.2 分片大小怎么选:不是越小越好
分片大小是方案里最容易被忽视的参数,但它直接影响上传成功率和整体效率。
我做过一轮简单对比测试:
| 分片大小 | 总请求数(2GB文件) | 优点 | 缺点 |
|---|---|---|---|
| 1MB | 2000+ | 单块失败影响极小 | 请求数量太多,服务端压力大,HTTP握手开销高 |
| 5MB | 约400 | 均衡 | 适合大多数场景 |
| 20MB | 约100 | 请求数少,服务端压力低 | 单块失败重传成本高,网络抖动时容易反复失败 |
综合来看,我建议普通内网或一般公网环境下选4MB到8MB之间。我们项目里用的是5MB,实测下来成功率比较稳定。如果你所在业务移动端场景多、网络质量不稳定,建议降到2MB,宁可多几次请求,也要减少单块失败的概率。
2.3 服务端如何“记住”已上传的分片
服务端维护上传状态的方案,常见有三种。
- 内存Map:简单但重启即丢,只适合演示。
- 数据库表:一个upload_task表加一个upload_chunk表,记录任务和分片状态,适合分布式部署。
- 本地文件系统:利用目录结构,比如每个任务一个目录,传完一个分片就写一个文件,文件名就是分片序号。
为了讲清楚原理,后文代码示例我用的是第三种,即基于本地文件目录来做状态记录,不依赖数据库,逻辑最直观。生产环境如果把任务信息表放到数据库中,再把分片文件放到OSS或MinIO上,换个存储层就可以了。
需要记录的状态字段大致有这些:任务ID、文件名、文件大小、分片总数、已上传分片数、每个分片的唯一标识、上传时间。
2.4 校验与合并:如何保证文件不损坏
分片上传阶段,每个分片都要做校验。我会在每片数据后面附带该分片的MD5值,服务端收完一片就计算收到的数据的MD5,不一致则返回错误码,客户端对该片重传。
全部传完后进入合并阶段,这也是容易出错的环节。合并时以分片序号为准,把每个临时分片文件按顺序写入最终文件。
合并完成后,我要做两件事:一是对比最终文件大小和声明的大小是否一致;二是用精确MD5比对前后端的哈希值。第二次校验面对的场景是:分片校验通过了、大小也对了,但服务端合并的读写逻辑有bug,导致内容错位。如果不做这一步,文件损坏可能要等用户下载打开时才暴露。
3. 从零写一版Spring Boot后端:分片接收、索引记录、合并恢复
3.1 目录与元数据设计
下面直接用Spring Boot写一个简化但可运行的版本。
先规划目录。在服务器上上传根目录下,每个任务创建一个目录,命名规则为upload/{taskId}。临时分片文件存放在该目录下的chunks子目录中,合并完成后放在upload/final/{taskId}.bin。
元数据我用一个简单的文本文件存放,命名为task.json,初始时由第一个请求写入。内容大致是文件原始名称、文件大小、分片大小、分片总数、当前已完成的块索引列表。
之所以不用数据库,是为了演示时降低复杂度。如果要在生产中用MySQL,表结构和这个JSON字段是一一对应的,迁移成本非常低。
3.2 上传接口:初始化任务与接收分片
先看初始化接口,前端会把文件基本信息传过来,后端检查是否已存在相同任务,存在则直接返回已有信息,否则创建任务目录并写入元数据。
java复制@RestController
@RequestMapping("/upload")
public class UploadController {
private final String rootPath = "/data/uploads/";
@PostMapping("/init")
public Map<String, Object> init(@RequestBody InitRequest req) throws IOException {
String taskId = DigestUtils.md5DigestAsHex(
(req.getFilename() + req.getSize() + req.getChunkSize()).getBytes());
Path taskDir = Paths.get(rootPath, taskId);
Map<String, Object> result = new HashMap<>();
if (Files.exists(taskDir)) {
result.put("exists", true);
result.put("taskId", taskId);
result.put("chunkIndexes", readFinishedChunks(taskId));
return result;
}
Files.createDirectories(taskDir.resolve("chunks"));
TaskMeta meta = new TaskMeta(req.getFilename(), req.getSize(),
req.getChunkSize(), req.getTotalChunks(), new TreeSet<>());
Files.write(taskDir.resolve("task.json"), JSON.toJSONString(meta).getBytes(StandardCharsets.UTF_8));
result.put("exists", false);
result.put("taskId", taskId);
result.put("chunkIndexes", Collections.emptyList());
return result;
}
}
这里有个很容易踩的坑:taskId如果直接用整个文件的MD5,在大文件场景下前端计算耗时太长,用户体验差。因此上面代码是用文件名字、大小、分片大小这些元数据生成的,它只能识别“同配置的重复上传”,不能真正判断内容重复。想要实现精确秒传,可以等待合并完成后用最终文件MD5再回写一个标志,这时才真正可靠。
再看分片接收接口。
java复制@PostMapping("/chunk")
public ResponseEntity<?> uploadChunk(@RequestParam("taskId") String taskId,
@RequestParam("index") int index,
@RequestParam(value = "chunkMd5", required = false) String chunkMd5,
@RequestParam("file") MultipartFile file) throws IOException {
Path taskDir = Paths.get(rootPath, taskId);
if (!Files.exists(taskDir)) {
return ResponseEntity.status(404).body("task not found");
}
Path chunkFile = taskDir.resolve("chunks").resolve(index + ".part");
// 如果文件已存在,说明之前已传过,直接返回成功
if (Files.exists(chunkFile) && chunkFile.length() == file.getSize()) {
return ResponseEntity.ok(Map.of("received", index));
}
file.transferTo(chunkFile);
if (chunkMd5 != null) {
String realMd5 = DigestUtils.md5DigestAsHex(Files.newInputStream(chunkFile));
if (!realMd5.equalsIgnoreCase(chunkMd5)) {
Files.deleteIfExists(chunkFile);
return ResponseEntity.status(400).body("chunk md5 mismatch");
}
}
updateTaskMeta(taskId, index);
return ResponseEntity.ok(Map.of("received", index));
}
这段代码里最能体现断点续传思路的是幂等处理:如果分片文件已经存在且大小匹配,就当作上传成功,直接返回。否则断点重传请求到了服务端,还没有检查就会重复写文件,某些特殊情况下还会产生半截文件。实际上很多生产环境上传失败就是这种“看似成功,实际文件损坏”的静默问题。
updateTaskMeta方法做的事情是读取task.json,把当前分片索引加入已完成列表,再写回去。
java复制private synchronized void updateTaskMeta(String taskId, int index) throws IOException {
Path taskFile = Paths.get(rootPath, taskId, "task.json");
TaskMeta meta = JSON.parseObject(Files.readString(taskFile), TaskMeta.class);
meta.getFinishedChunks().add(index);
Files.write(taskFile, JSON.toJSONString(meta).getBytes(StandardCharsets.UTF_8));
}
这里我加了一个synchronized,因为并发上传时多个分片请求会同时更新同一个task.json文件,不加锁的话后面的写操作会覆盖前面已经写入的分片索引,造成状态丢失,明明传完了却永远无法触发合并。下面的章节我会专门讲这个并发问题。
3.3 合并接口与恢复逻辑
合并接口的触发条件是客户端确定所有分片都已上传完成,后端在这个接口里做最终校验和合并。
java复制@PostMapping("/merge")
public ResponseEntity<?> merge(@RequestParam("taskId") String taskId) throws IOException {
Path taskDir = Paths.get(rootPath, taskId);
Path taskFile = taskDir.resolve("task.json");
if (!Files.exists(taskFile)) {
return ResponseEntity.status(404).body("task not found");
}
TaskMeta meta = JSON.parseObject(Files.readString(taskFile), TaskMeta.class);
if (meta.getFinishedChunks().size() < meta.getTotalChunks()) {
return ResponseEntity.status(400)
.body(Map.of("msg", "chunks not finished", "finished", meta.getFinishedChunks()));
}
Path finalFile = Paths.get(rootPath, "final", taskId + ".bin");
Files.createDirectories(finalFile.getParent());
try (FileOutputStream fos = new FileOutputStream(finalFile.toFile())) {
for (int i = 0; i < meta.getTotalChunks(); i++) {
Path chunk = taskDir.resolve("chunks").resolve(i + ".part");
Files.copy(chunk, fos);
}
}
// 清理临时分片和元数据
deleteRecursively(taskDir);
String finalMd5 = DigestUtils.md5DigestAsHex(Files.newInputStream(finalFile));
return ResponseEntity.ok(Map.of("path", finalFile.toString(), "md5", finalMd5));
}
这段代码有一个经验点:合并时千万不要用Files.move或者直接append,因为分片可能在传输时发生了乱序,必须严格按照索引号从小到大逐个读取;另外即使每个分片本身校验通过,合并时也可能因为某个分片物理文件损坏导致读取出错,所以合并过程要捕获IOException并给出明确提示,而不是让任务静默失败。合并完成后立刻删除临时目录,防止磁盘被残留文件占满。
3.4 前端配合要点
后端接口就位后,前端重点做三件事。
第一是生成文件唯一标识,在调用init接口前,按文件名、大小、分片大小生成taskId这侧的原始信息,也可以直接把taskId交给后端生成。
第二是并发控制。不要一次性把所有分片请求全部发出去,浏览器对同一域名有并发连接数限制,一般HTTP/1.1下是6个左右。超过之后请求会排队,反而拖慢速度。我通常同时发3个分片请求,单个分片失败就重试该片,重试次数不超过3次。
第三是记录本地进度。即使服务端完整记录了分片状态,前端做一个本地持久化(localStorage或者IndexedDB)仍然有价值,因为用户断线后重新打开页面,可以快速回显“当前已传80%”,而不必等init接口返回。
前端比较核心的上传逻辑大概是下面这样:
javascript复制async function uploadFile(file) {
const chunkSize = 5 * 1024 * 1024;
const totalChunks = Math.ceil(file.size / chunkSize);
const initResult = await fetch('/upload/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, size: file.size, chunkSize, totalChunks })
}).then(res => res.json());
const taskId = initResult.taskId;
const finishedSet = new Set(initResult.chunkIndexes);
let offset = 0;
const tasks = [];
for (let i = 0; i < totalChunks; i++) {
if (finishedSet.has(i)) {
offset += Math.min(chunkSize, file.size - offset);
continue;
}
const chunk = file.slice(offset, offset + Math.min(chunkSize, file.size - offset));
tasks.push(uploadChunk(taskId, i, chunk));
offset += chunk.size;
}
const concurrency = 3;
let cursor = 0;
async function worker() {
while (cursor < tasks.length) {
const current = cursor++;
await tasks[current];
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
await fetch(`/upload/merge?taskId=${taskId}`, { method: 'POST' });
}
代码里最值得注意的就是从initResult中拿到finishedChunks这一步,它是整个断点续传逻辑在前端呈现的关键。如果服务端已经有80%的分片了,前端就只传输剩下的20%,进度条直接从80%开始跳。用户感知到的就是“续传”的体验。
4. 并发与一致性的坑:多分片同时传丢数据、合并边界、超时重试
4.1 多分片乱序到达的合并问题
一个很常见的误区是认为分片是按顺序传输的,合并时直接按接收顺序拼接就行。但并发上传下,网络包抵达顺序和分片请求发起顺序没有必然关系,第5片可能比第3片先到。
所以存储每个分片时,文件名必须用分片的索引号命名,例如0.part、1.part,千万不要用时间戳或UUID来命名,否则合并时根本无法排序。这也是我在上面示例里坚持用index + ".part"作为分片文件名的原因。
合并时如果发现某个索引的分片缺失,直接返回错误,不要尝试用空数据填充,否则文件大小是对的,内容却是错位的。
4.2 并发更新元数据导致状态丢失
这是我踩过最隐蔽的坑。最初实现时,每个分片请求到了后端后,读取task.json、加入当前索引、写回,三个操作没有加锁。并发上来后,两个请求同时读到同一个数组,各自加入自己的索引,然后写回。后写的那个覆盖了先写的,已上传分片索引列表缩小了。从客户端看,明明传完了,merge接口却提示分片未完成。
修复方式是加锁。在单机场景下用synchronized即可,如果服务端是集群部署,就要把锁升级为分布式锁,或者干脆把已完成分片列表放到Redis里,用Redis的SADD来记录,天然支持并发去重。
还有一种做法是把任务状态存储到数据库,每个分片完成时执行一条INSERT语句,然后通过COUNT统计已完成数量。这比维护一个列表更符合数据库习惯,也不需要反复读改写整个JSON。
4.3 网络抖动与超时重试的幂等
断点续传方案里,分片上传接口必须设计为天然幂等。什么意思?同一个分片被客户端重试多次,服务端执行的结果应该等同于只执行一次。
实现上有两个关键保护。第一,接收分片前检查是否已存在同索引的分片文件且大小一致,存在就直接返回成功。第二,如果分片文件正在写入时客户端连接断开,服务端可能留下一个半截文件,这时只靠大小判断不靠谱,所以还要用分片MD5校验。
重试策略上,客户端的做法是每个分片最多重试3次,每次重试间隔按1秒、2秒、4秒递增。不要一失败就疯狂重发同一片,也不要在第1片失败时把整个任务终止。真正工作良好的系统,应该能容忍少数分片失败并自动修复。
4.4 磁盘临时目录的清理机制
分片上传场景下,服务端临时文件的生命周期管理经常被忽略。用户传了50个分片,最后10个没传,任务目录和50个临时文件就会永远存在。时间久了,磁盘被塞满,排查时又发现不了是哪个任务产生的。
我的处理策略是:在task.json里记录创建时间,启动一个定时任务,把超过24小时仍未合并的任务目录清理掉。如果用户确需长时间暂停再恢复,可以让客户端在恢复时重新走init接口,后端发现任务过期已被清理后自动重建任务即可。这个过程对用户透明,只要前端代码处理了任务不存在时重新初始化,就不会出问题。
清理操作要避开正在写入的分片文件,所以定时任务执行时可以先进入一个“清理窗口”,和文件写入操作共用同一个锁,防止一边删除一边写入的竞态问题。
5. 进阶优化:前端Worker、秒传、以及MinIO等对象存储的适配
5.1 Web Worker:把分片计算移出主线程
前端大文件分片本身也是性能消耗点。一个2GB文件按5MB分片,总共要执行400多次slice操作,如果主线程在计算时还要处理UI渲染、滚动事件、用户点击,页面会明显卡顿。
Web Worker是真正常用的解法。将文件对象直接传给Worker,Worker里做分片、计算MD5、发起上传请求,主线程只负责接收Worker回传的进度消息并更新页面进度条。
在Worker里做上传还有一个额外的好处:即使页面被切到后台,分片上传依然可以继续运行,因为Worker不受标签页可见性的限制。实测下来,用Worker上传时,前端掉帧情况明显减少,用户开其他窗口操作也不会被打断。
5.2 秒传:让重复上传变成一次HEAD或GET请求
秒传本质上和断点续传共享同一套元数据体系。初始化时,前端先计算文件的完整MD5,后端拿着这个MD5到存储层查询,如果文件已存在,直接关联引用,返回“上传成功”。
这里有个性能与精确性的协调问题。2GB文件计算全量MD5,在普通PC上可能要几十秒到几分钟,反而比上传还慢。折中方案是使用“秒传+秒验”机制:先用文件大小、头尾各1MB、中间间隔抽样几个块的哈希组合成一个粗略指纹。指纹匹配时再触发精确MD5后台计算,不匹配就直接进入分片上传流程。大多数真实的重复文件,粗略指纹已经能判断出来,用户不会有感知。
5.3 MinIO/OSS等对象存储的断点续传适配
MinIO到底支持不支持断点续传?很多团队会问这个问题。准确地说,MinIO本身提供了S3兼容的分片上传接口,但它是面向服务端SDK的,与浏览器端直接上传是两回事。
如果你把分片直接上传到MinIO,后端的逻辑就不再是“先把分片写到本地盘再合并”,而是调用MinIO的CompleteMultipartUpload接口,把多个分片对象合并成一个对象。合并动作由MinIO内部完成,你不需要自己写for循环拼接文件。
用Spring Boot集成MinIO时,核心逻辑是这样:
java复制// 初始化分片上传
CreateMultipartUploadResponse response = minioClient.createMultipartUpload(
CreateMultipartUploadArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
String uploadId = response.result().uploadId();
// 上传每个分片
UploadPartResponse partResponse = minioClient.uploadPart(
UploadPartArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.partNumber(i)
.stream(inputStream, partSize, -1)
.build());
// 完成合并
minioClient.completeMultipartUpload(
CompleteMultipartUploadArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.parts(parts)
.build());
这里有几个坑要注意。分片序号从1开始而不是从0开始,偏移一个序号就报错。每个分片上传完后会返回一个ETag,合并时必须按分片顺序把ETag列表传进去,顺序错了文件内容就是错乱的。另外如果上传中断,MinIO中未完成的UploadId不会被自动删除,需要定期调用ListMultipartUploads和AbortMultipartUpload清理。
阿里云OSS、腾讯云COS这些对象存储的机制基本一致,只是SDK方法名不同。理解了MinIO这套逻辑,换到其他云厂商基本没有学习成本。
5.4 从断点续传向传输中台演进的思路
做到这里,断点续传已经是一个完整可用的功能了。如果把它放到更大的产品视角里,还能继续演进。
一个方向是全局传输任务中心。不只服务网页端大文件,后台管理端、移动端、桌面客户端都复用同一套分片上传接口。任务ID跨端可见,用户在手机上传了30%,回到电脑上可以继续传剩下的70%,前提是两端的任务元数据和分片文件都存储在同一套后端服务中。
另一个方向是动态分片大小。根据当前网络测速结果自动判断分片大小,网速好的时候用8MB分片,网速差的时候降到2MB甚至512KB。这个思路并不复杂,前端先传一个小探针文件测量往返时间,再估算可用带宽,就能动态确定策略。
还有一个值得做的事是上传进度的时间预测。不要只显示百分比,用户真正关心的是“还要等多久”。服务端可以在任务元数据里记录每片上传耗时,前端拉取最近若干片的平均速度,除以剩余字节数,就能估算剩余时间。这个功能对提升用户体验的效果非常明显。
做传输功能最有成就感的地方,不是代码写得多优雅,而是用户再也不会因为网络波动白等一个小时。断点续传这个技术方案看起来朴素,但从分片策略、状态记录、并发控制到存储适配,每一层都藏着细节,每一层都需要真正面对过生产环境的人才能讲明白。希望这篇文章能让你少走一些我走过的弯路。
