为什么需要分片上传?

对于后端来说,如果文件上传的时候采用一整个上传的方式,那么后端需要在内存中留出很大一部分空间来进行一个中转。如果文件过大,那么可能会造成内存空间的拥挤,频繁的IO造成服务器性能的下降,在web这个多并发的情况下,性能的下降会导致响应时间的增加,严重甚至会导致服务的崩溃。所以我们要对大文件的上传进行专门的优化。

同时,过大的文件传输也会长时间的占用网络信道,会导致网络的拥塞,其余的服务响应时间也会增加。

总结来说,传统整文件上传面临三个核心问题:

  1. 内存压力 — 服务端需要一次性将整个文件加载到内存中,上百MB甚至GB级的文件很容易导致OOM
  2. 网络不稳定 — 传输过程中一旦网络中断,整个文件需要重新上传,用户体验极差
  3. 上传时间过长 — 单连接串行传输,无法充分利用带宽,用户等待时间长

整体方案:文件分片

核心思路是:将大文件切分成多个小片段,逐片上传,最后在服务端合并还原

我们和前端约定,在上传文件的时候,将文件切片处理,传输的时候附带有文件的唯一标识,以及文件切片数,切片标识。

在后端时,我们创建临时文件夹来存储临时切片,在前端传入的标识和切片总数达标的时候,开始在后端合并文件成整个文件,并删除临时文件夹。

整体流程

sequenceDiagram
    participant 前端
    participant 后端
    前端->>前端: 1. 选择文件
    前端->>前端: 2. 计算文件MD5(唯一标识)
    前端->>前端: 3. 将文件切片(Blob.slice)
    前端->>后端: 4. 检查文件是否已存在(秒传)
    后端-->>前端: 返回:已存在 / 未存在 / 部分存在
    前端->>前端: 5. 根据返回跳过已上传的分片
    loop 逐片上传
        前端->>后端: 上传分片(携带hash、index、total)
        后端->>后端: 接收分片,校验MD5,存入临时目录
        后端-->>前端: 分片上传结果
    end
    前端->>后端: 6. 请求合并文件
    后端->>后端: 7. 检查分片完整性,按顺序合并
    后端->>后端: 8. 删除临时分片
    后端-->>前端: 返回:上传成功,文件URL

前端实现

文件切片

利用 Blob.prototype.slice 方法,可以将一个大文件按指定大小切成多个小块:

/**
* 将文件按指定大小切片
* @param {File} file - 要切片的文件
* @param {number} chunkSize - 每片大小(字节),默认 5MB
* @returns {Array<{file: Blob, index: number}>} 切片数组
*/
function createChunks(file, chunkSize = 5 * 1024 * 1024) {
const chunks = [];
let cur = 0;
let index = 0;
while (cur < file.size) {
chunks.push({
file: file.slice(cur, cur + chunkSize),
index: index++,
});
cur += chunkSize;
}
return chunks;
}

计算文件Hash(唯一标识)

使用 spark-md5 库对文件内容进行MD5计算。为了避免阻塞主线程,通常放在 Web Worker 中执行:

// worker.js — 在Web Worker中计算文件MD5
self.importScripts('spark-md5.min.js');

self.onmessage = function (e) {
const { chunks } = e.data;
const spark = new self.SparkMD5.ArrayBuffer();
let count = 0;

function loadNext() {
const reader = new FileReader();
reader.onload = function (event) {
spark.append(event.target.result);
count++;
// 上报进度
self.postMessage({ type: 'progress', progress: Math.round((count / chunks.length) * 100) });
if (count < chunks.length) {
loadNext();
} else {
self.postMessage({ type: 'done', hash: spark.end() });
}
};
reader.readAsArrayBuffer(chunks[count].file);
}

loadNext();
};

主线程调用:

function calculateHash(chunks) {
return new Promise((resolve) => {
const worker = new Worker('/worker.js');
worker.postMessage({ chunks });
worker.onmessage = (e) => {
if (e.data.type === 'done') {
resolve(e.data.hash);
worker.terminate();
}
};
});
}

为什么用 Web Worker?
对于GB级文件,MD5计算可能需要数秒甚至数十秒。在主线程中执行会导致页面卡死,用户无法操作。放在 Worker 中可以保持页面响应。

上传分片

使用 FormData 将每个分片连同元信息一起发送:

async function uploadChunks(chunks, fileHash, fileName) {
const requests = chunks.map((chunk, index) => {
const formData = new FormData();
formData.append('chunk', chunk.file); // 分片数据
formData.append('hash', fileHash); // 文件整体hash,作为唯一标识
formData.append('chunkHash', `${fileHash}-${index}`); // 分片hash
formData.append('index', index); // 分片序号
formData.append('total', chunks.length); // 总分片数
formData.append('fileName', fileName); // 原始文件名
return formData;
});

// 控制并发数,避免同时发起过多请求
await concurrentUpload(requests, 3);
}

