5938 字
约 19 分钟
2
分片上传入门文档

分片上传入门文档

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

                   ↓

                上传完成

到这里,“分片上传”本身已经算真正入门了

分片上传入门文档
http://www.clxhxhhr.top/posts/562/
作者
clxstart
发布于
2026-09-11
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。