大型文件上传实现方案:分块 + 断点续传 + 优化策略
大型文件上传(通常指 100MB 以上,如视频、安装包、备份文件)的核心痛点是传输耗时久、网络易中断、服务器压力大,解决方案的核心是 “分块上传 + 断点续传” ,再结合并行传输、进度反馈、校验优化等机制,兼顾稳定性、效率和可靠性。以下是完整实现方案(含核心逻辑、代码示例、优化策略)。
一、核心设计思路
大型文件上传的本质是 “化整为零”:将超大文件拆分成多个小分块,逐个传输后在服务器端合并,同时记录传输进度,支持中断后恢复。关键设计点:
- 分块拆分:按固定大小拆分文件(如 4MB / 块),避免单块过大导致超时,过小导致请求过多;
- 唯一标识:用文件 MD5/SHA-1 或 UUID 作为文件唯一 ID,确保客户端与服务器端操作的是同一文件;
- 断点续传:客户端记录已上传分块,服务器端存储接收的分块,中断后仅传输未完成部分;
- 并行传输:多线程 / 多请求同时上传不同分块,提升传输效率;
- 完整性校验:分块级校验(避免单块损坏)+ 合并后文件校验(确保完整);
- 服务器优化:分块临时存储、合并异步化、限流防刷,降低服务器压力。
二、完整实现流程(客户端 + 服务器端)
整体流程示意图
客户端 服务器端
| |
1. 计算文件MD5 + 拆分分块 1. 接收文件MD5,创建临时分块目录
| |
2. 查询已上传分块(断点请求) 2. 返回已接收分块列表
| |
3. 并行上传未完成分块 3. 接收分块,校验后存储(按分块索引命名)
| |
4. 所有分块上传完成 → 发起合并请求 4. 合并分块为完整文件,删除临时分块
| |
5. 校验合并后文件MD5 5. 返回合并后文件MD5
| |
6. 上传成功/重试失败分块 6. 清理临时数据,返回结果
三、客户端实现(以 Web 前端为例,Vue/JS)
前端是用户交互入口,需处理文件拆分、并行上传、进度反馈、断点记录、异常重试。以下是基于 axios 的核心实现(支持浏览器环境)。
1. 核心依赖与配置
// 依赖:axios(HTTP请求)、spark-md5(计算文件MD5,高效处理大文件)
// 安装:npm install axios spark-md5
import axios from 'axios';
import SparkMD5 from 'spark-md5';
// 配置
const config = {
chunkSize: 4 * 1024 * 1024, // 分块大小:4MB(可根据网络调整)
parallelCount: 3, // 并行上传线程数(避免过多请求压垮服务器)
serverBaseUrl: 'http://localhost:8080/api/upload', // 服务器接口地址
retryCount: 3, // 分块上传失败重试次数
};
2. 核心功能实现
(1)计算文件唯一标识(MD5)
大文件直接计算 MD5 会阻塞主线程,需用分片读取 + Web Worker 异步计算(避免页面卡顿)。
// 异步计算文件MD5(Web Worker方式,避免阻塞主线程)
function calculateFileMd5(file) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
const chunkSize = config.chunkSize;
let currentOffset = 0; // 当前读取偏移量
// 读取下一个分片
function loadNextChunk() {
const blobSlice = file.slice(currentOffset, currentOffset + chunkSize);
fileReader.readAsArrayBuffer(blobSlice);
}
// 读取完成回调
fileReader.onload = (e) => {
spark.append(e.target.result); // 累加分片数据
currentOffset += chunkSize;
if (currentOffset < file.size) {
loadNextChunk(); // 继续读取下一分片
} else {
const md5 = spark.end(); // 计算最终MD5
resolve(md5);
}
};
// 读取失败回调
fileReader.onerror = (err) => {
reject(`MD5计算失败:${err.message}`);
};
// 开始读取第一个分片
loadNextChunk();
});
}
(2)拆分文件为分块
根据配置的分块大小,将文件拆分为多个 Blob 对象,记录每个分块的索引、大小等信息。
// 拆分文件为分块
function splitFileIntoChunks(file, fileMd5) {
const chunks = [];
const totalChunks = Math.ceil(file.size / config.chunkSize);
for (let i = 0; i < totalChunks; i++) {
const start = i * config.chunkSize;
const end = Math.min(start + config.chunkSize, file.size);
const chunk = file.slice(start, end); // 截取分块Blob
chunks.push({
fileMd5, // 文件唯一标识
chunkIndex: i, // 分块索引(从0开始)
totalChunks, // 总分块数
chunkSize: end - start, // 当前分块大小
blob: chunk, // 分块数据
});
}
return chunks;
}
(3)查询已上传分块(断点续传核心)
上传前向服务器查询已接收的分块,避免重复上传。
// 查询服务器已上传的分块索引
async function queryUploadedChunks(fileMd5) {
try {
const response = await axios.get(`${config.serverBaseUrl}/check`, {
params: { fileMd5 },
});
return response.data.uploadedChunkIndexes || []; // 服务器返回已上传的分块索引数组
} catch (err) {
console.error('查询断点失败,默认重新上传:', err);
return [];
}
}
(4)并行上传分块(带重试机制)
用队列控制并行线程数,失败分块自动重试(最多retryCount次)。
// 并行上传分块
async function uploadChunks(chunks, uploadedChunkIndexes, onProgress) {
// 过滤掉已上传的分块
const needUploadChunks = chunks.filter(
(chunk) => !uploadedChunkIndexes.includes(chunk.chunkIndex)
);
if (needUploadChunks.length === 0) {
onProgress(100); // 已全部上传完成
return true;
}
let completedCount = 0; // 已完成的分块数
const totalNeedUpload = needUploadChunks.length;
// 并行上传队列(控制并发数)
const uploadQueue = async () => {
while (needUploadChunks.length > 0) {
const chunk = needUploadChunks.shift(); // 取出队列头部分块
await uploadSingleChunk(chunk, onProgress, () => {
completedCount++;
// 计算整体进度(已上传分块数 / 总分块数)
const progress = Math.round(
((uploadedChunkIndexes.length + completedCount) / chunks.length) * 100
);
onProgress(progress);
});
}
};
// 启动parallelCount个并行线程
const threads = Array(config.parallelCount).fill().map(uploadQueue);
await Promise.all(threads); // 等待所有线程完成
return true;
}
// 上传单个分块(带重试)
async function uploadSingleChunk(chunk, onProgress, onComplete) {
const formData = new FormData();
formData.append('fileMd5', chunk.fileMd5);
formData.append('chunkIndex', chunk.chunkIndex);
formData.append('totalChunks', chunk.totalChunks);
formData.append('chunk', chunk.blob); // 分块数据
let retry = 0;
while (retry < config.retryCount) {
try {
await axios.post(`${config.serverBaseUrl}/chunk`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
// 单个分块上传进度(可选,用于细粒度反馈)
onUploadProgress: (e) => {
const chunkProgress = e.loaded / e.total;
// 可结合整体进度计算,此处简化
},
});
onComplete(); // 分块上传完成回调
return;
} catch (err) {
retry++;
if (retry >= config.retryCount) {
throw new Error(`分块${chunk.chunkIndex}上传失败(重试${config.retryCount}次)`);
}
console.warn(`分块${chunk.chunkIndex}上传失败,正在重试(${retry}/${config.retryCount})`);
// 重试前可添加延迟(如1秒),避免频繁重试
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
(5)发起文件合并请求
所有分块上传完成后,通知服务器合并分块为完整文件。
// 通知服务器合并分块
async function mergeChunks(fileMd5, fileName) {
try {
const response = await axios.post(`${config.serverBaseUrl}/merge`, {
fileMd5,
fileName,
});
return response.data; // 服务器返回合并后的文件信息(如MD5、存储路径)
} catch (err) {
throw new Error(`文件合并失败:${err.message}`);
}
}
(6)整合上传流程(对外暴露 API)
// 大型文件上传主函数
export async function uploadLargeFile(file, onProgress, onSuccess, onError) {
try {
// 1. 计算文件MD5(唯一标识)
onProgress(0, '正在计算文件标识...');
const fileMd5 = await calculateFileMd5(file);
// 2. 拆分文件为分块
onProgress(0, '正在拆分文件...');
const chunks = splitFileIntoChunks(file, fileMd5);
// 3. 查询已上传分块(断点续传)
onProgress(0, '正在查询上传进度...');
const uploadedChunkIndexes = await queryUploadedChunks(fileMd5);
// 4. 并行上传未完成分块
onProgress(0, '开始上传文件...');
await uploadChunks(chunks, uploadedChunkIndexes, (progress) => {
onProgress(progress, `上传中(${progress}%)`);
});
// 5. 通知服务器合并分块
onProgress(100, '正在合并文件...');
const result = await mergeChunks(fileMd5, file.name);
// 6. 校验文件完整性(可选,客户端二次校验)
// const localMd5 = await calculateFileMd5(file);
// if (localMd5 !== result.fileMd5) throw new Error('文件校验失败');
// 7. 上传成功
onSuccess(result);
} catch (err) {
onError(err.message);
}
}
(7)前端调用示例(Vue 组件)
<template>
<div>
<input type="file" @change="handleFileSelect" accept=".zip,.mp4,.iso" />
<div>进度:{{ progress }}%</div>
<div>状态:{{ status }}</div>
<button @click="resumeUpload" v-if="canResume">继续上传</button>
</div>
</template>
<script>
import { uploadLargeFile } from './uploadUtils';
export default {
data() {
return {
progress: 0,
status: '未选择文件',
selectedFile: null,
canResume: false,
};
},
methods: {
handleFileSelect(e) {
const file = e.target.files[0];
if (!file) return;
this.selectedFile = file;
this.resumeUpload(); // 选择文件后直接开始上传
},
resumeUpload() {
if (!this.selectedFile) return;
this.status = '上传中...';
this.canResume = false;
uploadLargeFile(
this.selectedFile,
(progress, msg) => {
this.progress = progress;
this.status = msg;
this.canResume = progress < 100; // 未完成时可继续上传
},
(result) => {
this.status = `上传成功!文件ID:${result.fileId}`;
this.canResume = false;
},
(err) => {
this.status = `上传失败:${err}`;
this.canResume = true; // 失败后可重试
}
);
},
},
};
</script>
四、服务器端实现(以 Spring Boot 为例)
服务器端需处理分块接收、临时存储、断点查询、分块合并、校验清理,核心是保证分块存储可靠、合并高效、接口兼容前端请求。
1. 核心配置
@Configuration
public class UploadConfig {
// 分块临时存储路径(建议用独立磁盘或云存储,如OSS)
public static final String CHUNK_TEMP_PATH = "/data/upload/chunks/";
// 最终文件存储路径
public static final String FINAL_FILE_PATH = "/data/upload/files/";
// 最大请求大小(分块4MB,所以设置10MB足够)
public static final long MAX_REQUEST_SIZE = 10 * 1024 * 1024;
// 配置Spring Boot文件上传限制
@Bean
public MultipartConfigElement multipartConfigElement() {
MultipartConfigFactory factory = new MultipartConfigFactory();
factory.setMaxFileSize(DataSize.ofBytes(MAX_REQUEST_SIZE));
factory.setMaxRequestSize(DataSize.ofBytes(MAX_REQUEST_SIZE));
return factory.createMultipartConfig();
}
// 初始化存储目录
@PostConstruct
public void initDir() {
File chunkDir = new File(CHUNK_TEMP_PATH);
File finalDir = new File(FINAL_FILE_PATH);
if (!chunkDir.exists()) chunkDir.mkdirs();
if (!finalDir.exists()) finalDir.mkdirs();
}
}
2. 核心接口实现
(1)断点查询接口(Check 接口)
@RestController
@RequestMapping("/api/upload")
public class LargeFileUploadController {
// 查询已上传的分块索引
@GetMapping("/check")
public ResponseEntity<Map<String, Object>> checkUpload(
@RequestParam String fileMd5) {
// 1. 检查文件是否已合并完成(避免重复上传)
File finalFile = new File(UploadConfig.FINAL_FILE_PATH + fileMd5);
if (finalFile.exists()) {
return ResponseEntity.ok(Map.of(
"uploaded", true,
"message", "文件已存在"
));
}
// 2. 检查临时分块目录,获取已上传的分块索引
File chunkDir = new File(UploadConfig.CHUNK_TEMP_PATH + fileMd5);
List<Integer> uploadedIndexes = new ArrayList<>();
if (chunkDir.exists()) {
File[] chunkFiles = chunkDir.listFiles((dir, name) -> name.startsWith("chunk_"));
if (chunkFiles != null) {
for (File chunk : chunkFiles) {
// 分块文件名格式:chunk_0、chunk_1...
String indexStr = chunk.getName().split("_")[1];
uploadedIndexes.add(Integer.parseInt(indexStr));
}
}
}
// 3. 返回已上传分块索引
return ResponseEntity.ok(Map.of(
"uploaded", false,
"uploadedChunkIndexes", uploadedIndexes
));
}
(2)分块接收接口(Chunk 接口)
// 接收分块并存储
@PostMapping("/chunk")
public ResponseEntity<String> uploadChunk(
@RequestParam String fileMd5,
@RequestParam int chunkIndex,
@RequestParam int totalChunks,
@RequestParam MultipartFile chunk) {
try {
// 1. 校验分块数据
if (chunk.isEmpty()) {
return ResponseEntity.badRequest().body("分块数据为空");
}
// 2. 创建当前文件的分块存储目录(按fileMd5隔离,避免冲突)
File chunkDir = new File(UploadConfig.CHUNK_TEMP_PATH + fileMd5);
if (!chunkDir.exists()) {
chunkDir.mkdirs();
}
// 3. 存储分块(文件名:chunk_索引,如chunk_0)
File chunkFile = new File(chunkDir, "chunk_" + chunkIndex);
chunk.transferTo(chunkFile); // 保存分块到磁盘
return ResponseEntity.ok("分块" + chunkIndex + "上传成功");
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("分块" + chunkIndex + "上传失败:" + e.getMessage());
}
}
(3)分块合并接口(Merge 接口)
// 合并分块为完整文件
@PostMapping("/merge")
public ResponseEntity<Map<String, Object>> mergeChunks(
@RequestBody Map<String, String> params) {
String fileMd5 = params.get("fileMd5");
String fileName = params.get("fileName");
try {
// 1. 校验分块目录是否存在
File chunkDir = new File(UploadConfig.CHUNK_TEMP_PATH + fileMd5);
if (!chunkDir.exists()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "分块目录不存在"
));
}
// 2. 获取所有分块文件,并按索引排序
File[] chunkFiles = chunkDir.listFiles((dir, name) -> name.startsWith("chunk_"));
if (chunkFiles == null || chunkFiles.length == 0) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "无分块数据"
));
}
// 按分块索引排序(避免合并顺序错乱)
Arrays.sort(chunkFiles, (a, b) -> {
int indexA = Integer.parseInt(a.getName().split("_")[1]);
int indexB = Integer.parseInt(b.getName().split("_")[1]);
return indexA - indexB;
});
// 3. 合并分块到最终文件(文件名:fileMd5_原始文件名,避免重复)
String finalFileName = fileMd5 + "_" + fileName;
File finalFile = new File(UploadConfig.FINAL_FILE_PATH + finalFileName);
try (FileOutputStream out = new FileOutputStream(finalFile)) {
for (File chunk : chunkFiles) {
try (FileInputStream in = new FileInputStream(chunk)) {
byte[] buffer = new byte[1024 * 1024]; // 1MB缓冲区
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
chunk.delete(); // 合并后删除单个分块(节省空间)
}
}
// 4. 删除分块目录
chunkDir.delete();
// 5. 计算合并后文件的MD5(供客户端校验)
String mergedFileMd5 = calculateFileMd5(finalFile);
// 6. 返回结果(文件ID、存储路径、MD5等)
return ResponseEntity.ok(Map.of(
"success", true,
"fileId", fileMd5,
"fileName", finalFileName,
"fileMd5", mergedFileMd5,
"filePath", finalFile.getAbsolutePath()
));
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of(
"success", false,
"message", "文件合并失败:" + e.getMessage()
));
}
}
// 辅助方法:计算文件MD5(与客户端一致)
private String calculateFileMd5(File file) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
try (FileInputStream in = new FileInputStream(file)) {
byte[] buffer = new byte[1024 * 1024];
int len;
while ((len = in.read(buffer)) != -1) {
md.update(buffer, 0, len);
}
}
byte[] digest = md.digest();
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
五、关键优化策略(生产环境必看)
1. 分块大小优化
- 推荐值:4MB~16MB(根据网络环境调整);
- 弱网络(如移动端):减小分块(2MB),降低重试成本;
- 高速网络(如企业内网):增大分块(8MB~16MB),减少请求次数。
2. 并行传输控制
- 前端并行线程数:3~5 个(过多会导致服务器连接池耗尽,过少影响效率);
- 服务器端:通过 Nginx/Apache 限制单个 IP 的并发请求数(如 10 个),避免恶意压测。
3. 存储优化
- 临时分块存储:用高速磁盘(如 SSD)或分布式存储(如 MinIO、OSS),避免磁盘 IO 瓶颈;
- 过期清理:定时删除超过 24 小时的未合并分块(避免磁盘空间浪费),可通过定时任务(如 Spring 的
@Scheduled)实现。
4. 断点记录持久化
- 客户端:用
localStorage/IndexedDB存储断点信息(文件 MD5、已上传分块索引),避免页面刷新后丢失; - 服务器端:若分块存储在分布式系统,需记录分块的存储节点信息,避免节点故障导致分块丢失。
5. 安全性优化
- 身份认证:给上传接口添加 Token 校验(如 JWT),避免匿名上传;
- 权限控制:限制用户可上传的文件类型、大小(如仅允许.mp4/.zip,单个文件最大 10GB);
- 防重复上传:服务器端先校验文件 MD5,已存在则直接返回成功,避免重复传输;
- 分块校验:接收分块时,可要求客户端传递分块的 MD5,服务器端校验(避免分块损坏)。
6. 性能优化
- 服务器端合并异步化:大文件合并耗时较长(如 1GB 文件合并需几秒),可将合并操作放入线程池异步执行,前端通过轮询查询合并状态;
- 压缩传输:对文本类大文件(如日志、文档),前端用 Gzip 压缩分块后上传,服务器端解压,减少传输带宽。
7. 跨域支持
- 服务器端添加 CORS 配置(允许前端域名的
GET/POST请求),避免跨域报错:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/upload/**")
.allowedOrigins("http://localhost:8081") // 前端域名
.allowedMethods("GET", "POST")
.allowedHeaders("*")
.maxAge(3600);
}
}
六、适用场景与扩展
适用场景
- 视频 / 音频上传(如短视频平台、直播回放);
- 大型安装包 / 镜像文件上传(如软件官网、云服务器镜像);
- 备份文件上传(如企业数据备份、个人云盘)。
扩展功能
- 秒传:服务器端提前存储了文件 MD5,直接返回成功(无需传输);
- 分片预签名:若使用云存储(如 AWS S3、阿里云 OSS),可通过预签名 URL 让前端直接向云存储上传分块,服务器端仅负责合并通知,降低服务器压力;
- 进度条细粒度反馈:前端显示每个分块的上传状态(成功 / 失败 / 重试),提升用户体验。
总结
大型文件上传的核心是 “分块 + 断点续传”,前端负责拆分、并行上传、进度反馈,服务器端负责接收、存储、合并。生产环境中,需结合存储优化、安全性、性能优化等策略,确保上传稳定、高效、安全。以上方案可直接落地,也可根据实际需求(如分布式部署、云存储集成)进行扩展。