一、大文件切片上传怎么设计?
大文件上传我一般会拆成:切片、并发上传、断点续传、秒传、失败重试和最终校验这几块,核心是让上传可以暂停、失败后继续,而且不用从头再传。
面试官继续追问后的展开
1. 为什么要切片?
如果一个 1.5GB 视频直接上传:
text
1.5GB File
↓
一个 HTTP 请求
↓
上传失败
↓
基本只能重新上传
切片之后:
text
1.5GB File
↓
┌────┬────┬────┬────┬────┬────┐
│ 0 │ 1 │ 2 │ 3 │ ...│ N │
└────┴────┴────┴────┴────┴────┘
↓
分别上传
↓
服务端保存各个 chunk
↓
全部完成
↓
按顺序合并
前端核心 API 就是:
js
const chunk = file.slice(start, end);
但 slice() 本身根本不是这道题的难点。
真正的难点是:
切完以后,怎么可靠地把这些 chunk 上传、恢复、校验并最终合并。
2. 切片之后为什么不能直接 Promise.all()?
这是第一个高频追问。
比如:
js
await Promise.all(
chunks.map(chunk => upload(chunk))
);
代码虽然简单,但实际上等于:
有多少切片就同时发多少请求。但不应该一次性把所有 chunk 都启动上传。
如果一个 1GB 文件按 1MB 切,就是大约 1024 个请求。
这会带来:
- 浏览器连接和请求调度压力
- 服务端瞬时并发压力
- 带宽竞争
- 大量 Promise 和请求对象
- 某个网络环境下整体吞吐反而下降
所以实际应该做:固定并发数 + 任务队列。
例如:
text
chunk queue
│
┌───────┼───────┐
↓ ↓ ↓
worker1 worker2 worker3
↓ ↓ ↓
chunk0 chunk1 chunk2
↓
完成后继续取下一个
例如限制:
js
const CONCURRENCY = 4;
4 个请求完成一个,再从队列取下一个。
这里还有一个容易被问的点
并发数不是固定的"4~6 个最佳"。
它取决于:
- HTTP 版本
- 浏览器
- 网络质量
- chunk 大小
- 服务端处理能力
- 带宽
- 是否走 CDN / 对象存储
所以高级回答不要说:
HTTP/1.1 最多只能 6 个,所以设置 6。
更准确的说法是:
HTTP/1.1 同源连接数受浏览器限制,而 HTTP/2 可以在单连接上复用多个请求;实际并发数还是应该根据网络和服务端吞吐做控制,而不是死记一个数字。
3. 怎么实现断点续传?
这是这道题真正开始拉开差距的地方。
错误思路
text
浏览器 localStorage
↓
记录 chunk 0、1、2、3 已上传
这个方案只能解决:
同一台设备、同一个浏览器、缓存没有被清掉的情况下恢复。
但它解决不了:
text
清缓存
换浏览器
换电脑
换手机
重新登录
所以断点续传真正应该以服务端状态为准。
正确流程
用户选择文件:
text
File
↓
计算 fileHash
↓
POST /upload/check
│
├── 文件已经存在 → 秒传
│
└── 文件不存在
↓
返回已上传 chunks
↓
前端过滤已上传 chunk
↓
只上传缺失 chunk
例如服务端返回:
json
{
"uploadedChunks": [0, 1, 2, 5, 6]
}
那么:
text
0 ✓
1 ✓
2 ✓
3 → 上传
4 → 上传
5 ✓
6 ✓
7 → 上传
这样网络断在 99%,重新上传的时候:不是从 0 开始,而是继续上传缺失部分。
4. 服务端怎么知道这是哪个文件?
这里就需要一个非常关键的概念:
文件唯一标识
通常可以基于:
text
fileHash
再结合:
text
chunkIndex
来定位 chunk。
例如:
text
fileHash = abc123
chunk:
abc123_0
abc123_1
abc123_2
...
或者服务端使用:
text
uploadId + chunkIndex
这种设计通常更稳妥。
因为:
文件身份和一次上传任务的身份,不一定应该完全绑定在一起。
例如同一个文件:
text
fileHash = abc123
可能同时出现:
text
uploadId=A
uploadId=B
这就涉及后面的并发上传同一个文件问题。
5. 秒传到底是什么?
秒传本质上不是"上传很快"。而是:
服务端发现自己已经有这个文件,所以直接复用已有文件,不再上传文件内容。
流程:
text
用户选择文件
↓
计算 fileHash
↓
POST /upload/check
↓
服务端查询 fileHash
↓
存在
↓
直接返回成功
所以:
text
1.5GB 文件
↓
只需要上传/计算文件指纹
↓
服务端发现已有
↓
直接完成
这才叫秒传。
6. 那么 fileHash 怎么算?
这里又是一个很容易被忽略的性能问题。如果你直接:
text
读取整个 1.5GB 文件
↓
计算 MD5
就可能造成:
- CPU 消耗
- 主线程卡顿
- 内存压力
- 用户界面卡顿
所以实际可以:
text
File
↓
分块读取
↓
Hash Worker
↓
计算 fileHash
也可以使用:
text
Web Worker
把计算放到 Worker 中,避免阻塞主线程。
7. 文件 Hash 和 Chunk Hash 是一回事吗?
不是。 可以分别存在:
text
fileHash
和:
text
chunkHash
例如:
text
fileHash = SHA256(file)
chunk0Hash = SHA256(chunk0)
chunk1Hash = SHA256(chunk1)
chunk2Hash = SHA256(chunk2)
这样服务端可以进一步校验:
text
上传 chunk
↓
服务端计算/校验 chunkHash
↓
一致 → 接收
不一致 → 重新上传这个 chunk
这样比等到整个文件合并之后才发现错误更加高效。
8. 如果某一个切片上传失败怎么办?
不能因为一个 chunk 失败:
text
chunk 0 ✓
chunk 1 ✓
chunk 2 ✗
chunk 3 ✓
chunk 4 ✓
然后:
text
全部重新上传
应该:只重试失败的 chunk。
例如:
text
chunk2
↓
失败
↓
retry 1
↓
失败
↓
retry 2
↓
失败
↓
retry 3
↓
仍然失败
↓
任务失败
重试一般配合:
text
指数退避
避免网络异常的时候大量请求同时再次打到服务端。
9. 网络断了,怎么恢复?
这里其实有两种情况。
情况一:页面没刷新
前端可以保留当前上传任务状态:
text
uploadId
fileHash
uploadedChunks
网络恢复之后:
text
重新查询服务端
↓
获取最新 uploadedChunks
↓
继续上传缺失 chunk
情况二:页面刷新甚至换设备
这时候前端自己的内存状态全部没了。所以仍然依赖:
text
fileHash
↓
服务端查询
↓
返回已经存在的 chunks
↓
继续上传
这也是为什么:
localStorage 最多只能做客户端辅助缓存,不能作为断点续传的最终依据。
10. 两个用户同时上传同一个文件怎么办?
这是非常好的追问。
例如:
text
User A ── upload ──┐
├── fileHash = abc123
User B ── upload ──┘
服务端不能简单认为:
text
A 创建文件
B 又创建一个完全相同的文件
应该围绕:
text
fileHash
做去重。例如:
text
fileHash = abc123
↓
服务端发现已经存在
↓
直接复用
但这里又有一个更深的问题:
文件可能正在上传,还没有最终完成。
例如:
text
User A
chunk 0 ✓
chunk 1 ✓
chunk 2 ✓
...
User B
↓
发现 abc123 正在上传
↓
复用已有上传进度
这就涉及:
- upload session
- 状态机
- 并发控制
- 幂等
- 数据库唯一约束
- 分布式锁 / CAS
11. Chunk 上传接口为什么必须幂等?
例如:
http
POST /upload/chunk
网络情况可能是:
text
客户端发送 chunk
↓
服务端已经成功保存
↓
响应丢失
↓
客户端以为失败
↓
再次上传同一个 chunk
如果服务端没有幂等设计,就可能:
text
重复保存
所以应该让:
text
uploadId + chunkIndex
或者:
text
fileHash + chunkIndex
成为唯一定位。再次上传相同 chunk 时:
text
已经存在 → 直接返回成功
不存在 → 保存
这就是高级工程设计里非常重要的:幂等性。
12. 所有 Chunk 都上传完之后呢?
这时候才进入:
text
Merge
流程:
text
chunk0 ✓
chunk1 ✓
chunk2 ✓
...
chunkN ✓
↓
POST /upload/complete
↓
服务端确认所有 chunk 都存在
↓
按 chunkIndex 顺序合并
↓
生成最终文件
注意:前端不能简单告诉服务端"我上传完了",服务端应该自己再次确认。
13. 合并的时候怎么保证顺序?
例如:
text
chunk0
chunk1
chunk2
chunk3
必须按照:
text
0 → 1 → 2 → 3
合并。不能按照上传完成顺序:
text
2 → 0 → 3 → 1
因为:
上传顺序和文件顺序是两回事。
这也是为什么每个 chunk 必须带:
text
chunkIndex
14. 合并完成后怎么校验完整性?
这是非常关键的一问。
最直接的方案是:
text
原文件 fileHash
↓
上传 + 合并
↓
服务端对最终文件计算 hash
↓
比较
↓
一致 → 成功
不一致 → 异常
例如:
text
Client:
SHA-256(originalFile)
↓
abc123
Server:
SHA-256(mergedFile)
↓
abc123
↓
一致
↓
上传成功
15. 如果最终 Hash 不一致,是全部重传还是部分重传?
不能简单地说"部分重传"。
因为:
text
fileHash 不一致
只能说明:
最终文件和原文件不一致。
它本身不能直接告诉你:
"到底是 chunk 37 错了。"
如果每个 chunk 都有:
text
chunkHash
服务端就可以进一步定位:
text
chunk0 ✓
chunk1 ✓
chunk2 ✓
chunk3 ✗
...
那么:
只重新上传 chunk3。
所以更完整的设计是:
text
fileHash
│
↓
整体完整性校验
│
不一致?
│
↓
chunkHash 对比
│
┌────────┴────────┐
↓ ↓
找到异常 chunk 无法定位
↓ ↓
部分重传 整体重新处理
16. 合并是不是一定要同步完成?
不一定。如果是:
text
10MB 文件
同步合并可能完全没问题。
但如果是:
text
10GB
50GB
100GB
合并可能持续很长时间。这时候更合理的是:
text
POST /upload/complete
↓
创建 merge task
↓
立即返回
↓
后台异步合并
↓
状态:merging
↓
完成
↓
状态:completed
前端可以:
text
轮询
或者:
text
WebSocket
SSE
接收状态变化。
注意:WebSocket/SSE 不是大文件上传本身的必要条件,只是用于把异步合并状态通知给前端。
17. 整个链路怎么串起来?
这是这道题最值得背下来的一张图:
text
用户选择文件
│
↓
计算 fileHash
│
↓
/upload/check
│
┌──────────┴──────────┐
↓ ↓
文件已经存在 文件不存在
↓ ↓
秒传 返回 uploadId
│
↓
查询已上传 chunks
│
↓
过滤已上传 chunk
│
↓
并发队列上传
│
┌─────────────────┴──────────────┐
↓ ↓
成功 失败
│ │
│ 自动重试/指数退避
│ │
└──────────────┬─────────────────┘
↓
全部 chunk 完成
│
↓
/upload/complete
│
↓
服务端合并
│
↓
最终文件完整性校验
│
┌─────────┴─────────┐
↓ ↓
一致 不一致
↓ ↓
成功 定位异常 chunk
↓
重传
18. 这道题真正考什么?
可以把面试官的追问链压缩成:
text
会切片
↓
会并发控制吗?
↓
会断点续传吗?
↓
换电脑还能续传吗?
↓
能秒传吗?
↓
两个用户同时上传呢?
↓
请求重试会不会重复?
↓
合并怎么保证顺序?
↓
怎么校验完整性?
↓
校验失败怎么定位?
↓
大文件合并会不会阻塞?
↓
异常状态怎么恢复?
所以它考的其实不是:
Blob.slice()会不会。
而是:
你能不能把一个"文件上传 API"设计成一个可靠的端到端系统。
19. 面试官最容易继续追问的几个点
追问 1:localStorage 能不能实现断点续传?
不能作为最终方案。
它只能保存客户端状态,清缓存、换浏览器、换设备都会丢。
真正的上传进度应该以服务端已保存的 chunk 状态为准。
追问 2:为什么不用 Promise.all()?
因为切片数量可能非常大,不能让所有请求同时发出去,要用并发队列控制同时上传的数量。
追问 3:为什么需要 chunkIndex?
因为上传完成顺序是不确定的,服务端必须靠 chunkIndex 恢复文件原来的顺序。
追问 4:为什么需要 fileHash?
主要解决两个问题:
text
1. 判断是不是同一个文件
2. 实现秒传和断点续传
追问 5:为什么还需要 chunkHash?
因为 fileHash 只能告诉你最终文件对不对,chunkHash 可以进一步定位到底哪个切片有问题。
追问 6:上传接口为什么要幂等?
因为:
text
请求成功
↓
响应丢失
↓
客户端重试
可能导致同一个 chunk 被重复上传。所以服务端要能够安全处理重复请求。
追问 7:HTTP/2 之后还需要控制并发吗?
需要。 HTTP/2 解决的是多个请求共享连接的问题,但不代表服务端、浏览器和网络可以无限并发。并发控制依然是为了控制:
text
带宽
CPU
内存
服务端压力
请求队列
20. 最后给你一版真正适合面试开口的答案
大文件上传我一般不会只考虑"切片",而是把它做成一套完整的上传链路:切片、并发控制、断点续传、秒传、失败重试、合并和完整性校验。
前端先用 Blob.slice() 把文件切成多个 chunk,每个 chunk 带上 uploadId、chunkIndex 等信息,然后通过并发队列控制同时上传的请求数量,不能直接 Promise.all() 把所有切片一起发出去。
断点续传不能只依赖 localStorage,真正应该以服务端状态为准。用户重新上传时,先根据 fileHash 查询服务端已经有哪些 chunk,只上传缺失的部分,所以即使刷新页面、清缓存甚至换一台电脑,也能继续。
秒传也是基于 fileHash:服务端如果已经存在这个文件,就直接复用已有文件,不需要再次上传内容。
上传过程中,单个 chunk 失败只重试这个 chunk,并且接口要保证幂等,避免网络重试造成重复数据。
所有 chunk 上传完成后,再通知服务端按 chunkIndex 顺序合并。合并完成后校验最终文件的 fileHash;如果不一致,再通过 chunkHash 定位异常 chunk,优先做部分重传。
所以这道题真正考的不是会不会 slice(),而是能不能把大文件上传做成一个可恢复、可重试、可校验、能处理并发和异常的完整方案。