Chunked Upload Lab
原理拆解
沿着一次上传的数据流动逐层拆开:浏览器内切片与哈希、三态判定、并发上传、 服务端流式合并,最后是状态机、目录约定与当前实现的已知边界。
01
切片与哈希
文件进入工作台后先被 Blob.slice 切成 4 MiB 的分片——切割是惰性的,只有真正发送的那一片才会进内存。同时,SparkMD5 在 Web Worker 里按 2 MiB 的切片增量计算整文件 MD5,作为后续所有判定的唯一身份。
为什么分片是 4 MiB?
lib/upload/constants.ts,每个值都附了理由。Hash 为什么另用 2 MiB 的切片?
02
三态判定
POST /api/upload/check
哈希算完后,客户端拿着 MD5 问服务端「这个文件你还要多少」。服务端不查数据库, 直接看文件系统,给出三种结论之一:
| 结论 | 判定条件 | 客户端行为 |
|---|---|---|
| 秒传 | merged/<hash>.bin 已存在 | 直接完成,返回下载链接 |
| 续传 | chunks/<hash>/ 下有分片 | 跳过已上传 index,只传缺口 |
| 全新 | 两者都不存在 | 从 0 号分片开始完整上传 |
也就是说,「上传会话」不是一条数据库记录,而是目录里已经落盘的那些文件—— 暂停、刷新、隔天再传,都走同一条恢复路径。
03
并发与重试
POST /api/upload/chunk
缺口分片进入并发池:调度器启动时读取一次并发数快照,维持 N 个在途请求, 每完成一片立刻补下一片。单个分片以 multipart 提交,服务端先写临时文件再 rename 成 <index>.part——目录里出现的分片一定是完整的。
单片失败按 1s → 2s → 4s 指数退避重试,最多 3 次;7 秒内仍连不通就判定整个任务失败,把决定权交还给用户。
为什么默认并发是 4?
04
流式合并
POST /api/upload/merge
所有分片到齐后,服务端按 index 顺序把 .part 文件以流的方式拼接进 merged/<hash>.bin——读一段写一段,内存占用与文件大小无关。原文件名单独存在 <hash>.name 里。
下载走 GET /api/files/[hash],同样是流式响应,支持大文件边下边播。
05
客户端状态机
每个任务在客户端是一台 8 状态机,由 Zustand 持有;暂停本质上是 AbortController 中断在途请求,恢复则换一个新控制器从 check 重新进入管道:
hashing → checking ─┬─ instant
└─ uploading ⇄ paused
│
↓
merging → completed
│
└─ failed → (retry) → checkinginstant 与 completed 都视为完成态;failed 会保留错误信息,重试从 check 重新开始, 因此天然享受续传。
06
目录布局
服务端没有 manifest、没有数据库,文件系统布局本身就是上传会话状态:
.uploads/ ├── chunks/<fileHash>/<index>.part # 上传中 ├── merged/<fileHash>.bin # 合并完成 └── merged/<fileHash>.name # 原文件名
07
已知边界
- 任务列表不持久化——刷新即丢;但服务端文件系统状态保留
- 单实例 server,未引入文件锁
- 无清理机制(孤儿 chunks 不会自动 GC)
- 无鉴权(任何人持 hash 即可下载)
- chunk size 写死 4 MiB(
lib/upload/constants.ts里改);并发数 UI 可调
完整设计文档:docs/superpowers/specs/2026-06-06-next-upload-design.md