做需求评审时,经常遇到一个问题:Vue.js 项目里要支持大文件上传,几十兆的照片还好说,超过 1GB 的视频、安装包、三维重建模型,如果一股脑丢给接口,很可能传半小时后连接超时。我前两天刚整理了一个 Vue.js 分片上传大文件的 DEMO,从拿到需求到跑通大概花了一天,这里把实现细节和踩过的坑完整记录下来。如果你是刚接触分片上传的前端同学,或者正在写上传方案但担心漏掉细节,这篇内容应该能直接帮你落地。
1. 先说结论:分片上传到底在解决什么问题
1.1 为什么不能直接把 File 塞进一个请求
很多同学第一次听到“分片上传”会觉得是个很玄的概念,其实底层原理特别朴实:浏览器里的 File 对象本身就是 Blob 的子类,而 Blob 提供了slice方法,可以直接把文件内容按字节切段。大文件上传之所以要切成小块,本质上是把一个大任务拆成多个小任务,分别降低单次请求的失败成本。
举个例子,一个 2GB 的视频文件,如果一次性 POST 给后端,会面临三座大山:第一,HTTP 连接如果中途断开,axios 默认不会自动重连,整个传输直接失败,用户只能从头再选一遍文件;第二,后端如果把接收的数据读进内存再落盘,2GB 文件直接可能把服务内存顶爆;第三,中间经过 Nginx 这类反向代理时通常有超时时间限制,上传速度慢了就会被网关掐断。分片上传把 2GB 拆成 400 个 5MB 的分片后,每个分片都是独立请求,任何一个分片失败只需要重传那一片,这才是它真正牛逼的地方。
这里有一个容易误解的点,大文件上传不是“压缩后传上去”更合适。视频、压缩包这类资源本身已经是压缩格式,前端再压缩基本压不动,还会额外消耗 CPU。分片上传解决的也不是文件体积变小,而是“断点续传”和“失败重传成本”的问题。这也是我在做 DEMO 时最深的体会:方案的核心价值不是把文件切碎,而是让传输过程可控、可中断、可恢复。
1.2 一次完整的分片上传闭环长什么样
一个完整可用的分片上传流程,不是简单把文件切完然后逐个 POST 就算完事。我在 DEMO 里维护了完整的闭环,大致分为六步。
- 用户选择文件后,先读取文件内容计算出一个唯一标识,我直接采用 MD5,这样同一个文件下次再选时可以被识别出来。
- 后端接收到上传请求后,先根据文件标识查询这个文件之前是否上传过部分分片,把已上传的分片序号列表返回给前端。
- 前端把文件切分成固定大小的分片,每个分片都带着文件标识、分片序号、总分片数等元信息,独立发起上传请求。
- 所有分片上传完成后,前端再调用一个“合并接口”,后端把所有分片按顺序拼接成完整文件。
- 如果上传过程中用户取消,或者某个分片失败重试了几次还不行,前端记录断点位置,下次上传时可以跳过已完成的区域。
- 上传中间如果出现网络闪断,用户重新选择同一个文件后,前端第一步去查已上传分片列表,就能直接续传,这就是大家常说的断点续传。
这个闭环看起来像只有切片和合并,但真正决定 DEMO 好不好用的,其实是断点续传和并发的设计。在下面的章节里,我会把每个环节对应的代码和接口设计逐步拆开。
2. 动手前的准备:技术栈和接口约定
2.1 我用到的技术栈
这个 DEMO 我用的是 Vue 3 + Vite + axios + spark-md5,组件部分采用 Composition API 写法。不过我要多说一句,分片上传的核心逻辑其实是框架无关的工具函数,如果你还在用 Vue 2 的 Options API,把后面我封装的uploader.js工具类拿过去用也完全没问题,组件层只是负责调用和展示进度。
具体依赖就两个,axios 用来发送请求,spark-md5 用来计算文件指纹。
npm install axios spark-md5Vite 配置里不需要额外处理什么,axios 请求直接走相对路径/api/...,开发环境下我在vite.config.js里配置了代理,把/api转发到本地后端服务。这样做的好处是避开开发环境的跨域限制,不用给后端单独配 CORS。
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, }, })分片大小我直接设置成 5MB,这个值不是拍脑袋定的。分片太小会导致请求数量膨胀,比如 2GB 文件如果每个分片 1MB,会产生 2000 多个并发请求任务,在浏览器层和管理队列上反而增加额外开销;分片太大又会丧失断点续传的粒度优势,比如一个网络抖动导致 50MB 的分片失败了,重试代价就会偏高。实测下来 2MB 到 10MB 是一个比较舒服的范围,5MB 是折中的选择。
2.2 前后端接口约定
DEMO 里我把后端接口抽象成四个,任何一个分片上传方案到最后基本都会收敛成这个结构。
| 接口 | 方法 | 作用 | 关键参数 |
|---|---|---|---|
/api/upload/chunk | POST | 上传单个分片 | file, fileId, chunkIndex, totalChunks |
/api/upload/chunk/query | GET | 查询某个文件已上传的分片序号 | fileId |
/api/upload/merge | POST | 通知后端合并所有分片 | fileId, fileName, totalChunks |
/api/upload/check | GET | 可选接口,用于秒传判断 | fileId |
我在实际开发中会额外加一个接口用于文件秒传,也就是选择文件后先用 fileId 去查服务端有没有完整文件,如果有直接返回成功,前端就不用再走上传流程了。DEMO 里我先不展开秒传,重点把分片上传主链路讲清楚。
上传分片时我用的是 FormData 格式,每个分片请求里都带上那个分片的 Blob 和文件元信息。这里有个细节:整个文件只用一个 fileId,不同分片用chunkIndex区分,合并时服务端按chunkIndex排序拼接,所以分片序号是 0 开始还是 1 开始不重要,但前后端必须约定一致。我在 DEMO 里用的是 0 开始,后端合并时按序号升序处理。
2.3 文件唯一标识 fileId 的设计逻辑
断点续传要实现,必须有一个能在服务端识别“同一个文件”的 key。最稳妥的方案是取整个文件内容的 MD5,内容没变 MD5 就不会变。这个思路在秒传场景下尤其有效,因为内容完全相同就可以直接复用服务端已有文件。
但直接算 MD5 有个性能问题,一个 5GB 的文件要完整读一遍才能算完,哪怕是本地读取也会花费好几秒,用户会看到页面卡死。所以我这里给 DEMO 提供一个可配置策略:默认用文件内容 MD5,实际项目里如果文件特别大,可以用file.name + file.size + file.lastModified生成一个快速 fileId。坏处是内容变化但名字大小没变时无法识别,好处是算得快,几乎不消耗等待时间。这个取舍要看业务能不能接受,没有绝对的标准答案。
为了不让 demo 的代码越来越复杂,我先按“完整 MD5”的方案讲,哈希计算阻塞 UI 的问题在第四章会专门给出 Web Worker 优化方案。
3. 核心逻辑实现:从切片到合并的完整 DEMO
3.1 文件切分与 MD5 计算
文件切分本身很简单,就是循环调用file.slice(start, end),但写的时候需要注意切片和 Hash 计算最好同步进行,不要先读完整个文件算 Hash,再遍历一遍切片,那样会白白把文件读两遍。我这里是按 2MB 的小片读取,边读边喂给 SparkMD5。
import SparkMD5 from 'spark-md5' export async function computeFileMd5(file, chunkSize = 2 * 1024 * 1024) { const spark = new SparkMD5.ArrayBuffer() let offset = 0 while (offset < file.size) { const chunk = file.slice(offset, offset + chunkSize) const buffer = await chunk.arrayBuffer() spark.append(buffer) offset += chunkSize // 每处理完 10 个小片就让出一次主线程,避免页面假死 if ((offset / chunkSize) % 10 === 0) { await new Promise((resolve) => setTimeout(resolve, 0)) } } return spark.end() }这段代码有个小坑,chunk.arrayBuffer()方法虽然好用,但它会把 Blob 内容完整读取到内存里。我这边是按 2MB 切片去算,内存占用还好,但如果你把 chunkSize 调成 50MB 又用同一套逻辑算 MD5,浏览器内存占用就会明显升高,尤其用户浏览器多个标签页同时操作时可能崩掉。
切片完成后,我会把分片信息缓存到一个数组里,包含分片的 Blob 数据本身、分片序号、大小。这里不要直接把分片 Blob 全部存进响应式状态里,比如 Vue3 的reactive数组可能会尝试对 Blob 做代理,数据量大时性能很差。我建议把 Uploader 类实例直接定义为普通对象,组件内部用markRaw包装,避免不必要的响应式劫持。
const CHUNK_SIZE = 5 * 1024 * 1024 const CONCURRENCY = 3 function createChunks(file, size = CHUNK_SIZE) { const chunks = [] let start = 0 while (start < file.size) { const end = Math.min(file.size, start + size) chunks.push({ index: chunks.length, blob: file.slice(start, end), size: end - start, }) start = end } return chunks }3.2 上传任务管理器:并发、重试与进度
如果写个最简单的循环把 400 个分片同时 axios.post,浏览器和服务器大概率都会出问题。浏览器对同一域名有并发连接数限制,超出部分会排队;服务端同时接收太多大请求也可能因为文件描述符不足拒绝连接。所以我封装了一个简单的任务队列,控制同时上传的分片数量为 3 个。
下面这个 Uploader 是 DEMO 里最核心的工具类,基本思路是:把整个上传过程拆成一个个uploadChunk任务,每个分片自带重试次数,用一个并发池去调度它们。
import axios from 'axios' export class LargeFileUploader { constructor({ file, fileId, chunkSize = 5 * 1024 * 1024, concurrency = 3 }) { this.file = file this.fileId = fileId this.chunkSize = chunkSize this.concurrency = concurrency this.totalChunks = Math.ceil(file.size / chunkSize) this.chunks = this.createChunks() this.uploadedSet = new Set() // 已成功上传的分片序号 this.aborted = false this._listeners = {} } createChunks() { const list = [] for (let i = 0; i < this.totalChunks; i++) { list.push({ index: i, blob: this.file.slice(i * this.chunkSize, (i + 1) * this.chunkSize), }) } return list } on(event, fn) { this._listeners[event] = fn } _emit(event, payload) { if (this._listeners[event]) { this._listeners[event](payload) } } // 查询并初始化已上传的分片状态 async init() { const { data } = await axios.get('/api/upload/chunk/query', { params: { fileId: this.fileId }, }) data.uploadedChunks.forEach((index) => this.uploadedSet.add(index)) return this.uploadedSet } async uploadSingle(chunk) { const formData = new FormData() formData.append('file', chunk.blob) formData.append('fileId', this.fileId) formData.append('chunkIndex', chunk.index) formData.append('totalChunks', this.totalChunks) const response = await axios.post('/api/upload/chunk', formData) if (response.data.code !== 0) { throw new Error(response.data.message || 'upload failed') } } async uploadChunkWithRetry(chunk) { let retry = 0 while (retry < 3) { try { await this.uploadSingle(chunk) this.uploadedSet.add(chunk.index) this._emit('progress', { uploaded: this.uploadedSet.size, total: this.totalChunks, }) return } catch (e) { retry++ if (this.aborted || retry >= 3) { throw e } // 退避重试:指数级等待后继续 await new Promise((resolve) => setTimeout(resolve, 500 * Math.pow(2, retry))) } } } async start() { this.aborted = false const queue = this.chunks.filter((chunk) => !this.uploadedSet.has(chunk.index)) let current = 0 const workers = new Array(Math.min(this.concurrency, queue.length)) .fill(0) .map(async () => { while (current < queue.length && !this.aborted) { const chunk = queue[current] current++ await this.uploadChunkWithRetry(chunk) } }) await Promise.all(workers) if (!this.aborted) { await this.merge() } } async merge() { const { data } = await axios.post('/api/upload/merge', { fileId: this.fileId, fileName: this.file.name, totalChunks: this.totalChunks, }) if (data.code !== 0) { throw new Error(data.message || 'merge failed') } this._emit('complete', data.data) } abort() { this.aborted = true } }这段代码里最需要留意的就是uploadedSet。这个 Set 在整个生命周期里同时承担三个作用:断点续传时已经完成的分片集合、进度计算时的已完成数量、以及启动时过滤掉不需要重复上传的分片。把这三个功能统一收口到一个集合里,代码才能保持简洁。
并发控制这段,如果是刚入门的同学可能会有点绕。我的做法是维护一个current游标和多个并发 worker,每个 worker 从队列里取下一个任务执行。这种“手动游标”比把整个任务数组切成多个子数组交给 worker 更靠谱,因为每个分片耗时不同,游标方式能保证没有 worker 空转,总吞吐量更稳定。
3.3 整体进度怎么算才准
DEMO 里进度条肯定不能只是摆个样子。上传进度包含两个维度:单个分片的 HTTP 传输进度和整个文件的完成进度。我选择直接以“成功上传分片数 / 总分片数”作为整体进度依据,而不是去监听每一个分片的传输字节数。
为什么呢?因为服务端合并完成后才真正算“可用”,只要分片还没传完,即便当前这一片网络传输已经走了 90%,文件整体也无法使用。以分片成功数为单位计算,逻辑简单且不受单个请求传输速率抖动影响。
function calcProgress(uploaded, total, currentChunkTransferred = 0) { const unit = 100 / total return Number((uploaded * unit + currentChunkTransferred * unit).toFixed(2)) }需要展示单个分片实时速度的时候,再给 axios 请求传onUploadProgress参数,在那个回调里取event.loaded / event.total,计算出当前分片占整体进度的百分比,再把前面 baseProgress 加起来。这样组合出来的进度条是平滑的,不会出现传着传着突然从 50% 跳到 100% 的情况。
3.4 比分片上传更难搞的是合并通知
所有分片 POST 完成后,前端还要调一次合并接口。这里要处理两个边界:一是所有分片必须真的传完了,不能有漏网之鱼;二是合并接口本身可能失败,需要重试。
我在这里做了一层保护:调用 merge 前重新检查uploadedSet.size === totalChunks - 1,因为我的分片序号从 0 开始,最后一个分片的序号是totalChunks - 1。如果数量对不上,就说明有分片虽然被 Set 记录了但实际状态有问题,这时候不调合并接口,而是把缺失的分片找出来重新传。
后端合并时,最推荐的方式不是把所有分片用追加方式拼成一个文件,而是基于文件偏移量把每个分片写到对应位置。这样即使前端分片上传乱序到达,最终生成的文件也一定是正确顺序。合并完成后还要校验文件大小是否和前端传来的fileSize一致,不一致基本可以断定有分片丢数据,这个校验在生产环境特别重要。
4. Vue 组件接入:页面上的真实交互
4.1 Vue3 组合式 API 封装上传逻辑
工具类写完了,接下来把它接进 Vue 组件。我习惯在组件里维护 uploader 实例、上传状态和进度值,用组合式 API 把这些逻辑整理成useUploader函数,这样以后多个页面上传功能都能复用。
<template> <div class="upload-page"> <input type="file" :disabled="uploading" @change="onFileChange" /> <div v-if="uploader"> <p>文件名:{{ fileName }}</p> <p>文件大小:{{ fileSizeText }}</p> <div class="progress-bar"> <div class="progress-inner" :style="{ width: percent + '%' }"></div> </div> <span>{{ percent }}%</span> <button v-if="uploading" @click="onAbort">取消</button> <button v-if="!uploading && percent > 0" @click="onResume">继续上传</button> </div> </div> </template> <script setup> import { ref, markRaw } from 'vue' import { LargeFileUploader } from '../utils/uploader' import { computeFileMd5 } from '../utils/hash' const rawFile = ref(null) const uploader = ref(null) const uploading = ref(false) const percent = ref(0) const fileName = ref('') const fileSizeText = ref('') async function onFileChange(e) { const file = e.target.files[0] if (!file) return rawFile.value = file fileName.value = file.name fileSizeText.value = formatSize(file.size) uploading.value = false percent.value = 0 // 先算 hash,文件越大这里越耗时,可用 Web Worker 优化 const fileId = await computeFileMd5(file) const instance = new LargeFileUploader({ file, fileId, chunkSize: 5 * 1024 * 1024, concurrency: 3, }) // 关键:不需要响应式劫持 uploader uploader.value = markRaw(instance) } async function startUpload() { const instance = uploader.value if (!instance) return uploading.value = true instance.on('progress', ({ uploaded, total }) => { percent.value = Number(((uploaded / total) * 100).toFixed(2)) }) instance.on('complete', () => { uploading.value = false alert('上传成功') }) try { await instance.init() await instance.start() } catch (err) { uploading.value = false console.error('上传失败', err) } } function onAbort() { uploader.value?.abort() uploading.value = false } async function onResume() { await startUpload() } function formatSize(size) { if (size < 1024 * 1024) return (size / 1024).toFixed(2) + 'KB' if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(2) + 'MB' return (size / 1024 / 1024 / 1024).toFixed(2) + 'GB' } </script>这里有个很容易踩的坑:不要在文件选择事件里立刻调用startUpload。用户选完文件后,界面需要先展示文件信息和计算进度,然后等用户点击“开始上传”。如果选完文件就自动上传,用户根本来不及取消那些手滑选中的 5GB 文件。当然如果你做的是拖拽上传区域,自动上传体验更好,这个按产品需求取舍。
4.2 取消上传的正确姿势
取消上传不是简单把aborted设为 true,还要破坏当前正在发送的 axios 请求,否则已经发出的分片请求会继续上传完再结束。我在 Uploader 里维护了一个AbortController实例,每批次上传前创建新实例,取消时调用controller.abort()。
axios 支持通过signal传入取消信号,使用起来很干净。
import axios from 'axios' export class LargeFileUploader { createAbortController() { this.abortController = new AbortController() return this.abortController.signal } async uploadSingle(chunk) { const formData = new FormData() // ... 省略 append const response = await axios.post('/api/upload/chunk', formData, { signal: this.createAbortController(), }) } abort() { this.aborted = true if (this.abortController) { this.abortController.abort() } } }注意:一旦取消,整个实例内已经上传成功并记录在uploadedSet里的分片是真实存在于服务端的。下次重新选择同一个文件,init()会把这些分片查出来,前端直接跳过,所以取消的代价并不高。这也是我推荐用“分片成功标记 + 服务端持久查询”而不是“本地进度缓存”做断点续传的原因,本地刷新一下可能就丢了,服务端状态才是可靠来源。
4.3 Web Worker 优化 Hash 计算的 UI 卡顿
如果文件只有 100MB,MD5 计算可能一眨眼就完了。但文件到 5GB 时,完整读取文件算 MD5 的时间接近不可接受的级别,用户会看到页面标题栏一直转圈。这时候需要把计算过程搬进 Web Worker,避免阻塞主线程。
我的做法是用 Worker 接收主线程发来的分片 ArrayBuffer,不断把数据追加给 SparkMD5,最终把 Hash 结果传回主线程。注意这里要把 ArrayBuffer 的底层内存转移给 Worker,方式是第二参数传[buffer],这样大块内存不会被拷贝,减少主线程内存占用和卡顿。
// hash.worker.js import SparkMD5 from 'spark-md5' const spark = new SparkMD5.ArrayBuffer() let count = 0 self.onmessage = (e) => { const { type, buffer, total } = e.data if (type === 'append') { spark.append(buffer) count++ self.postMessage({ type: 'progress', count, total }) } else if (type === 'finish') { self.postMessage({ type: 'done', hash: spark.end() }) } }主线程侧对应代码会复杂一些,因为 File 不能一次性安全地发给 Worker,还是得在主线程切片后把片段转成 ArrayBuffer 再转移过去。我在 DEMO 里封装了一个createHashWorker函数,用它替换computeFileMd5即可。
export function computeFileMd5WithWorker(file) { return new Promise((resolve, reject) => { const worker = new Worker(new URL('../worker/hash.worker.js', import.meta.url), { type: 'module', }) const chunkSize = 2 * 1024 * 1024 let offset = 0 let total = Math.ceil(file.size / chunkSize) worker.onmessage = (e) => { const { type, hash, count } = e.data if (type === 'done') { worker.terminate() resolve(hash) } } worker.onerror = (err) => { worker.terminate() reject(err) } async function readNext() { if (offset >= file.size) { worker.postMessage({ type: 'finish' }) return } const chunk = file.slice(offset, offset + chunkSize) const buffer = await chunk.arrayBuffer() offset += chunkSize worker.postMessage({ type: 'append', buffer, total }, [buffer]) readNext() } readNext() }) }这里有个使用细节:postMessage 第二个参数[buffer]是转移列表,把 ArrayBuffer 的所有权交给 Worker 侧,主线程这边不能再访问这个 buffer 对象。计算过程中如果打断,需要妥善 terminate Worker,否则后台线程会一直跑,示例代码里加了worker.terminate(),实际项目里还要处理取消按钮中断计算的场景。
4.4 大文件 Hash 之后重新选择同一文件的续传验证
断点续传的演示效果,我在本地是这样验证的:选择一个大文件,点开始上传,传了大约 30% 时强制结束浏览器进程,然后重新打开页面,再次选择同一个文件。因为 MD5 和文件内容强相关,计算出的 fileId 没有变,前端执行init()时从服务端查出了已经上传的分片列表,后续 start 直接跳过了这些分片,进度从 30% 开始继续往上走。
如果你的后端没有做完整的已上传分片持久查询,续传会失败。很多刚上手的同学以为只要前端把分片序号存到 localStorage 就行,但换了个浏览器或清缓存后记录就丢了。服务端查询接口才是断点续传的地基,前端缓存只能算锦上添花。
5. 上传过程中的常见问题与排查实录
5.1 分片数量太多导致浏览器请求阻塞
有次我测试一个 20GB 的文件,分片大小调成了 1MB,总分片数将近 2 万个,尽管并发限制是 3,但任务队列里每个分片都要生成 FormData、创建 axios 请求、监听进度,整体内存占用会明显上升,上传速度反而没提升。
排查方法很简单,把浏览器 Network 面板打开,如果看到同时有几十个请求堆积在队列里等待,说明并发控制或者分片大小设置不合理。我给 DEMO 的调参经验是:先根据文件大小动态决定分片大小,比如小于 200MB 用 2MB 分片,200MB 到 2GB 用 5MB 分片,超过 2GB 用 10MB 分片,让总分片数稳定在几百的级别,上传体验最好。
5.2 分片全部显示上传成功但合并后的文件打不开
这个问题的典型表现是请求日志里所有分片都返回 200,但后端合并完文件后,用播放器打开提示文件损坏。排查方向主要有两个。
第一个是并发上传顺序导致分片追加乱序,如果后端采用FileOutputStream按请求到达顺序追加写入,各分片响应顺序不是固定的,最终文件必然是错乱的。解决方式是后端使用随机读取写入,按chunkIndex乘以分片大小定位写入位置。
第二个是存在重复分片叠加上传。前端重试机制如果做不好,同一次上传中某个分片失败后重试成功,但失败响应前服务端其实已经写入成功,就会导致该分片被写入两次。解决方式是在后端上传接口里做幂等处理,同一个fileId + chunkIndex的分片重复提交时直接返回成功,不重复写入。
5.3 进度条传着传着突然回退
进度条回退最容易出现在续传场景。比如服务端已经上传了 50 个分片,前端一开始init()查询到 50,本地 progress 显示为 12%,接着上传第 51 个分片时网络报错,重试过程里某个回调又把之前已经计入成功的分片从状态里移除了,进度就回退到 10%。
这是我自己在写第一版时踩过的坑,根源在于用数组存储分片上传状态时没有做幂等。DEMO 里我用 Set 管理已上传分片,uploadedSet.add(chunk.index)天然幂等,重复上传同一分片不会导致计数叠加,进度自然不会虚高再回落。
5.4 断点续传在换了网络环境后就续不上
续不上先确认两件事:fileId 是否一致,服务端查询也能拿到之前的记录。如果是公网 IP 变了、临时文件服务目录挂了这类环境原因,前端无能为力。排查时我会先直接调用/api/upload/chunk/query看返回列表,如果接口返回空,再检查服务端文件存储目录是否被清理了。
这里要提醒一下,生产环境的上传临时目录必须定期维护,不能无限制累积。常见做法是加一个定时任务,清理超过 24 小时仍未合并的分片,否则用户上传一半就放弃,磁盘会被各种残留分片堆满。
5.5 超大文件算 Hash 时页面完全卡死
页面卡死的根因不是 Hash 算法本身,而是读文件占用了主线程。把 Hash 计算扔给 Worker 后还要注意一点,主线程在给 Worker 喂数据时也不能一次性把所有分片都读完,我采用的是懒读取模式,发一个片段到 Worker,读下一个片段,用递归控制节奏,避免短时间内申请大量内存。
如果觉得 Worker 引入复杂,也可以退而求其次,使用不基于整体内容的快速 fileId 方案。对绝大多数业务,file.name + file.size + file.lastModified足够用了,能避免 Hash 计算带来的所有问题。是否要精确冗余,完全看你对上传成功率的要求。
5.6 合并接口调用失败时前端应该怎么处理
合并接口一般不会被频繁调用,所以前端容易忽视它的异常处理。我建议对 merge 调用也做重试,因为合并过程本身需要读取、写入大量磁盘数据,时间可能超过常规 HTTP 超时阈值。但如果重试多次仍然失败,就不要再盲目自动重试了,否则服务端可能已经处于合并了一半的状态,继续触发会导致文件名冲突或者目录状态异常。
合理做法是先把失败原因抛给用户,保留“重试合并”按钮,等用户手动操作时再去查文件当前状态。宁可用户体验稍微差一点,也不能让服务端合并状态不可恢复。
6. 实测数据与还能怎么扩展
6.1 本地实测的一组参考数据
我在本地用同一个 2GB 的视频文件做了一组简单对比,浏览器是 Chrome,后端是 Spring Boot 本地接口,网络是局域网。
| 方案 | 分片大小 | 并发数 | 完成时间 | 观察现象 |
|---|---|---|---|---|
| 单请求一次上传 | 整个文件 | 1 | 超时中断 | 后端内存飙升,网关断开连接 |
| 分片上传 | 5MB | 1 | 约 2 分 30 秒 | 进度稳定但较慢 |
| 分片上传 | 5MB | 3 | 约 1 分 05 秒 | 速率接近磁盘/网络瓶颈 |
| 分片上传 | 5MB | 8 | 约 1 分 02 秒 | 提升很小,偶发 TCP 排队 |
这个测试结果并不意外,并发超过一定值后瓶颈不再是浏览器或后端处理能力,而是本机磁盘读写速度和网络环境。所以 DEMO 里默认并发数设 3,已经能覆盖绝大多数场景,不必盲目调高。
6.2 DEMO 后续可以扩展的几个方向
当前这个 DEMO 已经可以用于日常学习或小范围内部工具,但如果要上生产,我建议优先补三件事。
第一是秒传逻辑。选择文件后先调/api/upload/check,服务端比对 fileId 和文件大小,如果已经存在完整文件直接向前端返回旧文件 URL,省掉一整个上传流程。第二是动态并发控制。根据当前上传速度和失败率自动调整并发数,网络波动大时降低并发,网络稳定时提高并发,能显著提升弱网环境下的成功率。第三是后端合并完以后做文件完整性校验,不只是 size 对比,最好能对合并前后文件的 MD5 再做一次校验,彻底杜绝静默损坏。
另外,如果做的是企业内部工具,建议在分片上传之外再补一个上传队列,支持多文件依次上传。毕竟真实用户通常不会只传一个文件,多个 2GB 文件同时并发会反过来拖垮带宽,队列管理能明显提升整体体验。
最后分享一个我自己实践下来的经验:这个 DEMO 写完后,我最深的感受是分片上传的复杂度其实不在切片算法,而在“失败后如何恢复”。fileId 的生成策略、服务端分片查询接口的设计、合并时的幂等性,这些细节才决定系统靠不靠谱。你在写的时候,一定要先想清楚断点续传如何验证,再开始写切片和上传代码,否则很容易做出来一个看起来能传、一旦断网就全部重来的花瓶功能。