关键词:Vue 3、TypeScript、Web Worker、文件切片、并发池、断点续传、秒传、指数退避、Spring Boot、幂等、原子合并、完整性校验
项目地址:Coder-XO/big-file-upload-platform
上传一个普通头像,只需要把 File 放进 FormData 再发给服务器。但当文件变成数 GB 的视频、安装包或数据归档时,问题会突然复杂起来:单次请求容易超时,网络抖动会让用户从头再来,读取整个文件会挤占浏览器内存,计算哈希会卡住页面,过高并发又可能把网络和服务端一起压垮。
因此,大文件上传真正值得讨论的不是调用 Blob.prototype.slice() 这一行代码,而是如何建立一条可恢复、可校验、可观测的传输流水线。
本文以 ChunkFlow 项目为例,完整说明 Vue 3 前端和 Spring Boot 服务端如何共同完成这件事。
一、项目目标与技术方案
ChunkFlow 不是简单地把一个文件拆成多次请求,而是围绕"可靠传输"设计了一套完整闭环:
- 在 Web Worker 中逐片读取文件并计算内容指纹;
- 使用指纹初始化上传任务,优先判断能否秒传;
- 获取服务端已经完整落盘的分片索引;
- 计算缺失分片,并通过受控并发池进行补传;
- 对失败请求执行指数退避重试,允许随时暂停和继续;
- 服务端以幂等方式保存分片,按索引完成合并;
- 校验成品总大小和 MD5,通过后再原子发布文件。
项目采用以下技术方案:
| 层级 | 技术与职责 |
|---|---|
| 前端基础 | Vue 3、TypeScript、Vite、Pinia |
| 内容指纹 | Web Worker、SparkMD5、分片增量计算 |
| 任务调度 | 可配置并发池、AbortController、指数退避 |
| 状态同步 | 以服务端已落盘分片索引为准,只补传缺片 |
| 服务端 | Java 17、Spring Boot、Multipart、文件流 |
| 数据可靠性 | 临时写入、原子移动、任务锁、顺序合并、MD5 复核 |
| 状态持久化 | 上传会话、分片文件、成品元数据及哈希索引落盘 |
| 文件访问 | 标准 Resource 响应与 HTTP Range 请求 |
断点续传不能依赖一个简单的"最后分片索引"。在并发上传场景中,分片可能乱序完成,例如服务端已经收到 [0, 1, 3],但第 2 片仍然缺失。因此,接口返回 uploadedChunks: number[],前端通过全集与已上传集合的差集计算真正需要补传的分片。
二、系统边界:前端和服务端分别负责什么
一个清晰的职责边界比某个"高级 API"更重要。
前端负责:
- 选择文件和展示交互状态;
- 按需读取文件切片;
- 在 Worker 中计算内容指纹;
- 查询服务端已有分片;
- 控制并发、暂停和重试;
- 汇总各分片进度,计算速度与剩余时间。
服务端负责:
- 校验文件元数据和分片参数;
- 以磁盘事实为准判断断点;
- 幂等保存每个分片;
- 对同一上传任务加锁,避免并发合并;
- 按索引顺序合并;
- 校验合并后的大小和内容指纹;
- 建立成品索引,为下一次相同文件提供秒传。
三、难点一:哈希不能卡住页面,也不能吃掉数 GB 内存
1. 为什么要计算文件指纹
指纹承担两个任务:
- 识别用户重新选择的是不是同一份文件,从而恢复上传;
- 查询服务端是否已有相同成品,从而实现秒传。
只使用"文件名 + 文件大小"并不可靠,因为两个不同文件可能恰好同名同大小。ChunkFlow 读取真实内容并使用 SparkMD5 增量计算。
2. Worker 不等于自动省内存
把代码放进 Web Worker 只能避免阻塞主线程,并不会自动降低内存占用。如果先把所有切片都转成 ArrayBuffer 后放进数组,那么 2GB 文件仍可能对应接近 2GB 的额外内存。
正确方式是每次只读取一片,用完立即交给增量哈希器。slice() 定义在 Blob 接口上,而 File 继承自 Blob,所以文件对象可以直接写成 file.slice(start, end):
ts
const spark = new SparkMD5.ArrayBuffer()
const totalChunks = Math.ceil(file.size / chunkSize)
for (let index = 0; index < totalChunks; index += 1) {
const start = index * chunkSize
const end = Math.min(file.size, start + chunkSize)
const buffer = await file.slice(start, end).arrayBuffer()
spark.append(buffer)
postMessage({
type: 'progress',
progress: Math.round(((index + 1) / totalChunks) * 100),
})
}
postMessage({ type: 'done', hash: spark.end() })
这样做有三个直接收益:
- 主线程可以继续渲染动画和响应点击;
- 峰值额外内存接近单个分片,而不是整个文件;
- 哈希进度可以独立呈现,避免用户面对"没有反应"的页面。
3. 为什么项目仍使用 MD5
MD5 不适合密码、签名或对抗恶意碰撞。但这里的目标是非安全场景下的内容去重指纹,重点是增量计算速度和前后端一致性。项目还会在服务端合并后重新计算 MD5,防止传输损坏或错误合并。
如果项目面对高安全内容或不可信攻击者,应改用 SHA-256 或 BLAKE3,并同时考虑分片级校验、租户隔离和访问控制。
四、难点二:并发不是越高越好
串行上传最简单,但无法充分利用网络;无限并发看起来快,却可能产生更多超时、重传和服务端压力。
ChunkFlow 使用一个非常小的任务池:维护共享游标,启动固定数量的消费者,每个消费者取出下一个缺失索引并上传。
ts
async function runConcurrent<T>(
items: T[],
limit: number,
worker: (item: T) => Promise<void>,
shouldStop: () => boolean,
) {
let cursor = 0
async function consume() {
while (!shouldStop()) {
const current = cursor++
if (current >= items.length) return
await worker(items[current])
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, consume),
)
}
默认并发数为 3,用户可以在界面中切换。这个数不是"万能最优值",它只是一种保守默认:移动网络、内网、云存储直传和本地服务的最佳并发度都不同。更进一步的方案可以根据近期吞吐量、失败率和 RTT 动态调节并发。
分片顺序怎么保证
并发上传意味着到达顺序不可预测,因此不能依赖"最后到达的分片"。每个分片必须携带稳定索引,服务端按索引命名:
text
00000000.part
00000001.part
00000002.part
...
最终合并时按索引升序读取即可。上传顺序和合并顺序由此解耦。
五、难点三:暂停、重试与续传其实是同一个状态问题
1. 暂停不能只改按钮文字
真正的暂停至少要完成两件事:
- 停止并发池继续领取新任务;
- 使用
AbortController终止仍在传输的请求。
已完整落盘的分片仍然保留。用户点击继续时,前端不会迷信本地状态,而是再次调用初始化接口,与服务端磁盘对账后只上传缺片。
2. 网络错误需要退避,而不是瞬间连点三次
每个分片最多尝试三次,等待时间按 500ms、1000ms、2000ms 增长。指数退避能给短暂故障恢复时间,也能避免大量失败请求同时冲击服务器。
同时要区分两类异常:
- 用户主动暂停导致的取消:任务进入
paused,不展示为错误; - 网络或服务端异常:重试耗尽后进入
error,保留继续入口。
3. 为什么服务端必须返回索引集合
上传状态不能只记录在内存,也不能只写一个"最大索引"。假设分片 0、1、3 已成功,分片 2 因网络问题失败,如果只返回最大索引 3,前端就会误以为前四片全部存在。
服务端应该扫描并验证真实文件,返回:
json
{
"uploadId": "...",
"uploadedChunks": [0, 1, 3],
"completed": false
}
前端通过全集减去已上传集合得到 [2, 4, 5, ...],这才是可靠的增量上传。
六、难点四:分片接口必须幂等
网络超时并不等于服务端没有成功写入。如果客户端因为没收到响应而重试,同一个分片可能到达两次。
ChunkFlow 的处理流程是:
- 根据元数据计算该索引应有的准确大小;
- 如果目标分片已存在且大小正确,直接返回成功;
- 否则先写入带随机后缀的临时文件;
- 检查临时文件大小;
- 使用原子移动替换目标分片。
text
2.uploading-a1b2c3 --写完并校验--> 00000002.part
为什么不直接写最终文件?因为进程崩溃、磁盘异常或连接中断可能留下半个分片。临时写入配合原子移动,让"目标文件存在"尽可能代表"这个分片已经完整"。
七、难点五:合并不是简单地把文件拼起来
服务端收到"开始合并"请求后,需要按顺序完成以下检查:
- 所有索引是否都存在;
- 每一片的大小是否符合预期,最后一片允许小于固定分片大小;
- 合并后总大小是否等于原文件大小;
- 合并后 MD5 是否等于客户端初始化时提供的内容指纹。
只有全部通过,才把临时合并文件原子移动到正式目录,并写入成品元数据与哈希索引。
合并操作还必须针对同一个 uploadId 加锁,否则两个完成请求可能同时读分片、同时写成品。当前项目使用进程内 ReentrantLock,适合单机部署;如果扩展为多实例,应把锁与会话状态升级为 Redis、数据库或对象存储提供的条件写机制。
八、秒传的本质不是"不上传",而是"可信复用"
初始化接口收到文件指纹后,会查询成品索引。如果索引存在、文件实体存在且大小一致,直接返回文件地址,前端进入 instant 状态。
秒传带来明显体验收益,但在真实业务中还有安全边界:不能因为用户知道某个哈希就自动获得该文件访问权限。生产系统需要把"物理文件去重"和"用户文件记录"分开:物理层可以复用同一对象,业务层仍要创建属于当前用户的授权记录。
九、进度条为什么经常不准
只按"完成分片数 / 总分片数"计算,会让进度以 5MB 为单位跳动。最后一个分片大小不同,也会引入误差。
ChunkFlow 使用 Axios 的上传进度事件,分别记录每个在途分片已经发送的字节:
text
总进度字节 = 已完成分片字节 + 所有在途分片 loaded 之和
上传百分比 = 总进度字节 / 文件总大小
平均速度 = 本轮已发送字节 / 本轮耗时
预计剩余 = 未发送字节 / 平均速度
暂停恢复后速度重新计时,避免把暂停时间算进平均速度。生产环境还可以使用最近几秒的滑动窗口,减少速度数字大幅跳动。
十、接口设计
完整链路只需要四个核心上传接口:
text
POST /api/uploads/init
GET /api/uploads/{uploadId}
PUT /api/uploads/{uploadId}/chunks/{chunkIndex}
POST /api/uploads/{uploadId}/complete
文件内容接口返回普通 Spring Resource。Spring MVC 能透明处理标准 Range 请求,因此浏览器可以拖动视频进度、断点读取大文件,而不需要自行手写一套 Range 解析器。
Spring Boot 默认单文件 multipart 上限较小,分片为 5MB 时需要显式调整:
yaml
spring:
servlet:
multipart:
max-file-size: 21MB
max-request-size: 22MB
上限应略高于允许的最大分片,而不是设置为"无限",这样异常请求不会轻易挤满临时目录。
十一、如何测试这套系统
不要只上传一次小图片就宣布完成。至少验证这些场景:
- 分片乱序到达仍能正确合并;
- 同一分片重复上传不会破坏结果;
- 中途暂停,重新选择同一文件后只上传缺片;
- 上传一部分后重启 Java 服务,续传状态仍然存在;
- 完成后重新选择同一文件触发秒传;
- 人为修改一个分片后,最终哈希校验拒绝发布;
- 视频或音频可以通过 Range 请求拖动播放;
- 大文件哈希期间页面仍可正常交互;
- 并发失败时按照预期退避,暂停不会显示成错误。
项目中的 Java 测试会模拟乱序、重复分片、合并、内容校验和秒传闭环;前端测试覆盖分片边界计算。真实网络场景还应通过浏览器限速和服务端故障注入进行验证。
十二、如何写进四年前端工程师的简历
不要只写"使用 Vue 实现大文件上传"。这句话没有说明难点、方案和结果。更好的表达应该覆盖线程、网络、状态与服务端协作。
可直接改写的项目描述
负责企业文件传输模块的方案设计与落地,基于 Vue 3、TypeScript、Web Worker 与 Spring Boot 实现大文件切片上传,支持断点续传、秒传、暂停恢复、并发控制和失败重试。
技术亮点表述
- 将文件内容指纹计算迁移至 Web Worker,并采用逐片读取和增量 MD5,避免大文件一次性加载造成的主线程卡顿与内存峰值。
- 设计可配置并发池及指数退避策略,按服务端已落盘索引仅补传缺失分片,实现网络中断后的可靠续传。
- 与 Java 服务端共同设计幂等分片协议,采用临时写入、原子移动、顺序合并和整文件哈希复核,保证重复请求与异常中断下的数据完整性。
- 建立上传状态机与实时指标体系,支持暂停/继续、分片级进度聚合、上传速度和剩余时间估算。
有真实数据时再补充
- 支持最大
[X GB]单文件上传; - 断点场景减少
[X%]重复流量; - 哈希阶段页面长任务从
[X ms]降至[X ms]; - 在目标网络环境下,并发上传吞吐提升
[X%]; - 分片重试成功率或上传成功率达到
[X%]。
这些数字必须来自压测、监控或真实业务统计,不能未经验证直接写进个人简历。
面试关键词
Blob.prototype.slice()、增量哈希、Web Worker、结构化克隆、主线程长任务、并发池、背压、AbortController、指数退避、幂等、断点续传、内容寻址、原子移动、文件锁、最终一致性、Range 请求、对象存储分片上传。
十三、面试官继续追问时怎么回答
为什么不用前端计算整个 SHA-256?
Web Crypto 的 digest 常见用法需要一次性提供完整数据,不适合直接处理数 GB 文件。可以选择支持增量的 WASM 实现、BLAKE3,或将可信校验放在服务端。当前项目使用增量 MD5 做去重指纹,并由服务端复核。
分片大小怎么确定?
分片过小会增加请求数和服务端元数据压力;分片过大则增加单次重试成本、内存占用和超时风险。5MB 是演示默认值,生产中应结合网络、网关限制、对象存储规则和失败率压测。
多实例部署怎么处理合并锁?
单机 ReentrantLock 只能保护一个 JVM。多实例要使用分布式锁或数据库状态机,并确保"检查状态---开始合并---发布成品"具备条件更新和幂等键。
上传到对象存储还需要 Java 服务端接收分片吗?
通常不需要。服务端可以创建 multipart upload、签发每一片的预签名 URL,浏览器直传对象存储,最后由服务端完成合并确认。这样能显著降低应用服务器带宽压力,但权限、签名过期、分片 ETag 和回调校验会成为新的重点。
结语
大文件上传是一个很好的全栈工程题:它表面上只是文件切片,深入后却同时涉及浏览器线程模型、内存管理、并发调度、网络容错、接口幂等、磁盘一致性和安全边界。
真正能体现工程能力的,不是列出"切片、秒传、断点续传"三个名词,而是能回答:状态以谁为准、失败发生在任意一步时如何恢复、重复请求是否安全、最终文件如何证明正确。
当这些问题都能在代码和测试中得到答案时,这个项目才从一个上传 Demo 变成了可以写进简历、也经得住面试追问的作品。