分片上传入门文档
1. 什么是分片上传?
分片上传,英文通常叫:
Chunk Upload
Multipart Upload
它解决的核心问题是:
一个文件太大时,不一次性上传整个文件,而是先把文件切成多个小块,再逐块上传,最后由服务端合并成完整文件。
比如用户需要上传一个:
1GB.zip
如果直接上传:
浏览器
↓
1GB 文件
↓
服务器
一旦上传到 95% 时网络断开:
950MB 已经上传
↓
网络异常
↓
整个请求失败
用户可能只能重新上传完整的 1GB。
这就是传统大文件上传最明显的问题。
分片上传则会把:
1GB
切成:
chunk-0 10MB
chunk-1 10MB
chunk-2 10MB
...
chunk-99 10MB
然后分别上传:
chunk-0 → Server
chunk-1 → Server
chunk-2 → Server
...
chunk-99 → Server
全部上传完成以后:
chunk-0
chunk-1
chunk-2
...
chunk-99
↓
服务端合并
↓
1GB.zip
这就是分片上传。
2. 为什么需要分片上传?
假设你的系统允许用户上传:
5MB 图片
其实直接上传完全没问题。
但是如果业务开始支持:
500MB 视频
2GB 压缩包
10GB 数据文件
50GB 模型文件
一次性上传会逐渐暴露很多问题。
最明显的就是:
文件太大
↓
HTTP 请求时间很长
↓
任何网络波动都可能导致整个请求失败
↓
失败以后重新上传
↓
浪费时间和带宽
除此之外,大文件上传还容易碰到:
Nginx 文件大小限制
Spring Boot 上传大小限制
请求超时
服务器内存占用
网络波动
浏览器关闭
客户端掉线
服务器重启
因此对于大文件,通常更适合:
大文件
↓
拆小
↓
分别上传
↓
最后合并
3. 分片上传的核心思想
整个分片上传实际上只有三个动作:
切片
+
上传
+
合并
可以直接记成:
分片上传
=
Split
+
Upload
+
Merge
完整过程:
原始文件
↓
文件切片
↓
┌──────────┼──────────┐
↓ ↓ ↓
chunk-0 chunk-1 chunk-2
↓ ↓ ↓
上传
↓
服务端临时保存
↓
所有分片完成
↓
合并
↓
完整文件
4. 一个具体例子
假设用户上传:
movie.mp4
文件大小:
100MB
规定:
每个分片 = 10MB
那么:
100MB / 10MB = 10个分片
于是:
chunk 0 = 0MB ~ 10MB
chunk 1 = 10MB ~ 20MB
chunk 2 = 20MB ~ 30MB
...
chunk 9 = 90MB ~ 100MB
浏览器分别发送:
POST /upload/chunk
第一次:
fileName = movie.mp4
chunkIndex = 0
totalChunks = 10
chunk = 二进制数据
第二次:
fileName = movie.mp4
chunkIndex = 1
totalChunks = 10
chunk = 二进制数据
直到:
chunkIndex = 9
全部上传完成。
服务端可能临时保存成:
upload-temp/
abc123/
0.part
1.part
2.part
3.part
...
9.part
最后执行:
0.part
+
1.part
+
2.part
+
...
+
9.part
↓
movie.mp4
5. 为什么需要 uploadId?
这个东西非常重要。
假设同时有两个用户上传:
video.mp4
如果服务器只是按照文件名保存:
temp/video.mp4/0.part
那么两个上传任务就可能冲突。
因此一般会给每一次上传生成一个唯一标识:
uploadId
比如:
uploadId = 8f934c2b...
目录变成:
temp/
8f934c2b/
0.part
1.part
2.part
另一个文件:
temp/
a19be82c/
0.part
1.part
2.part
这样两个上传任务互不影响。
所以可以理解:
uploadId
=
一次文件上传任务的唯一身份证
6. 一个完整分片上传流程
实际业务里,通常不会直接开始上传。
比较完整的流程是:
用户选择文件
↓
初始化上传
↓
服务器生成 uploadId
↓
客户端切分文件
↓
逐个 / 并发上传分片
↓
服务端保存分片
↓
确认全部分片完成
↓
请求合并
↓
服务端合并文件
↓
校验文件
↓
上传成功
比如:
POST /upload/init
请求:
{
"fileName": "movie.mp4",
"fileSize": 104857600,
"chunkSize": 10485760
}
服务器返回:
{
"uploadId": "abc123",
"totalChunks": 10
}
客户端接下来:
POST /upload/chunk
上传:
uploadId = abc123
chunkIndex = 0
chunk = xxx
然后:
uploadId = abc123
chunkIndex = 1
chunk = xxx
依次完成。
最后:
POST /upload/complete
请求:
{
"uploadId": "abc123"
}
服务器合并:
abc123/
0.part
1.part
2.part
...
9.part
↓
movie.mp4
7. 前端是怎么切文件的?
浏览器中的 File 本质上可以通过:
file.slice()
进行切片。
假设:
const chunkSize = 10 * 1024 * 1024;
也就是:
10MB
核心逻辑:
const chunks = [];
for (
let start = 0;
start < file.size;
start += chunkSize
) {
const end = Math.min(
start + chunkSize,
file.size
);
chunks.push(
file.slice(start, end)
);
}
假设:
file.size = 100MB
chunkSize = 10MB
最终:
chunks.length = 10
注意:
这里并不是把原文件真的复制成 10 个完整的新文件。
slice() 更像是:
从原始 File / Blob
截取某一段二进制数据
8. 前端如何上传一个分片?
通常使用:
FormData
例如:
const formData = new FormData();
formData.append("uploadId", uploadId);
formData.append("chunkIndex", index);
formData.append("chunk", chunk);
await fetch("/upload/chunk", {
method: "POST",
body: formData
});
服务器接收到的就是:
uploadId
+
chunkIndex
+
chunk 文件
其中:
uploadId
告诉服务器:
这个分片属于哪个上传任务
而:
chunkIndex
告诉服务器:
这个分片排第几个
9. Spring Boot 如何接收分片?
后端接口可以先简单设计为:
@PostMapping("/upload/chunk")
public void uploadChunk(
@RequestParam String uploadId,
@RequestParam Integer chunkIndex,
@RequestParam MultipartFile chunk
) throws IOException {
}
接下来创建临时目录:
Path uploadDir =
Paths.get(
"/data/upload-temp",
uploadId
);
Files.createDirectories(uploadDir);
生成分片文件:
Path chunkPath =
uploadDir.resolve(
chunkIndex + ".part"
);
保存:
chunk.transferTo(
chunkPath.toFile()
);
最终目录:
/data/upload-temp/
abc123/
0.part
1.part
2.part
3.part
10. 为什么一定要有 chunkIndex?
因为分片可能不是按照顺序到达服务器的。
假设前端并发上传:
chunk 0
chunk 1
chunk 2
chunk 3
chunk 4
理论上请求是同时发送的。
服务器收到的顺序可能变成:
chunk 3
chunk 1
chunk 4
chunk 0
chunk 2
因此不能根据:
服务器收到请求的顺序
判断文件顺序。
必须使用:
chunkIndex
例如:
chunkIndex = 0
chunkIndex = 1
chunkIndex = 2
最终合并时:
按照 chunkIndex 排序
才能恢复原文件。
11. 服务端怎么合并?
假设:
0.part
1.part
2.part
3.part
最直接的方式就是:
创建最终文件
↓
读取 0.part
↓
写入最终文件
↓
读取 1.part
↓
继续写入
↓
读取 2.part
↓
继续写入
Java 可以:
Path finalFile =
Paths.get(
"/data/files/movie.mp4"
);
try (
OutputStream output =
Files.newOutputStream(finalFile)
) {
for (int i = 0; i < totalChunks; i++) {
Path chunk =
uploadDir.resolve(
i + ".part"
);
Files.copy(
chunk,
output
);
}
}
最终:
0.part
+
1.part
+
2.part
+
3.part
↓
movie.mp4
12. 为什么不能一次把所有分片读进内存?
这是一个很重要的生产问题。
错误方式:
byte[] chunk0 = ...
byte[] chunk1 = ...
byte[] chunk2 = ...
byte[] result =
chunk0 + chunk1 + chunk2;
假设原文件:
10GB
那么:
Java JVM
根本不适合为了合并文件一次性加载 10GB 数据。
正确的思路是:
流式读
+
流式写
也就是:
读取一点
↓
写一点
↓
释放
这样无论是:
100MB
还是:
20GB
内存占用都可以控制在比较稳定的范围内。
13. 分片上传一定要串行吗?
不一定。
最简单的是:
chunk 0
↓
上传完成
chunk 1
↓
上传完成
chunk 2
这叫:
串行上传
优点:
简单
稳定
服务器压力小
缺点:
速度可能比较慢
因此实际项目通常会:
并发上传
例如一次同时上传:
chunk 0
chunk 1
chunk 2
chunk 3
四个完成以后再发送:
chunk 4
chunk 5
chunk 6
chunk 7
这种方式叫:
并发窗口
比如:
最大并发数 = 4
就意味着:
最多同时存在 4 个分片请求
14. 为什么不能疯狂并发?
有人可能会想:
100 个分片
那我就:
100 个请求一起发
理论上似乎更快。
实际上可能更慢。
因为这会同时占用:
浏览器连接
网络带宽
Nginx连接
Tomcat线程
服务器磁盘IO
对象存储连接
结果可能变成:
客户端:
100 个请求
↓
Nginx:
连接暴涨
↓
Spring Boot:
线程大量占用
↓
磁盘:
随机写入
↓
最终整体性能反而下降
因此生产中一般需要:
限制并发数量
常见思想:
3 ~ 8 个分片同时上传
具体多少不能死记,要根据:
网络
服务器配置
存储系统
文件大小
业务并发量
压测决定。
15. 分片应该设置多大?
这也是很经典的问题。
假设:
分片 = 1KB
一个 1GB 文件会产生:
100多万个请求
显然不合理。
如果:
分片 = 1GB
那其实跟不分片区别也不大。
因此需要平衡:
分片越小
→ 重传成本低
→ 请求数量多
分片越大
→ 请求数量少
→ 单个分片失败重传成本高
可以理解成:
小 Chunk:
稳定性 ↑
请求数 ↑
管理成本 ↑
大 Chunk:
请求数 ↓
单次失败成本 ↑
Web 系统常见可以从:
5MB
10MB
20MB
50MB
这样的范围开始测试。
不能简单认为:
10MB 永远最好
真正生产环境应该压测。
16. 上传完成后怎么知道可以合并?
这是一个非常关键的问题。
假设:
totalChunks = 10
服务器当前收到:
0
1
2
3
4
5
6
7
8
9
那么:
分片数量 == totalChunks
理论上可以合并。
但是只比较数量还不够严谨。
比如:
0
1
2
3
4
5
6
7
8
8
数量同样可能达到 10,但:
chunk 9
根本不存在。
所以应该检查:
0.part 是否存在
1.part 是否存在
2.part 是否存在
...
9.part 是否存在
只有:
所有 index 都存在
才能进入合并。
17. 分片上传和断点续传是什么关系?
分片上传:
解决:
大文件如何上传
断点续传:
解决:
上传中断以后如何继续
比如:
chunk 0 ✅
chunk 1 ✅
chunk 2 ✅
chunk 3 ✅
chunk 4 ❌
如果网络中断后重新开始:
重新上传:
chunk 0
chunk 1
chunk 2
chunk 3
chunk 4
...
那么:
这是分片上传
但不是完整意义上的断点续传
如果恢复后:
先问服务器:
哪些分片已经存在?
服务器:
{
"uploadedChunks": [
0,
1,
2,
3
]
}
客户端只上传:
chunk 4
chunk 5
...
这就是:
分片上传
+
断点续传
18. 分片上传和秒传是什么关系?
秒传是另外一个能力。
比如文件:
movie.mp4
客户端计算:
SHA-256
得到:
abc123xyz
上传前询问服务器:
有没有 hash = abc123xyz 的文件?
如果服务器已经存在:
有
那么:
不上传任何分片
直接返回成功
这个叫:
秒传
所以可以记住:
分片上传
解决大文件问题
断点续传
解决中断恢复问题
秒传
解决重复文件问题
19. 为什么需要文件 Hash?
文件 Hash 常见:
MD5
SHA-256
一个文件:
movie.mp4
通过算法生成:
SHA256
=
a8323e...
如果文件内容发生变化:
movie-v2.mp4
Hash 通常也会完全变化。
因此可以利用 Hash 判断:
这个文件是不是同一个文件
Hash 可以参与:
秒传
文件完整性校验
上传任务识别
重复文件检测
生产环境中如果偏重完整性和安全性,一般更推荐:
SHA-256
而不是把 MD5 当成安全哈希使用。
20. 每个分片需要 Hash 吗?
不是绝对必须。
但是比较完善的系统可能会给每个 Chunk 计算:
chunkHash
例如:
chunk 0
SHA256 = aaa
chunk 1
SHA256 = bbb
chunk 2
SHA256 = ccc
服务器收到分片:
计算服务器端 Hash
然后:
客户端 Hash
==
服务器 Hash
说明:
该分片大概率完整
否则:
重新上传该分片
这属于:
分片完整性校验
21. 分片上传的 API 怎么设计?
一个比较清晰的接口模型可以是:
① 初始化上传
POST /uploads
请求:
{
"fileName": "movie.mp4",
"fileSize": 104857600,
"fileHash": "xxx",
"chunkSize": 10485760
}
响应:
{
"uploadId": "abc123",
"totalChunks": 10
}
然后:
② 上传分片
PUT /uploads/{uploadId}/chunks/{chunkIndex}
请求 Body:
二进制 Chunk
接着:
③ 查询上传状态
GET /uploads/{uploadId}
返回:
{
"uploadId": "abc123",
"status": "UPLOADING",
"uploadedChunks": [
0,
1,
2,
3
]
}
最后:
④ 完成上传
POST /uploads/{uploadId}/complete
服务端执行:
检查所有 Chunk
↓
合并
↓
校验 Hash
↓
保存最终文件
↓
删除临时分片
22. 上传任务数据库怎么设计?
如果系统比较简单:
单机
低并发
内部系统
可以暂时直接依靠:
文件目录
记录上传状态。
但是正式系统一般建议有:
upload_task
例如:
upload_task
id
upload_id
file_name
file_size
file_hash
chunk_size
total_chunks
uploaded_chunks
status
created_at
updated_at
expire_at
状态:
INIT
↓
UPLOADING
↓
MERGING
↓
SUCCESS
失败:
UPLOAD_FAILED
MERGE_FAILED
这样系统就可以知道:
这个任务现在进行到哪里了
23. uploadedChunks 怎么存?
这是一个很实际的问题。
最简单的办法:
数据库里记录
例如:
[
0,
1,
2,
4,
6
]
也可以通过 Redis:
upload:abc123:chunks
存成 Set:
0
1
2
4
6
当:
chunk 3
上传成功:
SADD upload:abc123:chunks 3
查询:
SMEMBERS upload:abc123:chunks
就知道哪些已经完成。
但是如果最终状态必须长期保留:
DB
一般还是更可靠。
Redis 更适合:
临时状态
快速查询
上传期间状态
24. uploadId 能不能直接用文件 Hash?
可以,但不要简单认为这永远正确。
例如:
uploadId = SHA256(file)
确实有优势:
同一个文件天然得到同一个 ID
但是会带来一个问题:
两个用户同时上传同一个文件
可能进入同一个上传任务。
有时候这是你想要的:
去重
有时候不是:
每个用户需要独立上传记录
因此比较稳妥的设计通常是:
fileHash
负责:
文件身份
而:
uploadId
负责:
上传任务身份
不要把两个概念完全混成一个。
25. 分片文件存在哪里?
最简单:
服务器本地磁盘
例如:
/data/upload-temp/{uploadId}/
但是如果部署成:
3台 Spring Boot
那么问题来了。
第一次:
chunk 0
→ Server A
第二次:
chunk 1
→ Server B
第三次:
chunk 2
→ Server C
于是:
Server A:
0.part
Server B:
1.part
Server C:
2.part
最终谁来合并?
就麻烦了。
所以分布式系统通常会考虑:
共享存储
或者:
MinIO
S3
OSS
COS
这种对象存储。
26. 对象存储里的分片上传
如果最终文件本来就准备放到:
MinIO
Amazon S3
阿里云 OSS
腾讯云 COS
通常没有必要:
客户端
↓
Spring Boot
↓
Spring Boot 本地存 Chunk
↓
Spring Boot 合并
↓
再上传对象存储
因为中间多了一层数据搬运。
更加合理:
浏览器
↓
对象存储
业务服务器负责:
鉴权
生成上传任务
生成签名
记录状态
通知完成
例如:
Browser
│
│ 获取上传凭证
▼
Business API
│
│ 返回 uploadId / presigned URL
▼
Browser
│
│ 直接上传
▼
MinIO / S3
这种:
客户端直传对象存储
在大文件业务里通常更有扩展性。
27. 为什么生产环境推荐对象存储原生 Multipart Upload?
很多对象存储本身已经提供:
Multipart Upload
整个流程:
Create Multipart Upload
↓
获得 UploadId
↓
Upload Part 1
↓
Upload Part 2
↓
Upload Part 3
↓
Complete Multipart Upload
↓
对象存储内部完成文件
也就是说:
你不用自己:
保存 0.part
保存 1.part
保存 2.part
自己写合并逻辑
对象存储已经把这些能力提供好了。
如果生产项目本来就在用对象存储:
优先考虑使用对象存储原生 Multipart Upload,而不是自己从零实现一套分片协议。
28. 失败重试怎么处理?
假设:
chunk 0 ✅
chunk 1 ✅
chunk 2 ❌
chunk 3 ✅
不应该:
整个文件重新上传
应该只:
重新上传 chunk 2
例如:
第一次失败
↓
等待
↓
重试
第二次失败
↓
等待更久
↓
重试
这就是:
Retry
实际项目通常会限制:
最大重试次数
例如:
3 次
超过以后:
标记该分片失败
由用户选择:
重新上传
或者系统之后恢复。
29. 为什么接口需要幂等?
假设:
chunk 5
服务器其实已经保存成功。
但是:
响应返回途中网络断开
客户端不知道服务器成功了,于是:
再次上传 chunk 5
这时服务端不应该:
出现异常
而应该允许:
同一个 uploadId
+
同一个 chunkIndex
重复提交。
通常可以:
覆盖原来的 5.part
或者:
检测已存在且校验一致
直接返回成功
这叫:
幂等
对于分片上传非常重要。
30. 合并操作也要幂等
假设:
POST /complete
第一次实际上已经合并成功。
但是客户端没收到响应,又请求一次:
POST /complete
服务器不应该:
再把文件乱合并一次
而应该发现:
status = SUCCESS
然后:
直接返回最终文件信息
所以:
上传分片
和:
完成合并
最好都考虑幂等性。
31. 临时分片什么时候删除?
假设用户:
上传到 20%
然后直接关闭浏览器。
服务器留下:
20 个临时 Chunk
如果永远不删除:
1万个用户
↓
大量废弃 Chunk
↓
磁盘最终被占满
因此每个上传任务通常需要:
expireAt
比如:
24小时没有继续上传
自动清理:
upload_task
+
temp chunks
可以用:
定时任务
扫描:
status != SUCCESS
AND
updated_at < 当前时间 - 24小时
然后删除。
32. 一个比较完整的系统架构
普通单机版:
Browser
↓
Spring Boot
↓
Temporary Directory
↓
Merge
↓
Final File
进阶版本:
Browser
│
▼
Upload API
│
┌─────────┴─────────┐
▼ ▼
Redis / DB Object Storage
│ │
│ ▼
│ Multipart Upload
│ │
└───────────┬───────┘
▼
Complete
│
▼
Final File
如果文件上传成功以后还要解析:
Browser
↓
Upload Service
↓
MinIO
↓
Kafka
↓
File Parse Service
↓
Apache Tika
↓
Text
这样就能直接和前面学的:
Kafka
Apache Tika
串起来。
33. 一个真实业务案例
假设做:
企业知识库
用户上传:
3GB.zip
系统完整流程可能是:
用户选择文件
↓
计算文件 SHA-256
↓
请求服务器检查文件
↓
不存在
↓
创建 Upload Task
↓
生成 uploadId
↓
文件切片
↓
并发上传 Chunk
↓
单个 Chunk 失败自动重试
↓
所有 Chunk 完成
↓
Complete Multipart Upload
↓
校验最终文件
↓
状态 SUCCESS
↓
Kafka 发送 file-uploaded
↓
Tika Parser
↓
提取文件内容
↓
文本切片
↓
Embedding
↓
Vector DB
这里每个组件职责很清楚:
Upload Service
负责文件上传
Object Storage
负责文件存储
Kafka
负责任务解耦
Tika
负责文件解析
Embedding
负责文本向量化
Vector DB
负责向量检索
34. 最容易踩的坑
分片上传看起来只是:
切
传
合
但是生产环境真正容易出问题的是:
分片顺序错误
重复上传
分片丢失
分片损坏
并发太高
上传任务状态不一致
多个服务实例找不到同一批 Chunk
合并过程中服务重启
完成接口重复调用
临时文件没有清理
客户端关闭以后留下垃圾文件
大文件 Hash 计算耗时过长
Nginx 超时
网关限制 Body 大小
Spring Boot Multipart 限制
磁盘空间不足
真正做生产系统时,这些往往比:
file.slice()
本身重要得多。
35. 最终总结
分片上传最核心的模型其实非常简单:
大文件
↓
Split
↓
┌───────────┼───────────┐
↓ ↓ ↓
Chunk 0 Chunk 1 Chunk 2
↓ ↓ ↓
Upload
↓
Temporary Storage
↓
Merge
↓
完整文件
第一阶段只需要记:
分片上传
=
切片
+
逐片上传
+
最终合并
第二阶段再加入:
uploadId
+
chunkIndex
+
totalChunks
第三阶段加入:
断点续传
+
失败重试
+
幂等
第四阶段加入:
fileHash
+
chunkHash
+
秒传
+
完整性校验
第五阶段进入生产架构:
对象存储 Multipart Upload
+
DB / Redis
+
任务过期清理
+
限流
+
并发控制
+
监控
最终你可以把完整的大文件上传体系记成:
文件
↓
Hash
↓
检查秒传
↓
初始化任务
↓
uploadId
↓
切片
↓
查询已有分片
↓
并发上传缺失分片
↓
失败重试
↓
Complete / Merge
↓
校验最终文件
↓
MinIO / S3 / OSS
↓
上传完成
到这里,“分片上传”本身已经算真正入门了。