十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue 3 大文件分片上传实践:断点续传与并发控制指南

Vue 3 大文件分片上传实践:断点续传与并发控制指南 做需求评审时经常遇到一个问题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/chunkPOST上传单个分片file, fileId, chunkIndex, totalChunks/api/upload/chunk/queryGET查询某个文件已上传的分片序号fileId/api/upload/mergePOST通知后端合并所有分片fileId, fileName, totalChunks/api/upload/checkGET可选接口用于秒传判断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. 核心逻辑实现从切片到合并的完整 DEMO3.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 classupload-page input typefile :disableduploading changeonFileChange / div v-ifuploader p文件名{{ fileName }}/p p文件大小{{ fileSizeText }}/p div classprogress-bar div classprogress-inner :style{ width: percent % }/div /div span{{ percent }}%/span button v-ifuploading clickonAbort取消/button button v-if!uploading percent 0 clickonResume继续上传/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 卡顿如果文件只有 100MBMD5 计算可能一眨眼就完了。但文件到 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超时中断后端内存飙升网关断开连接分片上传5MB1约 2 分 30 秒进度稳定但较慢分片上传5MB3约 1 分 05 秒速率接近磁盘/网络瓶颈分片上传5MB8约 1 分 02 秒提升很小偶发 TCP 排队这个测试结果并不意外并发超过一定值后瓶颈不再是浏览器或后端处理能力而是本机磁盘读写速度和网络环境。所以 DEMO 里默认并发数设 3已经能覆盖绝大多数场景不必盲目调高。6.2 DEMO 后续可以扩展的几个方向当前这个 DEMO 已经可以用于日常学习或小范围内部工具但如果要上生产我建议优先补三件事。第一是秒传逻辑。选择文件后先调/api/upload/check服务端比对 fileId 和文件大小如果已经存在完整文件直接向前端返回旧文件 URL省掉一整个上传流程。第二是动态并发控制。根据当前上传速度和失败率自动调整并发数网络波动大时降低并发网络稳定时提高并发能显著提升弱网环境下的成功率。第三是后端合并完以后做文件完整性校验不只是 size 对比最好能对合并前后文件的 MD5 再做一次校验彻底杜绝静默损坏。另外如果做的是企业内部工具建议在分片上传之外再补一个上传队列支持多文件依次上传。毕竟真实用户通常不会只传一个文件多个 2GB 文件同时并发会反过来拖垮带宽队列管理能明显提升整体体验。最后分享一个我自己实践下来的经验这个 DEMO 写完后我最深的感受是分片上传的复杂度其实不在切片算法而在“失败后如何恢复”。fileId 的生成策略、服务端分片查询接口的设计、合并时的幂等性这些细节才决定系统靠不靠谱。你在写的时候一定要先想清楚断点续传如何验证再开始写切片和上传代码否则很容易做出来一个看起来能传、一旦断网就全部重来的花瓶功能。
返回列表