Chunked Upload Lab

Chunked Upload Lab

原理拆解

沿着一次上传的数据流动逐层拆开:浏览器内切片与哈希、三态判定、并发上传、 服务端流式合并,最后是状态机、目录约定与当前实现的已知边界。

01

切片与哈希

文件进入工作台后先被 Blob.slice 切成 4 MiB 的分片——切割是惰性的,只有真正发送的那一片才会进内存。同时,SparkMD5 在 Web Worker 里按 2 MiB 的切片增量计算整文件 MD5,作为后续所有判定的唯一身份。

为什么分片是 4 MiB?
太小(如 256 KiB)时 1 GB 文件会切出 4000+ 个 HTTP 请求,握手开销暴涨; 太大(如 50 MiB)时暂停 / 重试粒度太粗,单次失败重传太贵。 上限则由部署平台决定:Vercel Functions 请求体硬上限 4.5 MB(超限直接 413, 不可配置),4 MiB 为 multipart 信封留出余量。常量在 lib/upload/constants.ts,每个值都附了理由。
Hash 为什么另用 2 MiB 的切片?
Hash 切片只影响内存占用与 FileReader 调用次数,与上传协议无关,所以与 4 MiB 解耦:2 MiB 足够大(1 GB 文件只读 512 次),又足够小(Worker 内不会一次分配过大的 ArrayBuffer)。

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?
浏览器对同一来源的连接上限通常是 6(Chrome / Firefox / Safari 一致)。留 2 个槽位给页面自身的其他请求(API 探活、下载预览等),所以默认 4,滑杆允许在 1–8 之间调整。调整对新任务立即生效;进行中的槽位不变,恢复 / 重试时按新值调度。

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) → checking

instant 与 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