从0实现分片上传:断点续传 + 秒传 + 合并全解析
SryYa Three

为什么需要分片上传

在做文件上传功能时,最初我采用的是最简单的方案:前端通过一个 HTTP 请求,将完整文件直接上传到服务端。

测试时,我在上传一个3MB的照片时就报错了。按理说,这个才3MB,已经很小了,还是上传失败了。

后来我找到了错误原因,竟是multipart 解析器限制了上传文件的大小。

具体配置在application.yaml中的:

1
2
3
4
5
spring:
servlet:
multipart:
max-file-size: 1MB # 单个文件的最大限制,默认1MB
max-request-size: 10MB # 单次请求的总大小限制,默认10MB

我以为只是配置问题,但很快意识到: 即使放开限制,这种“整文件上传”的方案本身就是不可靠的

问题在于:

  1. 极高的失败率

    在上传较大文件(如视频、安装包)时,经常出现上传中断的情况,例如网络波动或请求超时。一旦失败,用户只能重新上传整个文件。

  2. 接口不稳定

    大文件上传请求持续时间长,占用服务器连接资源,一旦并发上来,容易导致接口响应变慢甚至超时。

  3. 用户体验很差

    当文件上传到80%或90%时失败,用户需要从头开始上传,体验非常糟糕。

基于这些问题,我开始思考:

是否可以将一个大文件拆分为多个小块分别上传,从而降低单次请求的风险?

这就是分片上传(Chunk Upload)的核心思想:“分而治之”。在这个基础上,我们还可以实现断点续传、并发上传等功能。

流程

既然是“分而治之”,那么实现这个功能就需要我们的前后端配合工作。

客户端流程:

  1. 文件切片
  2. 计算每片文件的hash(用于秒传/去重)
  3. 查询服务器已上传分片
  4. 并发上传缺失分片
  5. 通知服务器合并

服务端流程:

  1. 接收分片并保存
  2. 记录分片上传状态(可选Redis / DB)
  3. 校验分片完整性
  4. 合并分片成完整文件
  5. 标记上传完成,供客户端确认

流程图

graph TD
    A[开始上传文件] --> B[前端计算文件唯一标识 MD5]
    B --> C{请求后端: 秒传检查}
    
    C -- 文件已存在 --> D[显示: 秒传成功]
    C -- 存在部分分片 --> E[获取已上传分片列表]
    C -- 完全未上传 --> F[分片列表为空]
    
    E --> G[文件分片 Chunking]
    F --> G
    
    G --> H[循环上传未完成的分片]
    H --> I{所有分片上传完成?}
    
    I -- 否: 发生断网/中断 --> J[进度保存在后端/数据库]
    J --> K[用户下次点击上传]
    K --> B
    
    I -- 是 --> L[前端发起合并请求]
    L --> M[后端校验分片完整性]
    M --> N[调用存储接口合并文件]
    N --> O[清理分片临时记录]
    O --> P[上传结束: 成功]

前端分片

客户端计算整个文件的MD5:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 计算整个文件MD5
const calculateFileMD5 = (file) => {
return new Promise((resolve) => {
const chunkSize = 2 * 1024 * 1024; // 分片大小为2MB
const chunks = Math.ceil(file.size / chunkSize); // 计算总片数
const spark = new SparkMD5.ArrayBuffer();
let currentChunk = 0; // 当前片数索引(即处理到第几片)

const reader = new FileReader();

reader.onload = (e) => {
spark.append(e.target.result); // 将读取到的二进制数据添加到 MD5 计算器中
currentChunk++;

if (currentChunk < chunks) {
loadNext(); // 如果还没读完,继续读下一片
} else {
resolve(spark.end()); // 全部读完,输出最终的 MD5 字符串
}
};

// 切片操作
const loadNext = () => {
const start = currentChunk * chunkSize;
const end = Math.min(file.size, start + chunkSize);
reader.readAsArrayBuffer(file.slice(start, end));
};

loadNext();
});
};

客户端计算每个分片的MD5:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 计算分片的MD5
const calculateChunkMD5 = async (chunk) => {
return new Promise((resolve) => {
const reader = new FileReader();
const spark = new SparkMD5.ArrayBuffer();

reader.onload = (e) => {
spark.append(e.target.result);
resolve(spark.end());
};

reader.readAsArrayBuffer(chunk);
});
};

客户端分片并上传:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
const submitVideo = async () => {
const file = selectedVideoFile.value;
if (!file) return;

const CHUNK_SIZE = 2 * 1024 * 1024;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);

// 1.计算文件MD5
const fileMD5 = await calculateFileMD5(file);

// uploadId直接用MD5
const uploadId = fileMD5;

// 2.秒传检查
const checkRes = await request.get('/file/check', {
params: {fileMD5}
});

