从 1GB 文件上传看分片上传、断点续传与异步处理
在很多系统中,用户需要上传几十 MB、几百 MB,甚至几个 GB 的文件。如果直接通过一次 HTTP 请求上传整个文件,会遇到很多问题:
- 文件太大,请求持续时间很长;
- 网络短暂中断后,需要重新上传;
- 应用服务器可能占用大量内存;
- 上传接口容易超时;
- 文件解析、文本切片、向量化等后续任务会拖慢接口响应。
因此,比较成熟的做法是:
分片上传 + 断点续传 + 对象存储 + 状态管理 + 异步处理。
下面以一个 1GB 文件为例,介绍这套方案是如何工作的。
一、先把大文件切成小分片
假设每个分片大小为 5MB,那么 1GB 文件大约会被切成 205 个分片。
file.part-001
file.part-002
file.part-003
...
file.part-205
前端负责切片,并发上传多个分片。每个分片请求中通常会携带:
fileHash 整个文件的哈希
uploadId 本次上传任务的唯一 ID
partNumber 分片序号
partHash 当前分片的哈希
分片大小不是固定的。小分片更容易重试,但请求数量更多;大分片请求数量少,但单次失败需要重传的数据更多。实际项目中常见的大小有 5MB、10MB、20MB 或更大。
二、文件哈希和分片哈希分别做什么
很多人会把 MD5 理解成“判断分片属于哪个文件”,但实际职责可以进一步拆分。
1. 整个文件的哈希
整个文件的 MD5 或 SHA-256 可以用于:
- 判断两个文件内容是否相同;
- 实现秒传;
- 断点续传时找到之前的上传任务;
- 防止同一个文件被重复上传。
例如,前端计算出:
fileHash = abc123
后端可以先查询这个文件是否已经上传完成。如果已经存在,就不必重复上传。
2. 单个分片的哈希
分片哈希主要用于校验:
- 分片内容是否完整;
- 网络传输过程中是否损坏;
- 重试上传时内容是否一致。
但分片属于哪个上传任务,通常不是单纯依靠 MD5,而是依靠:
uploadId + partNumber
其中 uploadId 标识一次上传任务,partNumber 标识分片顺序。
三、MinIO 负责保存文件分片
后端收到分片后,可以将它保存到 MinIO 中,例如:
uploads/{uploadId}/part-001
uploads/{uploadId}/part-002
uploads/{uploadId}/part-003
更推荐的方式是:后端只负责创建上传任务并生成预签名 URL,前端直接把分片上传到 MinIO。
这样可以减少应用服务器的带宽、内存和 CPU 压力。
整体流程如下:
flowchart TD
A[前端切片] --> B[创建上传任务]
B --> C[获得 uploadId 和上传地址]
C --> D[并发上传分片到 MinIO]
D --> E[记录分片完成状态]
E --> F[检查是否全部完成]
F --> G[MinIO 服务端合并]
G --> H[更新 MySQL 文件状态]
H --> I[发送 Kafka 消息]
I --> J[异步解析和向量化]
四、Redis Bitmap 如何实现断点续传
Redis Bitmap 很适合记录分片上传进度。
假设一个文件有 8 个分片:
分片序号: 0 1 2 3 4 5 6 7
上传状态: 1 1 0 1 0 0 1 0
其中 1 表示已经上传成功,0 表示还没有上传。
当网络断开后,前端重新请求上传状态:
GET /upload/status?uploadId=xxx
后端返回已经完成的分片:
[0, 1, 3, 6]
前端只需要补传第 2、4、5、7 片,不需要从头上传。
这就是断点续传。
不过,Redis Bitmap 更适合作为高速缓存,而不建议作为唯一的数据来源。因为 Redis 可能过期、被淘汰,或者因为故障导致数据丢失。
生产系统通常会采用以下组合:
- Redis Bitmap:快速查询上传进度;
- MySQL 分片表或 MinIO Multipart 状态:保存可恢复的信息;
- 定时校准任务:发现 Redis 和实际对象不一致时重新修复。
五、MySQL 管理文件的业务状态
MySQL 主要保存文件元信息和业务状态,例如:
file_id
file_name
file_size
file_hash
upload_id
uploader_id
organization_id
status
object_key
created_at
updated_at
状态可以设计成:
INIT
UPLOADING
MERGING
COMPLETED
MERGE_FAILED
PROCESSING
PROCESSING_FAILED
需要注意的是,上传状态、合并状态和解析状态最好不要混在一起。
例如:
上传完成 ≠ 文件解析完成
文件合并成功 ≠ 向量化成功
这样系统出现问题时,才能准确判断到底是哪一步失败。
六、MinIO 合并是否具备原子性
MinIO 的 composeObject 可以在存储端直接把多个对象合并成一个新对象,不需要应用服务器把文件全部读出来。
它具有较好的对象层原子性:
- 合并完成后,目标对象才对外可见;
- 合并失败通常不会产生一个“半成品”的最终对象;
- 应用服务器不需要承载整个文件的数据流。
但是,这并不意味着整个业务具备分布式事务。
例如,可能出现:
MinIO 合并成功
MySQL 更新失败
也可能出现:
MySQL 显示已完成
Kafka 消息发送失败
因此,正确做法不是依赖单一组件的原子性,而是采用:
- 明确的状态机;
- 幂等接口;
- 可重试操作;
- 超时检测;
- 定时校准任务。
七、合并失败如何处理
合并时可以按照下面的步骤执行:
- 将 MySQL 状态更新为
MERGING; - 检查所有分片是否存在;
- 调用 MinIO 的合并接口;
- 校验最终文件大小、分片数量或哈希;
- 更新 MySQL 状态为
COMPLETED; - 发送后续处理消息;
- 最后清理临时分片和 Redis 记录。
如果合并失败,不要立即删除分片,而是:
MERGING → MERGE_FAILED
同时保留:
- 分片文件;
- 上传任务记录;
- 错误原因;
- 重试次数;
- 最近一次重试时间。
之后可以由用户主动重试,或者由后台任务自动重试。
如果目标文件已经成功生成,但 MySQL 更新失败,下一次重试时应先检查目标对象是否存在,避免重复创建。也就是说,合并接口必须具备幂等性。
八、合并完成后,Kafka 负责异步处理
文件合并成功后,可以发送一条 Kafka 消息:
{
"fileId": 10001,
"objectKey": "files/10001.pdf",
"fileHash": "abc123"
}
后台消费者收到消息后,执行:
下载或读取文件
↓
文件解析
↓
文本切片
↓
生成向量
↓
写入向量数据库
↓
更新处理状态
这样上传接口不需要等待这些耗时任务完成,用户可以更快得到“上传成功”的反馈。
如果解析失败,可以设计成:
PROCESSING → PROCESSING_FAILED
然后通过 Kafka 重试、死信队列或人工重新触发处理。
九、如何避免超大文件导致 OOM
避免 OOM 的核心原则是:
不要把整个文件一次性读进应用服务器内存。
具体措施包括:
- 使用流式读写;
- 使用固定大小的缓冲区;
- 前端直接上传到 MinIO;
- 使用 MinIO 或 S3 原生 Multipart Upload;
- 限制单个用户和全局并发数;
- 限制后台解析任务的并发度;
- 避免调用
getBytes()这类一次性读取方法; - 合并时使用 MinIO 的服务端能力;
- 对超大文件采用分批解析和分批向量化。
无论文件是 1GB 还是 10GB,应用服务器都不应该因为文件大小而分配同等规模的内存。
十、市面上的主流方案
目前主流方案主要有以下几类:
1. S3 或 MinIO Multipart Upload
这是最常见的对象存储方案,支持:
- 分片上传;
- 失败重试;
- 断点续传;
- 服务端合并;
- 分片校验;
- 未完成任务清理。
2. 云厂商 OSS 分片上传
阿里云 OSS、腾讯云 COS、七牛云等都提供类似能力,通常由官方 SDK 完成大部分细节。
3. 预签名 URL 直传
后端生成临时上传地址,前端直接上传对象存储。后端不经过文件数据流,是现在比较推荐的架构。
4. tus 协议
tus 是一种开放的可续传上传协议,适合需要自建上传服务、并希望统一客户端行为的场景。
5. 前端上传组件
例如一些支持切片、并发、重试和断点续传的前端库,可以减少前端重复开发。
总结
一套比较成熟的大文件上传方案通常是:
文件哈希
↓
创建 uploadId
↓
文件切片
↓
分片直传 MinIO
↓
Redis 加速记录进度
↓
数据库保存业务状态
↓
MinIO 服务端合并
↓
状态更新为 COMPLETED
↓
Kafka 异步解析和向量化
↓
失败重试与定时校准
这套设计的本质是:
把一个容易失败的大操作,拆分成多个小的、可重试、可恢复、可校准的操作。
最终需要重点保证四件事:
- 分片上传可以断点续传;
- 合并过程具备幂等性;
- 上传、合并、解析状态彼此独立;
- 任何失败都能重试、恢复或被后台任务发现。