大文件切片上传:从“能传”到“可靠”的 Vue 3 + Java 全栈实现

关键词: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 不是简单地把一个文件拆成多次请求,而是围绕"可靠传输"设计了一套完整闭环:

  1. 在 Web Worker 中逐片读取文件并计算内容指纹;
  2. 使用指纹初始化上传任务,优先判断能否秒传;
  3. 获取服务端已经完整落盘的分片索引;
  4. 计算缺失分片,并通过受控并发池进行补传;
  5. 对失败请求执行指数退避重试,允许随时暂停和继续;
  6. 服务端以幂等方式保存分片,按索引完成合并;
  7. 校验成品总大小和 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 中计算内容指纹;
  • 查询服务端已有分片;
  • 控制并发、暂停和重试;
  • 汇总各分片进度,计算速度与剩余时间。

服务端负责:

  • 校验文件元数据和分片参数;
  • 以磁盘事实为准判断断点;
  • 幂等保存每个分片;
  • 对同一上传任务加锁,避免并发合并;
  • 按索引顺序合并;
  • 校验合并后的大小和内容指纹;
  • 建立成品索引,为下一次相同文件提供秒传。
flowchart LR A["选择 File"] --> B["Worker 流式哈希"] B --> C["初始化上传"] C --> D{"成品已存在?"} D -->|"是"| E["秒传完成"] D -->|"否"| F["返回已落盘分片"] F --> G["计算缺片集合"] G --> H["受控并发池"] H --> I["幂等写入分片"] I --> J["顺序合并"] J --> K["总大小 + MD5 校验"] K --> L["原子发布成品"]

三、难点一:哈希不能卡住页面,也不能吃掉数 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() })

这样做有三个直接收益:

  1. 主线程可以继续渲染动画和响应点击;
  2. 峰值额外内存接近单个分片,而不是整个文件;
  3. 哈希进度可以独立呈现,避免用户面对"没有反应"的页面。

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 的处理流程是:

  1. 根据元数据计算该索引应有的准确大小;
  2. 如果目标分片已存在且大小正确,直接返回成功;
  3. 否则先写入带随机后缀的临时文件;
  4. 检查临时文件大小;
  5. 使用原子移动替换目标分片。
text 复制代码
2.uploading-a1b2c3  --写完并校验-->  00000002.part

为什么不直接写最终文件?因为进程崩溃、磁盘异常或连接中断可能留下半个分片。临时写入配合原子移动,让"目标文件存在"尽可能代表"这个分片已经完整"。

七、难点五:合并不是简单地把文件拼起来

服务端收到"开始合并"请求后,需要按顺序完成以下检查:

  1. 所有索引是否都存在;
  2. 每一片的大小是否符合预期,最后一片允许小于固定分片大小;
  3. 合并后总大小是否等于原文件大小;
  4. 合并后 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 变成了可以写进简历、也经得住面试追问的作品。

延伸阅读

相关推荐
张龙6871 小时前
uv 全面指南:比 pip 快 100 倍的 Python 包管理器,从安装到团队落地
后端·python
用户40966601317512 小时前
Guava 不止有 Lists 和 Maps:Cache、EventBus、Multimap 实战
java·后端
一只叫煤球的猫2 小时前
从 Java 21 到 Java 25:ThreadLocal 以外的选择—— ScopedValue
java·后端·架构
用户3169353811833 小时前
SpringBoot + Vue 项目生产环境部署完整指南
前端·后端
霸道流氓气质3 小时前
Spring 事务同步机制 —— AfterTransactionActionCollector 的异步解耦原理
java·后端·spring
Csvn3 小时前
📊 SQL 入门 Day 19:事务与隔离级别
后端·sql
面试鸭3 小时前
Anthropic 让 Claude 接了 CI 值班的第一班,人剩下的是写规则和合 PR
后端·面试
小强19883 小时前
useRef 到底用来干什么?不止获取 DOM 元素
后端