// 如果后端返回'complete',说明已经传输成功
if (checkRes.data.status === 'complete') {
alert('秒传成功');
return;
}

// 用set集合来存已上传的index
const uploadedSet = new Set(checkRes.data.uploadedChunks || []);

// 3. 分片上传
for (let i = 0; i < totalChunks; i++) {
// 如果set已经包含上传过的分片index,直接跳过
if (uploadedSet.has(i)) continue;

// 将文件分片
const start = i * CHUNK_SIZE;
const end = Math.min(file.size, start + CHUNK_SIZE);
const chunkFile = file.slice(start, end);

// 计算分片MD5
const chunkMD5 = await calculateChunkMD5(chunkFile);

// 上传分片至后端
await uploadVideoChunk({
uploadId,
chunkIndex: i,
totalChunks,
fileName: file.name,
chunkFile,
chunkMD5,
fileMD5
});
}

// 4. 后端合并并返回url
const videoUrl = await completeVideoUpload({
uploadId,
totalChunks,
fileName: file.name,
contentType: file.type,
fileMD5
});

console.log('上传成功:', videoUrl);
};

客户端上传分片函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const uploadVideoChunk = async ({
uploadId,
chunkIndex,
totalChunks,
fileName,
chunkFile,
chunkMD5,
fileMD5
}) => {
const formData = new FormData();

formData.append('uploadId', uploadId);
formData.append('chunkIndex', chunkIndex);
formData.append('totalChunks', totalChunks);
formData.append('fileName', fileName);
formData.append('file', chunkFile);
formData.append('chunkMD5', chunkMD5);
formData.append('fileMD5', fileMD5);

return request.post('/file/video/chunk', formData);
};

客户端通知合并分片:

1
2
3
4
5
6
7
8
9
10
11
export const completeVideoUpload = async ({
uploadId,
totalChunks,
fileName,
contentType,
fileMD5
}) => {
return request.post('/file/video/complete', null, {
params: {uploadId, totalChunks, fileName, contentType, fileMD5}
});
};

讲完了前端的分片,我们接下来讲后端是如何处理分片的。


后端处理

我们先来看看后端是如何上传分片至MinIO的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
private static final String CHUNK_KEY = "upload:chunks:";
private static final String META_KEY = "upload:meta:";

@Override
public void uploadVideoChunk(String uploadId,
Integer chunkIndex,
Integer totalChunks,
String fileName,
String chunkMD5,
String fileMD5,
MultipartFile file) {

try {
// 1.MD5校验
String serverMD5 = DigestUtils.md5DigestAsHex(file.getInputStream());
if (!serverMD5.equals(chunkMD5)) {
throw new RuntimeException("分片校验失败");
}

// 2.创建目录并保存分片
Path uploadDir = VIDEO_TEMP_DIR.resolve(uploadId);
Files.createDirectories(uploadDir);

// 用"1.part"、"2.part"存分片
Path chunkPath = uploadDir.resolve(chunkIndex + ".part");
// 先检查这个分片是否已经存在,如果存在就不再重复写入(幂等控制)
if (!Files.exists(chunkPath)) {
Files.copy(file.getInputStream(), chunkPath);
}

// 3.Redis记录分片状态
String chunkKey = CHUNK_KEY + fileMD5;

// 用bitMap存储状态
redisTemplate.opsForValue().setBit(chunkKey, chunkIndex, true);

// 4.写入元信息至Redis
String metaKey = META_KEY + fileMD5;

// 没有meta则创建
if (!Boolean.TRUE.equals(redisTemplate.hasKey(metaKey))) {
Map<String, String> meta = new HashMap<>();
meta.put("fileName", fileName);
meta.put("totalChunks", totalChunks.toString());
meta.put("uploadId", uploadId);

redisTemplate.opsForHash().putAll(metaKey, meta);

// 设置过期时间
redisTemplate.expire(metaKey, Duration.ofHours(24));
redisTemplate.expire(chunkKey, Duration.ofHours(24));
}

} catch (Exception e) {
throw new RuntimeException("上传失败", e);
}
}

后端秒传检查接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public UploadCheckResult checkFile(String fileMD5, Integer totalChunks) {

// 1.是否已全部完成
String completedKey = "upload:completed:" + fileMD5;
String url = redisTemplate.opsForValue().get(completedKey);

if (url != null) {
return new UploadCheckResult("complete", url, null);
}

// 2.是否部分上传
String chunkKey = "upload:chunks:" + fileMD5;

if (Boolean.TRUE.equals(redisTemplate.hasKey(chunkKey))) {
List<Integer> uploaded = getUploadedChunks(fileMD5, totalChunks);
return new UploadCheckResult("partial", null, uploaded);
}

return new UploadCheckResult("none", null, null);
}