/**
* 并发控制上传
* @param {Array<FormData>} requests - 所有分片请求
* @param {number} max - 最大并发数
*/
async function concurrentUpload(requests, max = 3) {
const pool = []; // 当前并发池
const results = [];

for (const formData of requests) {
const task = fetch('/api/upload/chunk', {
method: 'POST',
body: formData,
}).then((res) => {
// 任务完成,从池中移除
pool.splice(pool.indexOf(task), 1);
return res.json();
});

pool.push(task);
results.push(task);

// 当并发池满时,等待其中一个完成
if (pool.length >= max) {
await Promise.race(pool);
}
}

return Promise.all(results);
}

为什么要控制并发数?
浏览器对同一域名的并发连接数有限制(Chrome一般是6个)。如果一次性发出几百个请求,反而会造成请求排队,甚至触发浏览器或服务器的限流策略。通常并发数设为 3~6 比较合理。

请求合并

所有分片上传完成后,通知后端进行合并:

async function mergeRequest(fileHash, fileName, chunkTotal) {
const response = await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hash: fileHash,
fileName: fileName,
total: chunkTotal,
}),
});
return response.json();
}

后端实现(以Java/Spring Boot为例)

接收分片

@PostMapping("/upload/chunk")
public Result uploadChunk(
@RequestParam("chunk") MultipartFile chunk,
@RequestParam("hash") String fileHash,
@RequestParam("chunkHash") String chunkHash,
@RequestParam("index") Integer index,
@RequestParam("total") Integer total,
@RequestParam("fileName") String fileName) {

// 1. 创建临时目录:以文件hash命名,确保唯一
String tempDir = uploadPath + File.separator + fileHash;
File dir = new File(tempDir);
if (!dir.exists()) {
dir.mkdirs();
}

// 2. 将分片写入临时目录,文件名为分片序号
File chunkFile = new File(tempDir + File.separator + index);
chunk.transferTo(chunkFile);

// 3.(可选)校验分片MD5
String actualMd5 = DigestUtils.md5DigestAsHex(new FileInputStream(chunkFile));
if (!actualMd5.equals(chunkHash)) {
chunkFile.delete();
return Result.fail("分片校验失败,请重新上传");
}

return Result.success("分片上传成功");
}

合并分片

@PostMapping("/upload/merge")
public Result mergeChunks(@RequestBody MergeRequest request) {
String tempDir = uploadPath + File.separator + request.getHash();
File dir = new File(tempDir);

// 1. 检查分片完整性
File[] chunks = dir.listFiles();
if (chunks == null || chunks.length != request.getTotal()) {
return Result.fail("分片数量不完整");
}

// 2. 按序号排序(文件名就是序号)
Arrays.sort(chunks, Comparator.comparingInt(f -> Integer.parseInt(f.getName())));

// 3. 合并写入目标文件
String suffix = request.getFileName().substring(request.getFileName().lastIndexOf("."));
File targetFile = new File(uploadPath + File.separator + request.getHash() + suffix);

try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(targetFile))) {
for (File chunk : chunks) {
byte[] bytes = Files.readAllBytes(chunk.toPath());
bos.write(bytes);
}
bos.flush();
}

// 4. 清理临时目录
for (File chunk : chunks) {
chunk.delete();
}
dir.delete();

return Result.success("合并成功", targetFile.getName());
}

确定切片大小

切片大小的选择需要 平衡多个因素

切片大小 优点 缺点
过小(<1MB) 单片失败重传代价小 HTTP请求数量多,频繁IO,服务器压力大
过大(>20MB) 请求数少,效率高 分片优势不明显,单片传输失败代价大
推荐 2MB~10MB 平衡了请求数和传输效率

一种动态策略是根据文件大小自适应:

function getChunkSize(fileSize) {
if (fileSize < 100 * 1024 * 1024) { // < 100MB
return 2 * 1024 * 1024; // 2MB
} else if (fileSize < 1024 * 1024 * 1024) { // < 1GB
return 5 * 1024 * 1024; // 5MB
} else {
return 10 * 1024 * 1024; // 10MB
}
}

进阶优化

确保文件的完整性

问题:网络传输过程中,分片数据可能因为网络抖动、丢包等原因发生损坏。

方案:前后端约定使用相同的加密算法,对分片的文件计算一个MD5值,前端传输增加MD5值,后端拿到文件之后也进行相同的计算,然后对比是否一致。

