对10G大文件上传方案的完整拆解 qq牛牛机器人
一、核心挑战:为什么10G文件不能"一把梭"
| 问题 | 表现 |
|------|------|
| HTTP限制 | Nginx 默认 clientmaxbody_size 仅 1MB;Apache 默认 2GB |
| 内存压力 | 服务端一次性加载10G文件到内存,OOM 风险极高 |
| 网络抖动 | 传了20分钟,Wi-Fi断一下,全部白费 |
| 无进度感知 | 用户只能干等,不知道传了多少 |
| 超时风险 | 大文件耗时远超默认请求超时,连接被断开 |
结论:传统 FormData 一次性提交在10G面前全部失效。[citation:27.3][citation:27.56]
---
二、三合一方案架构
生产级大文件上传需要三个能力组合:[citation:27.47]
| 能力 | 解决什么问题 | 核心原理 |
|:----:|------------|---------|
| 分片并发 | 速度快 + 失败代价小 | 切成5MB小片,多片并行上传 |
| 断点续传 | 传一半断了不白干 | 记录已传分片,续传只传缺失的 |
| 秒传 | 相同文件不必重复传 | 计算文件MD5,服务端命中即跳过 |
整体流程:
客户端 服务端
│ │
├─ 计算文件 MD5 ────────────────→ 查询文件是否已存在
│←─ 已存在(秒传命中)───────────┤
│←─ 不存在,返回uploadId ────────┤
│ │
├─ 文件切片(5MB/片) │
├─ 多片并发上传(带序号+hash)───→ 暂存分片(MinIO)
├─ 查询已上传分片 ──────────────→ 返回缺失列表
├─ 续传缺失分片 ────────────────→
│ ... │
├─ 请求合并 ────────────────────→ 零拷贝合并+MD5校验
│←─ 返回最终文件地址 ─────────────┤
---
三、关键实现细节
3.1 前端:WebWorker 增量哈希 + Promise 并发池
// 文件分片 + MD5 计算(放在 WebWorker 中避免阻塞主线程)
const CHUNK_SIZE = 5 1024 1024; // 5MB
async function calculateHash(file) {
const chunks = [];
let offset = 0;
while (offset < file.size) {
chunks.push(file.slice(offset, offset + CHUNK_SIZE));
offset += CHUNK_SIZE;
}
// 增量计算整个文件的 MD5(SparkMD5)
const spark = new SparkMD5.ArrayBuffer();
for (let chunk of chunks) {
spark.append(await chunk.arrayBuffer());
}
return spark.end();
}
// 并发控制:限制同时上传的分片数
async function uploadChunks(file, uploadId, concurrency = 5) {
const chunks = createChunks(file);
const pool = new Set(); // Promise 并发池
for (let [index, chunk] of chunks.entries()) {
const task = uploadSingleChunk(chunk, index, uploadId);
pool.add(task);
task.finally(() => pool.delete(task));
if (pool.size >= concurrency) {
await Promise.race(pool); // 等一个完成再继续加
}
}
await Promise.all(pool); // 等所有分片完成
}
关键点:SparkMD5 使用增量哈希而非全量加载,避免浏览器内存爆炸;并发池控制(通常 3-6 个并发)既能压满带宽又不至于打垮服务端。[citation:27.48]
3.2 服务端:MinIO + Redis + RabbitMQ + 零拷贝合并
文章采用的后端技术栈:MinIO(对象存储暂存分片)+ Redis(记录上传进度)+ RabbitMQ(异步通知合并)。
// 分片合并:使用 FileChannel.transferTo 零拷贝
@PostMapping("/merge")
public Result merge(@RequestParam String uploadId,
@RequestParam String filename) {
String tempDir = getChunkDir(uploadId);
String targetPath = getTargetPath(filename);
try (FileChannel out = new FileOutputStream(targetPath).getChannel()) {
// 按分片序号依次合并
for (int i = 0; i < totalChunks; i++) {
File chunkFile = new File(tempDir, i + ".part");
try (FileChannel in = new FileInputStream(chunkFile).getChannel()) {
// 零拷贝:从分片文件直接传输到目标文件
in.transferTo(0, in.size(), out);
}
}
}
// 合并完成后,校验全文件MD5
// 清理临时分片目录
return Result.success(targetPath);
}
零拷贝(transferTo)减少了数据在内核态和用户态之间的拷贝次数,大文件合并的主要性能优化点就在这里。
3.3 秒传实现
// 上传前先查询
async function checkFileExists(md5) {
const res = await fetch(/api/file/exists?md5=${md5});
const data = await res.json();
if (data.exists) {
return data.fileUrl; // 秒传命中,直接返回已有文件地址
}
return null; // 传完整的文件
}
MD5 是秒传的唯一依据——只要文件内容不变,MD5 就不变,和文件名无关。[citation:27.52]
3.4 断点续传
// 上传前先查已上传的分片
async function getUploadedChunks(uploadId) {
const res = await fetch(/api/file/chunks?uploadId=${uploadId});
return await res.json(); // 返回已上传的分片序号列表
}
// 只上传缺失的分片
async function uploadRemaining(file, uploadId) {
const uploaded = await getUploadedChunks(uploadId);
const chunks = createChunks(file);
for (let [index, chunk] of chunks.entries()) {
if (uploaded.includes(index)) continue; // 跳过已上传的
await uploadSingleChunk(chunk, index, uploadId);
}
}
---
四、关键参数设计
| 参数 | 推荐值 | 说明 |
|:----:|:-----:|------|
| 分片大小 | 2MB - 10MB | 5MB 是最常见的选型,平衡并发数和重传代价 |
| 并发数 | 3 - 8 | 带宽越宽并发可以越高,普通宽带建议 5 |
| 超时时间 | 120s/片 | 单片 5MB 在普通网络环境下几十秒内能完成 |
| 重试次数 | 3 次 | 失败自动重试,退避策略用指数退避 |
| 临时保留 | 24h | 未完成的分片数据保留 24 小时后自动清理 |
---
五、三种常见异常兜底
| 异常 | 处理方式 |
|------|---------|
| 单片上传失败 | 自动重试 3 次(指数退避),3 次都不行才报错 |
| 网络中断 | 客户端记录 uploadId 和已传分片列表,恢复网络后断点续传 |
| 服务器合并失败 | 分片数据保留,重试合并;清理不完整的目标文件,重新触发合并 |
---
六、一句话总结
10G 文件不能整传的核心原因是内存放不下、网络靠不住。 解决方案是三层组合:分片并发(把10G切成2000个5MB并发传)→ 秒传(MD5命中即跳过)→ 断点续传(失败只重传单个分片)。前端用 WebWorker 算哈希 + Promise 并发池控制流量,后端用 MinIO 暂存分片 + 零拷贝合并。按这个方案,10G 文件在普通千兆内网下约 2-3 分钟可完成上传,弱网公网环境下也能稳定跑完。
牛牛机器人 / 牛牛算账机器人版权声明:以上内容作者已申请原创保护,未经允许不得转载,侵权必究!授权事宜、对本内容有异议或投诉,敬请联系网站管理员,我们将尽快回复您,谢谢合作!