后端返回已上传的分片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public List<Integer> getUploadedChunks(String fileMD5, Integer totalChunks) {

String chunkKey = "upload:chunks:" + fileMD5;

List<Integer> uploaded = new ArrayList<>();
// 如果bit位为true说明该分片已上传
for (int i = 0; i < totalChunks; i++) {
Boolean exists = redisTemplate.opsForValue().getBit(chunkKey, i);
if (Boolean.TRUE.equals(exists)) {
uploaded.add(i);
}
}
return uploaded;
}

后端检查是否全部上传完成:

1
2
3
4
5
6
7
8
9
10
11
private boolean isAllUploaded(String fileMD5, int totalChunks) {
String chunkKey = "upload:chunks:" + fileMD5;

// 如果有1个分片没上传成功则返回false
for (int i = 0; i < totalChunks; i++) {
if (!Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(chunkKey, i))) {
return false;
}
}
return true;
}

后端合并分片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@Override
public String completeVideoUpload(
String uploadId,
Integer totalChunks,
String fileName,
String contentType,
String fileMD5) {
Path uploadDir = VIDEO_TEMP_DIR.resolve(uploadId);
if (!Files.exists(uploadDir)) {
throw new RuntimeException("未找到上传任务");
}
if (totalChunks == null || totalChunks <= 0) {
throw new RuntimeException("分片总数非法");
}
// 文件类型校验
String safeName = fileName == null ? "" : fileName;
String suffix = safeName.contains(".") ? safeName.substring(safeName.lastIndexOf(".")) : ".mp4";
if (!Arrays.asList(".mp4", ".mov", ".webm", ".mkv").contains(suffix.toLowerCase())) {
throw new RuntimeException("不支持的视频格式");
}
// 合并分片
// 合并前先检查分片是否完整
if (!isAllUploaded(fileMD5, totalChunks)) {
throw new RuntimeException("分片未全部上传完成");
}
Path mergedFile = uploadDir.resolve("merged" + suffix);
try (RandomAccessFile raf = new RandomAccessFile(mergedFile.toFile(), "rw")) {
for (int i = 0; i < totalChunks; i++) {
Path chunkPath = uploadDir.resolve(i + ".part");


try (InputStream in = Files.newInputStream(chunkPath)) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
raf.write(buffer, 0, len);
}
}
}
} catch (IOException e) {
throw new RuntimeException("分片合并失败", e);
}
String mergedMD5;
try (InputStream in = new FileInputStream(mergedFile.toFile())) {
mergedMD5 = DigestUtils.md5DigestAsHex(in);
}
// 上传完成后再检验一次MD5
if (!mergedMD5.equals(fileMD5)) {
throw new RuntimeException("文件损坏");
}
// 给文件分配文件名
String objectName = UUID.randomUUID() + suffix;
String videoType = (contentType == null || contentType.isBlank()) ? "video/mp4" : contentType;
// 上传至MinIO
try (InputStream in = new FileInputStream(mergedFile.toFile())) {
minioClient.putObject(
PutObjectArgs.builder()
.bucket("video")
.object(objectName)
.stream(in, Files.size(mergedFile), -1)
.contentType(videoType)
.build()
);
// 返回路径给前端
String videoUrl = endpoint + "/video/" + objectName;
// 标记上传完成
redisTemplate.opsForValue().set("upload:completed:" + fileMD5, videoUrl);
// 清理临时状态
redisTemplate.delete("upload:chunks:" + fileMD5);
redisTemplate.delete("upload:meta:" + fileMD5);
log.info("video路径:{}", videoUrl);
return videoUrl;
} catch (Exception e) {
throw new RuntimeException("视频上传到对象存储失败", e);
} finally {
// 删除临时文件
try (Stream<Path> walk = Files.walk(uploadDir)) {
walk.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
});
} catch (IOException ignored) {
}
}
}
// 可优化的点:
// 1.在特定情况下合并接口可能被并发调用,可以加入分布式锁解决并发安全问题
// 2.如果文件特别大,全量计算文件MD5可能比较耗时,可以转为校验每片的MD5,校验通过再合并。
// 3.加入定时任务清理临时文件,避免某些原因导致的删除临时文件失败

通过前后端的共同努力,我们成功实现了分片上传和断点续传。


总结

实现分片上传的过程,其实就是**“分而治之”**思想在工程实践中的完美体现。面对单次请求无法承载的巨量数据,我们通过拆解任务、状态追踪和最终合并,化整为零地解决了难题。

从最初简单的 multipart 解析器报错,到最后手写出一套支持秒传与断点续传的系统,这种解决问题的路径也是每一个后端开发者的成长必经之路。代码虽多,但只要掌握了核心流程图,剩下的不过是根据业务需求进行填补。

由 Hexo 驱动 & 主题 Keep
总字数 28.1k 访客数 访问量