flowchart LR
    A[前端:chunk数据] --> B[计算MD5]
    B --> C[和chunk一起发送]
    C --> D[后端:接收chunk]
    D --> E[计算MD5]
    E --> F{MD5对比}
    F -->|一致| G[保存分片]
    F -->|不一致| H[丢弃,通知前端重传]

前端计算单个分片MD5:

async function calculateChunkMd5(chunkBlob) {
const buffer = await chunkBlob.arrayBuffer();
const spark = new SparkMD5.ArrayBuffer();
spark.append(buffer);
return spark.end();
}

断点续传

场景:用户上传一个2GB的文件,传到60%时网络断了。如果没有断点续传,下次需要从头开始,体验非常差。

原理:上传前先向后端询问”这个文件已经上传了哪些分片”,跳过已上传的部分,只传剩余分片。

前端实现

async function uploadWithResume(file) {
const chunks = createChunks(file);
const fileHash = await calculateHash(chunks);

// 1. 向后端查询已上传的分片列表
const { uploadedChunks, fileExists } = await checkFileStatus(fileHash);

// 2. 如果文件已存在(秒传),直接返回
if (fileExists) {
console.log('文件已存在,秒传成功!');
return;
}

// 3. 过滤掉已上传的分片
const chunksToUpload = chunks.filter(
(_, index) => !uploadedChunks.includes(index)
);

console.log(`总共${chunks.length}片,已上传${uploadedChunks.length}片,还需上传${chunksToUpload.length}片`);

// 4. 上传剩余分片
await uploadChunks(chunksToUpload, fileHash, file.name);

// 5. 请求合并
await mergeRequest(fileHash, file.name, chunks.length);
}

async function checkFileStatus(fileHash) {
const res = await fetch(`/api/upload/check?hash=${fileHash}`);
return res.json();
}

后端实现

@GetMapping("/upload/check")
public Result checkFile(@RequestParam("hash") String fileHash) {
Map<String, Object> result = new HashMap<>();

// 1. 检查最终文件是否已存在(秒传判断)
File targetFile = findFileByHash(fileHash);
if (targetFile != null && targetFile.exists()) {
result.put("fileExists", true);
result.put("uploadedChunks", Collections.emptyList());
return Result.success(result);
}

// 2. 检查临时目录中已有哪些分片
File tempDir = new File(uploadPath + File.separator + fileHash);
List<Integer> uploadedChunks = new ArrayList<>();
if (tempDir.exists()) {
for (File chunk : tempDir.listFiles()) {
uploadedChunks.add(Integer.parseInt(chunk.getName()));
}
}

result.put("fileExists", false);
result.put("uploadedChunks", uploadedChunks);
return Result.success(result);
}

暂停与恢复

前端可以通过 AbortController 实现手动暂停:

let controller = null;

function pauseUpload() {
if (controller) {
controller.abort(); // 中止所有正在进行的请求
}
}

function resumeUpload() {
// 重新调用 uploadWithResume,它会自动跳过已上传的分片
uploadWithResume(currentFile);
}

// 在上传函数中使用 AbortController
async function uploadChunkWithAbort(formData) {
controller = new AbortController();
return fetch('/api/upload/chunk', {
method: 'POST',
body: formData,
signal: controller.signal,
});
}

秒传

场景:用户上传了一个视频文件,另一个用户再次上传同一个文件时,服务端已经有了,就没有必要真正传输。

原理:在上传之前,先计算整个文件的MD5值,发送给后端检查是否已存在。如果存在就直接返回成功,一个字节都不用传

flowchart TD
    A[用户选择文件] --> B[计算文件整体MD5]
    B --> C[发送MD5给后端查询]
    C --> D{文件是否已存在?}
    D -->|已存在| E[直接返回URL(秒传完成)]
    D -->|不存在| F[走正常分片上传流程]
    E --> G[展示结果]
    F --> G
@GetMapping("/upload/check")
public Result checkFile(@RequestParam("hash") String fileHash) {
// 在数据库或文件系统中查找是否存在该hash对应的文件
FileRecord record = fileService.findByHash(fileHash);
if (record != null) {
// 秒传:文件已存在,直接返回访问地址
return Result.success("秒传成功", record.getUrl());
}
// 文件不存在,需要正常上传
return Result.success("文件不存在,请上传");
}

注意:MD5存在极低概率的碰撞(不同文件产生相同的MD5值)。对于安全性要求高的场景,可以:

  • 使用 SHA-256 替代 MD5,碰撞概率更低
  • 在MD5相同的基础上,再比较文件大小
  • 双Hash校验:同时比较 MD5 + CRC32