大文件分片上传 + 断点续传完整实现:2MB 分片、MD5 秒传、合并校验
文件分片上传与断点续传实现原理
引言
在企业级应用中,大文件上传是常见需求——视频、安装包、数据备份等文件动辄数百MB甚至数GB。传统的整文件上传方案面临诸多问题:网络中断导致重新上传、服务器内存压力大、无法显示进度、超时失败等。
分片上传将大文件切割为多个小片段分别上传,结合断点续传能力,有效解决了上述问题。本文将详细讲解分片上传的完整方案,包括2MB分片策略、断点续传查询、合并校验等核心机制。
一、整体方案设计
1.1 核心流程
flowchart TB
A[选择文件] --> B[前端计算文件MD5]
B --> C[请求创建上传任务]
C --> D{服务端检查}
D -->|文件已存在| E[秒传:直接返回文件地址]
D -->|部分分片已上传| F[返回已上传分片列表]
D -->|首次上传| G[返回空列表]
E --> Z[上传完成]
F --> H[断点续传:跳过已上传分片]
G --> I[从头开始上传]
H --> J[并发上传未完成分片]
I --> J
J --> K{所有分片上传完毕?}
K -->|否| J
K -->|是| L[请求合并分片]
L --> M[服务端合并+校验]
M --> N{校验通过?}
N -->|是| Z
N -->|否| O[上传失败,需重新上传]
style E fill:#d5f9d5
style Z fill:#d5f9d5
style O fill:#f9d5d5
1.2 设计要点
| 要点 | 方案 | 说明 |
|---|---|---|
| 分片大小 | 2MB | 兼顾上传效率和重传代价 |
| 文件标识 | MD5 + 文件大小 + 文件名 | 唯一标识一个文件 |
| 断点续传 | 查询已上传分片号 | 跳过已上传的分片 |
| 秒传 | MD5匹配已有文件 | 相同文件不重复上传 |
| 并发控制 | 3-5个并发上传 | 避免浏览器连接数限制 |
| 合并校验 | MD5一致性校验 | 确保文件完整性 |
二、前端实现
2.1 文件分片
/**
* 文件分片上传管理器
*/
class FileUploader {
constructor(options = {}) {
this.chunkSize = options.chunkSize || 2 * 1024 * 1024; // 默认2MB
this.concurrency = options.concurrency || 3; // 并发上传数
this.maxRetries = options.maxRetries || 3; // 单分片最大重试次数
this.onProgress = options.onProgress || (() => {});
this.onComplete = options.onComplete || (() => {});
this.onError = options.onError || (() => {});
}
/**
* 上传文件主流程
* @param {File} file 用户选择的文件
*/
async upload(file) {
try {
// 1. 计算文件MD5
const fileMd5 = await this.calculateMd5(file);
// 2. 创建上传任务
const task = await this.createUploadTask(file, fileMd5);
if (task.status === 'EXISTS') {
// 秒传:文件已存在
this.onComplete(task.fileUrl);
return;
}
// 3. 获取未上传的分片列表
const uploadedChunks = task.uploadedChunks || [];
const totalChunks = Math.ceil(file.size / this.chunkSize);
// 4. 并发上传未完成的分片
await this.uploadChunks(file, fileMd5, totalChunks, uploadedChunks);
// 5. 请求合并分片
const result = await this.mergeChunks(fileMd5, file.name, totalChunks);
this.onComplete(result.fileUrl);
} catch (error) {
this.onError(error);
}
}
/**
* 计算文件MD5(使用SparkMD5库)
* 采用分片计算,避免大文件内存溢出
*/
async calculateMd5(file) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
const chunks = Math.ceil(file.size / this.chunkSize);
let currentChunk = 0;
reader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
resolve(spark.end());
}
};
reader.onerror = reject;
function loadNext() {
const start = currentChunk * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
reader.readAsArrayBuffer(file.slice(start, end));
}
loadNext();
});
}
/**
* 创建上传任务
*/
async createUploadTask(file, fileMd5) {
const response = await fetch('/api/file/upload/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileMd5: fileMd5,
fileName: file.name,
fileSize: file.size,
totalChunks: Math.ceil(file.size / this.chunkSize)
})
});
return response.json();
}
/**
* 并发上传分片
*/
async uploadChunks(file, fileMd5, totalChunks, uploadedChunks) {
// 过滤出未上传的分片索引
const pendingChunks = [];
for (let i = 0; i < totalChunks; i++) {
if (!uploadedChunks.includes(i)) {
pendingChunks.push(i);
}
}
// 并发控制上传
const uploadQueue = [...pendingChunks];
const workers = [];
for (let i = 0; i < this.concurrency; i++) {
workers.push(this.uploadWorker(file, fileMd5, uploadQueue));
}
await Promise.all(workers);
}
/**
* 上传工作线程
* 从队列中取分片上传,直到队列为空
*/
async uploadWorker(file, fileMd5, queue) {
while (queue.length > 0) {
const chunkIndex = queue.shift();
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
const chunk = file.slice(start, end);
let retries = 0;
while (retries < this.maxRetries) {
try {
await this.uploadSingleChunk(chunk, fileMd5, chunkIndex);
this.onProgress(chunkIndex);
break;
} catch (error) {
retries++;
if (retries >= this.maxRetries) {
throw new Error(`分片${chunkIndex}上传失败,已重试${retries}次`);
}
// 指数退避等待
await this.sleep(1000 * Math.pow(2, retries));
}
}
}
}
/**
* 上传单个分片
*/
async uploadSingleChunk(chunk, fileMd5, chunkIndex) {
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileMd5', fileMd5);
formData.append('chunkIndex', chunkIndex);
const response = await fetch('/api/file/upload/chunk', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`分片上传失败: HTTP ${response.status}`);
}
return response.json();
}
/**
* 请求合并分片
*/
async mergeChunks(fileMd5, fileName, totalChunks) {
const response = await fetch('/api/file/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileMd5: fileMd5,
fileName: fileName,
totalChunks: totalChunks
})
});
return response.json();
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
三、后端实现
3.1 上传任务初始化
/**
* 文件上传控制器
*/
@RestController
@RequestMapping("/api/file/upload")
public class FileUploadController {
@Autowired
private FileUploadService fileUploadService;
/**
* 初始化上传任务
* 检查文件是否已存在(秒传),返回已上传分片列表(断点续传)
*/
@PostMapping("/init")
public Result<UploadTaskVO> initUpload(@RequestBody UploadInitDTO dto) {
UploadTaskVO task = fileUploadService.initUploadTask(dto);
return Result.ok(task);
}
/**
* 上传单个分片
*/
@PostMapping("/chunk")
public Result<Void> uploadChunk(
@RequestParam("file") MultipartFile file,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunkIndex") Integer chunkIndex) {
fileUploadService.uploadChunk(fileMd5, chunkIndex, file);
return Result.ok();
}
/**
* 合并分片
*/
@PostMapping("/merge")
public Result<FileUploadResultVO> mergeChunks(@RequestBody UploadMergeDTO dto) {
FileUploadResultVO result = fileUploadService.mergeChunks(dto);
return Result.ok(result);
}
}
3.2 上传任务服务
/**
* 文件上传服务
*/
@Service
public class FileUploadService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private FileStorageService fileStorageService;
/** 分片大小:2MB */
private static final long CHUNK_SIZE = 2 * 1024 * 1024;
/** 上传任务Redis Key前缀 */
private static final String TASK_KEY_PREFIX = "upload:task:";
private static final String CHUNK_KEY_PREFIX = "upload:chunk:";
/** 任务过期时间:24小时 */
private static final long TASK_EXPIRE_HOURS = 24;
/**
* 初始化上传任务
*/
public UploadTaskVO initUploadTask(UploadInitDTO dto) {
String taskKey = TASK_KEY_PREFIX + dto.getFileMd5();
// 1. 检查文件是否已存在(秒传)
String existingFileUrl = fileStorageService.findByMd5(dto.getFileMd5());
if (existingFileUrl != null) {
return UploadTaskVO.builder()
.status("EXISTS")
.fileUrl(existingFileUrl)
.build();
}
// 2. 检查是否有未完成的上传任务(断点续传)
List<Integer> uploadedChunks = getUploadedChunks(dto.getFileMd5());
// 3. 保存/更新任务信息
UploadTask task = UploadTask.builder()
.fileMd5(dto.getFileMd5())
.fileName(dto.getFileName())
.fileSize(dto.getFileSize())
.totalChunks(dto.getTotalChunks())
.uploadedChunks(uploadedChunks.size())
.createTime(LocalDateTime.now())
.build();
redisTemplate.opsForValue().set(taskKey, task,
TASK_EXPIRE_HOURS, TimeUnit.HOURS);
return UploadTaskVO.builder()
.status("UPLOADING")
.uploadedChunks(uploadedChunks)
.totalChunks(dto.getTotalChunks())
.build();
}
/**
* 上传单个分片
*/
@Transactional
public void uploadChunk(String fileMd5, Integer chunkIndex, MultipartFile file) {
try {
// 1. 保存分片到临时目录
String chunkPath = getChunkPath(fileMd5, chunkIndex);
fileStorageService.saveChunk(chunkPath, file.getInputStream());
// 2. 记录已上传分片
String chunkKey = CHUNK_KEY_PREFIX + fileMd5;
redisTemplate.opsForSet().add(chunkKey, chunkIndex);
redisTemplate.expire(chunkKey, TASK_EXPIRE_HOURS, TimeUnit.HOURS);
} catch (IOException e) {
throw new RuntimeException("分片保存失败", e);
}
}
/**
* 获取已上传的分片列表
*/
private List<Integer> getUploadedChunks(String fileMd5) {
String chunkKey = CHUNK_KEY_PREFIX + fileMd5;
Set<Object> chunks = redisTemplate.opsForSet().members(chunkKey);
if (chunks == null || chunks.isEmpty()) {
return Collections.emptyList();
}
return chunks.stream()
.map(obj -> Integer.parseInt(obj.toString()))
.sorted()
.collect(Collectors.toList());
}
/**
* 获取分片临时存储路径
*/
private String getChunkPath(String fileMd5, Integer chunkIndex) {
return String.format("temp/chunks/%s/%d", fileMd5, chunkIndex);
}
}
3.3 分片合并
/**
* 合并分片
* 将所有分片按顺序合并为完整文件,并进行MD5校验
*/
public FileUploadResultVO mergeChunks(UploadMergeDTO dto) {
String fileMd5 = dto.getFileMd5();
String chunkDir = "temp/chunks/" + fileMd5;
// 1. 检查所有分片是否已上传
List<Integer> uploadedChunks = getUploadedChunks(fileMd5);
if (uploadedChunks.size() < dto.getTotalChunks()) {
throw new RuntimeException("存在未上传的分片,无法合并");
}
// 2. 合并分片
String mergedFilePath = "temp/merged/" + fileMd5 + "_" + dto.getFileName();
try (OutputStream outputStream = new FileOutputStream(mergedFilePath)) {
// 按分片索引顺序合并
for (int i = 0; i < dto.getTotalChunks(); i++) {
String chunkPath = String.format("%s/%d", chunkDir, i);
try (InputStream inputStream = new FileInputStream(chunkPath)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
}
}
} catch (IOException e) {
throw new RuntimeException("分片合并失败", e);
}
// 3. 校验合并后文件的MD5
String actualMd5 = calculateFileMd5(mergedFilePath);
if (!fileMd5.equals(actualMd5)) {
// MD5不一致,删除合并文件
new File(mergedFilePath).delete();
throw new RuntimeException("文件校验失败,MD5不一致");
}
// 4. 将合并后的文件转移到正式存储
String fileUrl = fileStorageService.store(mergedFilePath, dto.getFileName());
// 5. 清理临时文件
cleanupTempFiles(fileMd5, dto.getTotalChunks(), mergedFilePath);
// 6. 清理Redis记录
redisTemplate.delete(CHUNK_KEY_PREFIX + fileMd5);
redisTemplate.delete(TASK_KEY_PREFIX + fileMd5);
return FileUploadResultVO.builder()
.fileUrl(fileUrl)
.fileMd5(fileMd5)
.fileName(dto.getFileName())
.build();
}
/**
* 计算文件MD5
*/
private String calculateFileMd5(String filePath) {
try (InputStream is = new FileInputStream(filePath)) {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
md.update(buffer, 0, bytesRead);
}
return Hex.toHexString(md.digest());
} catch (Exception e) {
throw new RuntimeException("MD5计算失败", e);
}
}
/**
* 清理临时文件
*/
private void cleanupTempFiles(String fileMd5, int totalChunks, String mergedFilePath) {
// 删除分片文件
for (int i = 0; i < totalChunks; i++) {
String chunkPath = String.format("temp/chunks/%s/%d", fileMd5, i);
new File(chunkPath).delete();
}
// 删除分片目录
new File("temp/chunks/" + fileMd5).delete();
// 删除合并临时文件
new File(mergedFilePath).delete();
}
四、断点续传详解
4.1 续传流程
sequenceDiagram
participant User as 用户
participant FE as 前端
participant API as 后端API
participant Redis as Redis
participant Storage as 文件存储
User->>FE: 重新选择同一文件上传
FE->>FE: 计算文件MD5
FE->>API: POST /upload/init {fileMd5, ...}
API->>Storage: 查询文件是否已存在
Storage-->>API: 不存在
API->>Redis: 查询已上传分片 SMEMBERS upload:chunk:{md5}
Redis-->>API: [0, 1, 2, 3, 5, 7]
API-->>FE: 返回已上传分片 [0,1,2,3,5,7]
Note over FE: 跳过分片0,1,2,3,5,7
FE->>API: 上传分片4
FE->>API: 上传分片6
FE->>API: 上传分片8
FE->>API: 上传分片9
API->>Redis: SADD upload:chunk:{md5} 4
API->>Storage: 保存分片4
Note over FE: 所有分片上传完毕
FE->>API: POST /upload/merge
API->>API: 合并所有分片
API->>API: MD5校验
API-->>FE: 返回文件地址
4.2 关键实现
断点续传的核心是查询已上传分片和跳过已上传分片:
/**
* 获取已上传分片(Redis Set结构)
* Key: upload:chunk:{fileMd5}
* Value: Set<chunkIndex>
*/
public List<Integer> getUploadedChunks(String fileMd5) {
String key = CHUNK_KEY_PREFIX + fileMd5;
Set<Object> chunks = redisTemplate.opsForSet().members(key);
if (chunks == null) {
return Collections.emptyList();
}
return chunks.stream()
.map(obj -> Integer.parseInt(obj.toString()))
.sorted()
.collect(Collectors.toList());
}
前端根据返回的已上传分片列表,跳过已上传的分片:
// 过滤出未上传的分片
const pendingChunks = [];
for (let i = 0; i < totalChunks; i++) {
if (!uploadedChunks.includes(i)) {
pendingChunks.push(i);
}
}
// 只上传未完成的分片
await this.uploadChunks(file, fileMd5, pendingChunks);
五、秒传机制
5.1 秒传原理
秒传的核心是文件MD5匹配:如果数据库中已存在相同MD5的文件,直接返回文件地址,无需重复上传。
flowchart LR
A[计算文件MD5] --> B{数据库中存在<br/>相同MD5的文件?}
B -->|是| C[直接返回文件地址<br/>✅ 秒传]
B -->|否| D[执行分片上传]
style C fill:#d5f9d5
style D fill:#e8f4f8
5.2 注意事项
- MD5碰撞:理论上存在不同文件MD5相同的情况,可结合文件大小和文件名进一步校验
- 文件归属:秒传需要记录文件与用户的关联关系,确保权限正确
- 存储引用:同一物理文件可被多个用户引用,删除时需使用引用计数
六、性能优化
6.1 分片大小选择
| 分片大小 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| 512KB | 重传代价小 | 请求次数多,开销大 | 弱网环境 |
| 2MB | 平衡效率与代价 | - | 通用推荐 |
| 5MB | 请求次数少 | 重传代价大 | 稳定网络 |
| 10MB | 上传快 | 内存压力大,重传代价大 | 局域网 |
6.2 并发上传优化
/**
* 动态调整并发数
* 根据网络状况自动调整上传并发数
*/
class AdaptiveFileUploader extends FileUploader {
constructor(options) {
super(options);
this.minConcurrency = 1;
this.maxConcurrency = 6;
this.currentConcurrency = options.concurrency || 3;
this.failureRate = 0; // 近期失败率
}
/**
* 根据失败率动态调整并发数
*/
adjustConcurrency() {
if (this.failureRate > 0.3) {
// 失败率过高,降低并发
this.currentConcurrency = Math.max(
this.minConcurrency,
this.currentConcurrency - 1
);
} else if (this.failureRate < 0.05) {
// 失败率很低,提高并发
this.currentConcurrency = Math.min(
this.maxConcurrency,
this.currentConcurrency + 1
);
}
}
}
七、完整流程图
flowchart TB
A[用户选择文件] --> B[前端计算文件MD5<br/>SparkMD5分片计算]
B --> C[调用初始化接口<br/>POST /upload/init]
C --> D{服务端判断}
D -->|MD5匹配已有文件| E[🎉 秒传成功<br/>直接返回文件URL]
D -->|部分分片已上传| F[返回已上传分片列表<br/>断点续传]
D -->|首次上传| G[返回空列表<br/>全量上传]
F --> H[前端跳过已上传分片]
G --> I[前端分片文件<br/>每片2MB]
H --> I
I --> J[并发上传分片<br/>3-5个并发]
J --> K{分片上传成功?}
K -->|是| L[Redis记录分片号<br/>SADD upload:chunk:md5 index]
K -->|否| M{重试次数<3?}
M -->|是| N[指数退避等待后重试]
M -->|否| O[❌ 上传失败]
L --> P{所有分片完成?}
P -->|否| J
P -->|是| Q[调用合并接口<br/>POST /upload/merge]
Q --> R[按顺序合并所有分片]
R --> S[计算合并文件MD5]
S --> T{MD5一致?}
T -->|是| U[转移到正式存储<br/>清理临时文件和Redis]
T -->|否| V[❌ 校验失败]
U --> W[🎉 上传成功<br/>返回文件URL]
style E fill:#d5f9d5
style W fill:#d5f9d5
style O fill:#f9d5d5
style V fill:#f9d5d5
结论与建议
核心要点
- 2MB分片是通用推荐值,兼顾上传效率和重传代价
- MD5标识是秒传和断点续传的基础,前端分片计算避免内存溢出
- Redis记录已上传分片号,实现断点续传查询
- 合并校验确保文件完整性,防止分片丢失或损坏
实践建议
- 分片大小可配置:根据网络环境允许用户调整分片大小
- 上传限速:避免大文件上传占用全部带宽,影响其他用户
- 临时文件清理:设置定时任务清理超时的临时分片文件
- 断点续传时效:上传任务设置24小时过期,超时需重新上传
- 安全校验:对上传文件进行病毒扫描和类型校验
- 存储适配:支持本地存储、MinIO、阿里云OSS等多种存储后端