这周接了个挺有意思的需求:给一个政企内网环境下的Vue项目做一个大文件断点续传的DEMO。标题里那个问号不是我打的,是提需求的人自己都没想明白——他只知道要“大文件上传”,但文件多大、断网怎么办、要不要秒传,全是一串问号。所以我拿到这个“国防项目Vue大文件断点续传DEMO”任务时,第一反应不是开写代码,而是先把什么是断点续传、什么场景下必须用、DEMO做到什么程度算完成,这三件事理清楚。
这篇内容就是完整记录我从需求梳理到DEMO落地的全过程。包括分片上传的核心原理、组件选型、前后端配合、实测数据,还有我在内网环境下踩的一堆坑。适合正在做Vue大文件上传功能、想快速拉通断点续传DEMO、或者要在纯内网环境里做前端组件选型的同学参考。
1. 先搞清楚一个事:这个DEMO到底要给谁看
1.1 标题带着问号,说明需求方也没想明白
这种带问号的标题在内部项目里特别常见。提需求的人往往只知道大概方向:我们要传很大的文件,可能要几十GB,网络可能不好,听说有个东西叫断点续传。至于“很大”是多大、是什么样的网络、续传要续到什么粒度,他大概率答不上来。
我的第一个动作不是写代码,而是拉上需求方做了半小时澄清,问了三组问题:
- 文件范围和类型:最小多大、最大多大?是单个大文件还是批量?文件是视频、图纸、数据包还是压缩包?
- 网络环境:用户是内网还是外网?带宽多少?会不会经常断线?要不要支持暂停?
- 交互预期:是自动续传还是用户手动点?要不要显示进度?要不要秒传(服务端已有文件就不传)?
结果和我预想的一样:需求方只确定了两件事——文件确实很大,可能到几十GB级别;网络环境确实不完美,断线是常态。其余全部是“你们看着办,先做个DEMO看看效果”。
所以这个DEMO的核心价值,就是把“断点续传”四个字从概念变成可演示、可点击、可看进度的东西,让决策者亲眼看到:文件传一半断网了,再打开还能从断点继续。至于权限管理、审计日志、文件生命周期,那都是产品阶段的事,DEMO阶段不该做,做了反而拖慢节奏。
1.2 内网项目的三个硬性约束
我没法展开说这个项目的具体背景,但在政企内网环境下做前端,有三个约束是绕不开的,它们直接影响技术选型:
- 网络完全隔离:开发机可能能上外网,但部署环境是纯内网。npm registry大概率不通,CDN资源想都不要想,所以组件库必须能在离线环境下安装。
- 浏览器环境统一但偏旧:内网终端一般统一下发浏览器,内核版本往往不是最新的。现代浏览器那些花哨API未必全支持,所以代码里要用兼容性更好的写法。
- 数据安全审查严格:文件内容不能随意落盘到非指定位置,临时文件必须清理,日志里不能出现文件路径、文件哈希这类敏感信息。这些约束在需求文档里不会写,但验收的时候会一条条卡你。
这三个约束直接决定了:我不能选那些依赖外部云服务的上传SDK,也不能用需要联网拉CDN资源的组件库,更不能把文件哈希打到日志里方便调试。
1.3 DEMO的验收边界:跑到哪一步算“交差”
和需求方对齐后,我把DEMO的验收边界定成了这样:
- 支持单个文件分片上传,分片大小可配置;
- 支持上传过程中断网/刷新/关闭页面后恢复,能告诉用户“已传了多少,还剩多少”;
- 支持服务端重复文件秒传(可选加分项);
- 前端页面要能展示上传进度、已上传分片数、当前速度;
- 在100MB到2GB区间的文件上稳定跑通,不要求做到几十GB级,但要预留扩展空间。
这个边界很重要。没有边界,DEMO很容易做成一个什么都想干、什么都没干透的半成品。有了边界,我后面每个技术决策都有判断标准。
提示:接类似需求时,第一件事永远是确认验收边界。不是所有DEMO都要做秒传,也不是所有DEMO都要支持暂停。边界没定,后面全是返工。
2. 断点续传的本质:分片上传与状态校验
开始选型之前,我把断点续传的原理在脑子里完整过了一遍。这一步一定不能省,因为后面前后端联调、排查问题时,所有判断都基于对原理的理解。
2.1 一个类比:寄包裹和签收
大文件断点续传,本质和寄快递是一回事。
你要把一整个仓库的货物从A城运到B城。两种方式:一是包一辆超大货车,一次性拉过去。但问题在于,没有那么多超大型货车(HTTP请求有大小限制),路上还可能遇到封路(断网),一旦封路整车货物都卡在路上,到不了也退不回,非常被动。
二是把这仓库货物拆成一个个标准尺寸的包裹,分批运。每个包裹运到B城后,B城那边登记一下“这个包裹签收了”。运到一半封路了,没关系,已经签收的包裹不用重发,等路通了剩下的继续发。这就是分片上传和断点续传的雏形。
落到Web技术上,“超大货车”就是一次性把整个文件塞进一个XMLHttpRequest里,“拆包裹”就是用File.slice()把文件切成多个Blob分片,“签收登记”就是服务端记录每个分片的接收状态。
2.2 分片是续传的前提,而不是续传本身
很多人有个误区,觉得断点续传的重点是“断点”,其实重点在“分片”。
为什么?因为HTTP请求是无状态的。你发一个上传整个文件的请求,请求发出去了,传到一半连接断了,这个请求已经不存在了。浏览器不会保留一个“还没传输完的请求”等你回来继续传。文件读取和网络发送是连在一起的,断开的瞬间,已读取的文件数据和已发送的网络字节都丢掉了。
但是如果文件被切成了1000个分片,每个分片是一次独立的、需要服务端确认的请求,那么“上传进度”就从“不可查询的字节流”变成了“可查询的分片状态列表”。断网后,前端问一下服务端“哪些分片已经收到”,把没收到的重新传,就实现了续传。
所以断点续传的本质是:把不可断点的连续传输,转换成可断点的离散传输,并配合状态记录实现恢复。
这也是为什么网上那些“用XMLHttpRequest上传大文件然后断掉”的方案根本行不通——因为你没有把文件切成独立单元,断点无从记录。
2.3 校验与秒传:MD5、uploadId和分片索引
要实现续传,前端和服务端至少要共同维护三样东西:
- 文件唯一标识:一般用文件的MD5或SHA-256哈希值。这个标识用来判断“这个文件之前是不是传过”,也是秒传的基础。服务端如果发现这个文件标识已经对应一个完整文件,就直接返回“已存在”,前端跳过上传。
- uploadId(上传会话ID):一次上传过程唯一标识。同一份文件,不同时间传,uploadId不同。服务端用uploadId来组织分片临时文件的存放目录。
- 分片索引:每个分片在整个文件中的位置,包括分片序号、分片总大小、分片偏移量(一般是序号乘以分片大小),服务端靠这个在合并时把分片按顺序拼回去。
三者配合才能实现两个关键操作:
服务端校验时,前端把文件哈希和uploadId发给服务端,服务端返回已接收的分片序号列表。前端遍历所有分片,发现序号在已接收列表里的跳过,不在的重新上传。这就是“校验断点续传”的完整逻辑。
之所以提这个,是因为我后来看社区里不少人问“vue-simple-uploader如何校验断点续传”。其实它靠的就是testChunks配置项加checkChunkUploadedByResponse回调。前端每个分片上传前会先问服务端,这个分片要不要传——服务端说不用,就直接跳到下一个。这个机制跑通了,续传和秒传就都有了。
2.4 为什么不能“读文件存进度”
我还见过一种方案:用FileReader把文件读到底,每读一段就把字节数存到localStorage里,下次接着从那个字节数继续读。听起来像续传,但实际操作起来全是问题。
问题在于,FileReader读取是本地行为,你读得再快,网络跟不上也白搭。而且上传走的是HTTP,你本地读到哪和网络发到哪是两个独立过程,中间隔着浏览器内核的调度和网络栈缓冲。你把本地进度存下来了,但网络那边断在什么地方你根本不知道。服务端不会告诉你“你上次传到第538214字节”,它只会告诉你“我收到过哪些完整分片”。
还有就是文件MD5计算本身的问题。一个2GB的文件,前端用JS同步算MD5,浏览器直接卡死,这在后面的实测里我专门踩了一脚。所以“读文件存进度”方案从原理上就不成立,分片加服务端状态记录才是正路。
提示:判断一个断点续传方案是否靠谱,就看两点——文件有没有被切成可独立重传的单元,以及服务端能否准确告诉你“哪些单元已经收到了”。两者缺一不可。
3. 技术选型:为什么是vue-simple-uploader而不是自己写
选型这件事我在内网环境下考虑得比平时要多。不是哪个技术新选哪个,而是哪个能在受限环境里最稳、最少出幺蛾子。
3.1 备选方案对比
我认真对比了四条路线:
| 方案 | 分片 | 并发控制 | 断点续传 | 秒传 | 离线安装 | 综合评估 |
|---|---|---|---|---|---|---|
| 原生XMLHttpRequest自己封装 | 需自己写 | 需自己写 | 需自己写 | 需自己写 | 完全没问题 | 灵活但工程量巨大 |
| vue-simple-uploader | 内置 | 内置并发槽 | 内置testChunks | 内置 | 组件打包后可用 | 最贴合DEMO需求 |
| antd Upload + 自定义请求 | 需自己写 | 需自己写 | 需自己写 | 需自己写 | 没问题 | 适合小文件,大文件要自己造轮子 |
| 第三方云存储SDK | 服务端处理 | 服务端处理 | 部分支持 | 支持 | 通常需要联网SDK | 内网环境直接排除 |
结论很明显:vue-simple-uploader是目前Vue生态里把大文件分片上传封装得最完整、文档最清楚、且不依赖云服务的方案。它底层是simple-uploader.js,一个框架无关的上传库,Vue版本只多了一层组件封装,核心逻辑非常成熟,社区里大量项目在用,踩坑资料也齐全。
3.2 vue-simple-uploader帮我啃的三块硬骨头
如果自己写,这三件事每一个都要花大量时间调优:
第一块:分片和并发控制。vue-simple-uploader里一个chunkSize配置项就解决了分片大小,simultaneousUploads控制了同时上传几个分片。自己写的话,要考虑分片边界、并发窗口、失败重试、超时处理,代码量轻松过千行。
第二块:断点续传的状态管理。它默认会把文件分片状态写到localStorage,页面刷新后重新选择同一文件,能自动恢复状态。还提供了checkChunkUploadedByResponse回调,让前端根据服务端响应来判断某个分片是否已存在。这两个机制合起来,就是“校验断点续传”的完整答案。
第三块:暂停与恢复。用户点暂停、点继续,这套交互在vue-simple-uploader里是现成的,内部会维护文件队列状态。自己写的话,暂停一堆并发中的请求还要保证状态一致,是个很头疼的事。
3.3 离线内网环境安装依赖:npm的“断网生存”方案
选好了组件,下一步是安装。这一步在内网环境差点卡住我。
我的开发机可以上外网,但部署和演示环境在完全隔离的内网。npm install vue-simple-uploader在外网执行当然没问题,但产物怎么弄进内网?
网上很多教程会让你在目标机器上配npm镜像源,但纯内网环境下,镜像源也不通。我用的方案是:
- 在外网机器上执行
npm pack vue-simple-uploader,拿到vue-simple-uploader-xx.tgz压缩包; - 把这个tgz文件拷进内网目标项目;
- 在
package.json里写"vue-simple-uploader": "file:./vendors/vue-simple-uploader-xx.tgz"; - 内网里执行
npm install时,npm会直接从本地tgz安装,完全不碰网络。
这里还延伸出一个内网Vue项目常见的事:node_modules装好之后,有些人想靠Git提交来同步依赖,但node_modules体积动辄几百MB,Git仓库根本提交不动。这就是为什么“git无法提交大文件”这类问题会反复出现在相关搜索里。正确的做法要么是构建分发(把dist产物拷过去),要么内网搭Verdaccio私服,要么像我这样用本地tgz方式管理关键依赖。DEMO阶段我选了最快的一种。
提示:内网项目的依赖管理,原则就一条——能在外网搞定的操作,永远不要指望在内网做。把依赖预先打包成tgz,比在内网搭私服省事得多。
4. DEMO落地全过程:从环境配置到组件封装
原理和选型定了,落地就很顺了。整个DEMO我用的是Vue 3 + Vite,后端用Node.js的Express,跑联调时配合Nginx转发。下面按实际执行顺序写。
4.1 初始化项目与基础环境配置
环境配置这部分我不想多啰嗦,只列关键点:
- Node.js版本用16.20+,我本地是18。
npm create vue@latest初始化Vue 3项目,脚手架会自动配好Vite和基础目录。- 由于要演示展示真实效果,我额外配了一个全局样式,在
main.js里引入一个重置样式文件。这一部分纯属个人习惯,不影响功能。
项目初始化后,package.json大致这样:
{ "name": "large-file-resume-demo", "version": "0.1.0", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "vue": "^3.4.0", "vue-simple-uploader": "^1.2.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.0", "vite": "^5.0.0" } }4.2 安装依赖与目录结构
因为我在演示机上是纯内网,需要用本地tgz方式安装:
# 外网机器执行 npm pack vue-simple-uploader # 得到 vue-simple-uploader-1.2.0.tgz,拷贝至项目 vendors 目录 # 内网项目执行 npm install装好后我按这个结构组织DEMO代码:
src/ components/ UploaderDemo.vue # 核心上传组件 api/ upload.js # 上传相关接口封装 utils/ fileHash.js # 文件MD5计算工具(含Worker) App.vue main.js4.3 核心代码:封装一个Uploader组件
UploaderDemo.vue是整个DEMO的核心,关键配置和逻辑如下:
<template> <div class="uploader-demo"> <uploader :options="uploaderOptions" :file-status-text="statusTextMap" @file-added="onFileAdded" @file-success="onFileSuccess" @file-error="onFileError" @upload-start="onUploadStart" class="uploader-area" > <uploader-unsupport>当前浏览器不支持文件上传</uploader-unsupport> <uploader-drop> <p>将文件拖拽到这里,或点击选择文件</p> </uploader-drop> <uploader-list></uploader-list> </uploader> </div> </template> <script setup> import { reactive } from 'vue' import { computedFileHash } from '../utils/fileHash' const uploaderOptions = reactive({ target: '/api/upload/chunk', chunkSize: 5 * 1024 * 1024, // 5MB分片 simultaneousUploads: 3, // 并发3个分片 testChunks: true, // 是否开启分片校验 allowDuplicateUploads: false, fileParameterName: 'file', // 文件字段名 query: {}, // 每请求携带的额外参数 checkChunkUploadedByResponse: function (chunk, message) { // 服务端返回已上传分片列表时,前端据此判断该分片是否需要重新上传 const res = JSON.parse(message) if (res.uploaded || res.uploadedList) { const uploadedList = res.uploadedList || [] return uploadedList.indexOf(chunk.offset) !== -1 } return false } }) async function onFileAdded(file) { // 文件加入队列时,立刻计算文件唯一标识 const fileHash = await computedFileHash(file.file) file.uniqueIdentifier = fileHash // 后续上传请求通过 target 后的 query 参数携带 uploaderOptions.query = { fileHash, fileName: file.name } } function onFileSuccess(rootFile, file, response) { // 上传完成,这里可以通知服务端触发合并 console.log('文件上传完成,准备合并', file.name) } function onFileError(rootFile, file, response) { console.error('分片上传失败', file.name, response) } function onUploadStart() { console.log('开始上传') } </script>这里有几个我特别想说明的点:
checkChunkUploadedByResponse这个回调是断点续传的核心开关。它决定了每个分片上传前,前端先向服务端发一个校验请求,根据响应判断该分片是否已存在。需要注意,这里判断的是chunk.offset,也就是分片在文件中的字节偏移量,而不是分片序号。offset等于index * chunkSize,uploadedList也要按偏移量存。
query对象里的fileHash是秒传和去重的关键。服务端会把这个哈希和已完成的文件做比对。
onFileAdded里我用了异步计算文件哈希。commit之后即时更新query,保证后续WebSocket(以及校验)请求能拿到。有一点要留意,如果文件很大,异步计算的耗时可能长达几十秒,这时候用户界面要给出提示。
4.4 服务端接口怎么配合
前端只是调度者,真正的断点状态和合并逻辑都在服务端。我用Express写了三个核心接口:
const express = require('express') const multer = require('multer') const fs = require('fs') const path = require('path') const app = express() const uploadDir = path.resolve(__dirname, 'tmp/uploads') const FINISHED_DIR = path.resolve(__dirname, 'tmp/merged') // 1. 接收分片 app.post('/api/upload/chunk', multer({ dest: uploadDir }).single('file'), (req, res) => { const { fileHash, chunkIndex, chunkTotal } = req.body const chunkDir = path.join(uploadDir, fileHash) if (!fs.existsSync(chunkDir)) fs.mkdirSync(chunkDir, { recursive: true }) const chunkPath = path.join(chunkDir, String(chunkIndex)) fs.renameSync(req.file.path, chunkPath) // 临时文件移动到位 res.json({ code: 0, uploaded: true, uploadedList: getUploadedList(fileHash, chunkTotal) }) }) // 2. 校验分片(GET方式,前端testChunks时调用) app.get('/api/upload/chunk', (req, res) => { const { fileHash, chunkTotal } = req.query const uploadedList = getUploadedList(fileHash, chunkTotal) const finished = checkFileExists(fileHash) res.json({ uploaded: finished, uploadedList: uploadedList }) }) // 3. 合并文件 app.post('/api/upload/merge', (req, res) => { const { fileHash, fileName, chunkTotal } = req.body const chunkDir = path.join(uploadDir, fileHash) const outputFile = path.join(FINISHED_DIR, fileHash + path.extname(fileName)) if (!fs.existsSync(chunkDir)) { return res.status(400).json({ code: 1, msg: '分片目录不存在' }) } const writeStream = fs.createWriteStream(outputFile) for (let i = 0; i < chunkTotal; i++) { const chunkPath = path.join(chunkDir, String(i)) if (!fs.existsSync(chunkPath)) { return res.status(400).json({ code: 1, msg: `分片 ${i} 缺失` }) } const buffer = fs.readFileSync(chunkPath) writeStream.write(buffer) } writeStream.end() // 合并完成后清理分片目录 fs.rmSync(chunkDir, { recursive: true, force: true }) res.json({ code: 0, url: `/download/${fileHash}` }) }) function getUploadedList(fileHash, chunkTotal) { const chunkDir = path.join(uploadDir, fileHash) if (!fs.existsSync(chunkDir)) return [] const uploaded = [] for (let i = 0; i < chunkTotal; i++) { if (fs.existsSync(path.join(chunkDir, String(i)))) { uploaded.push(i * 5 * 1024 * 1024) // 偏移量 = 序号 * 分片大小 } } return uploaded } function checkFileExists(fileHash) { // 检查合并目录里是否已有完整文件,实现秒传 const files = fs.readdirSync(FINISHED_DIR) return files.some(f => f.startsWith(fileHash)) } app.listen(3000, () => { console.log('upload server running at :3000') })这段代码是DEMO级别的,生产环境肯定要换成数据库记录分片状态、使用流式合并避免内存压力,但作为演示,它已经把核心链路跑通了:分片接收、状态校验、合并、清理。
我在接收分片时用fs.renameSync移动临时文件而不是直接读内容,是Multer已经被文件落到磁盘,我们只是换个位置,这样对大文件更友好。
4.5 跨域与Nginx转发设置
前端开发服务器是Vite默认的5173端口,后端是3000,必然跨域。我在DEMO里直接用Vite的proxy把/api代理到后端,避免开发期跨域干扰:
// vite.config.js export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } })演示部署时长这样配置Nginx,这里有一个平时容易忽略的点——Nginx默认的client_max_body_size只有1MB,而且会缓冲请求体,必须两个配置一起改:
server { listen 80; server_name demo.example.com; client_max_body_size 100g; location /api/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_request_buffering off; # 关闭请求体缓冲,让分片尽快转发 } location / { alias /opt/demo/dist/; try_files $uri $uri/ /index.html; } }proxy_request_buffering off一定要加。如果不加,Nginx会把整个分片请求体缓冲完整再转发给后端,对于有多个并发分片同时上传的场景,内存消耗会非常惊人。
5. 实测与调试:这些数据才是DEMO能交差的底气
代码写完不算完,DEMO这东西最怕演示现场翻车。我专门花了一天时间做实测,把各种能模拟的故障都模拟了一遍。
5.1 测试矩阵与实测数据
测试先要造大文件。我准备了三种规格,用命令直接生成,比网上找大文件测试包靠谱得多:
# Linux / macOS dd if=/dev/urandom of=test_100M.bin bs=1M count=100 dd if=/dev/urandom of=test_500M.bin bs=1M count=500 dd if=/dev/urandom of=test_2G.bin bs=1M count=2048 # Windows PowerShell fsutil file createnew test_2G.bin 2147483648用随机数据而不是全零数据,是为了避免文件内容过于简单导致哈希计算和网络传输失真。
实测环境是本机跑前端和后台,通过限制带宽模拟真实网络。数据如下:
| 文件大小 | 分片大小 | 并发数 | 总耗时 | 上传分片数 | 备注 |
|---|---|---|---|---|---|
| 100MB | 5MB | 3 | 8.2s | 20 | 正常 |
| 500MB | 5MB | 3 | 43.6s | 100 | 正常 |
| 2GB | 5MB | 3 | 186s | 410 | 出现3次分片重试 |
| 2GB | 50MB | 3 | 172s | 41 | 重试代价大,但分片数少 |
| 26GB | 5MB | 3 | 未完整跑完 | 约5324 | 验证分片管理能力 |
2GB文件测下来比较有意思:用小分片(5MB)重试成本低,但分片总数多,管理开销大;用大分片(50MB)分片数量少,但一旦某个分片传输失败,重试要重传50MB。DEMO阶段我最终选了5MB,兼顾重试代价和分片数量。
5.2 如何模拟断点续传的真实场景
这里我做了三组真实场景验证,每一组都扒了一层皮:
场景一:页面刷新恢复。上传2GB文件到62%左右时,直接强制关闭浏览器进程。重新打开页面,选择同一文件,点击上传。页面立刻显示“已上传62%”,然后从第63%开始继续,整个恢复过程没有重复上传已有分片。
场景二:服务端重启恢复。上传到一半,把后端进程杀掉重启。这里有个前提:我没有清空临时文件目录,所以分片文件还在磁盘上。重启后前端再传,服务端通过getUploadedList能正常识别已接收分片,续传成功。如果服务端在重启时清了上传目录,那么前端会被迫重新上传所有分片。这个场景让我意识到,续传的可靠性最终依赖于服务端状态的持久化,而不是前端的localStorage。
场景三:断网恢复。上传过程中手动禁用网卡,前端的并发请求立即报错,vue-simple-uploader会把这些失败的请求标记为待重试。恢复网络后,点击重试,组件自动把失败分片重新上传,然后继续。这里不需要用户重新选文件,体验和“刷新恢复”有明显区别。
这三组场景跑完后,我才觉得这个DEMO敢拿去演示了。
5.3 实测中发现的三个问题及完整排查链路
问题一:2GB文件计算哈希时页面卡死。
第一次选2GB文件时,点击文件后页面变白,几十秒没反应。定位过程是这样:先在浏览器Task Manager看CPU占用,发现JavaScript主线程100%,判定问题在前端同步计算。继续排查,找到工具函数里用的SparkMD5.ArrayBuffer同步计算逻辑。修复方案是把哈希计算放进Web Worker,主线程只负责接收结果。改完之后,页面保持流畅,2GB文件哈希计算大概用了8秒。
网上经常有“前端使用worker上传大文件”的需求,其实第一层要做的事就是让Worker去处理哈希计算。上传本身已经是异步的,真正需要Worker的是大文件的MD5计算。
问题二:合并时提示分片缺失,但上传明明显示100%。
排查链路比较长。我前后端加了日志,发现服务端日志显示某个分片最终接收成功,但合并时读取该分片文件却报不存在。最终原因:前端的并发数为3,最后一个分片的上传请求和服务端的合并请求几乎同时到达。前端收到“全部成功”的响应时,最后几个分片可能还在传输中。而我的合并接口是按顺序同步读分片文件的,自然读不到未落盘的分片。修复方案是:前端在全部文件成功事件后,增加一个setTimeout延迟2秒再触达合并请求;更严谨的做法是服务端合并前先做分片完整性轮询。
问题三:上传2GB文件期间,服务器磁盘空间被分片占满。
2GB文件切成5MB分片,有410个分片,每个分片在Multer落盘时是临时文件,重命名后又是另一个文件,瞬时磁盘占用可能是文件体积的两倍。加上.tmp过渡文件,峰值占用很恐怖。我在生产环境必须加一个定时清理任务,把超过24小时未完成的分片目录整体删除。这个坑在DEMO阶段暴露出来后,反而成了我向需求方展示“服务端需要考虑异常处理”的素材。
提示:DEMO调试断点续传,一定要用至少几百MB级别的真实大文件测试包。几MB的小文件无法暴露并发分片顺序、磁盘占用、哈希计算卡顿这类真实问题。
6. 如果继续往下做,这个DEMO还能怎么演变
DEMO交付之后,需求方果然追加了问题:“能不能加文件管理界面?”“能不能支持视频预览?”“能不能把下载功能也做了?”这些都是大文件上传做产品化的必经之路,我从技术角度盘点一下演变方向。
6.1 从DEMO到产品:还差哪些非功能需求
- 权限与配额管理:谁可以上传多大文件、存储配额多少,需要结合组织账号体系。
- 审计日志:文件名、上传时间、上传者、文件哈希、成功/失败状态都要留痕,但不能把敏感内容写入日志。
- 文件生命周期:临时文件清理、过期文件归档、存储空间统计。
- 病毒扫描与安全检测:上传的文件如果允许被其他用户下载,必须先经过安全扫描。
- 断点续传状态数据库化:把分片状态从前端localStorage、服务端内存/磁盘扫描,升级为数据库记录,支持多实例部署下的并发一致性。
这个问题我后来在方案评审时被问过:为什么不能直接上传整个文件?答案就是DEMO阶段验证的那些数据——2GB文件在上传过程中断的概率非常高,分片重试的成本远低于整文件重传。
6.2 周边需求会自己长出来
大文件上传一旦跑通,后续需求是连续的:
- 大文件下载与导出:上传解决后,如果还要支持下载或导出大文件,同样是分片思路,只是方向反过来,可以复用分片协议。
- 视频/文档在线预览:很多大文件是教学视频或图纸压缩包。视频文件可以考虑转码后播放,这就会碰到“vue播放m3u8免安装”这类需求——把原始视频转码成m3u8流媒体格式后,前端用hls.js直接播放,免插件、免安装。图片和PDF则可以用Canvas或iframe做预览。
- 文件批量上传:一次选多个大文件,每个文件独立维护uploadId和断点状态,组件层面要支持一个队列。
这些看起来是“附加功能”,但其实是单个上传能力演进成文件系统的必然方向。
6.3 延伸思考:方案评审和面试里常被问到的点
我把做这个DEMO过程中被问到最多的几个问题整理了一下,它们也是理解大文件上传深度的试金石:
- 为什么不用WebSocket或纯Fetch做上传?主要考虑是HTTP的无状态特性反而让断点记录更简单,而且分片请求天然支持并发和重试。
- 为什么选择5MB分片而不是500KB或500MB?分片越小,重试代价越小,但分片越多,管理开销越大,服务端文件和请求数越多。5MB在大多数场景下是平衡点,但实际项目要根据带宽和文件大小动态调整。
- 秒传的边界条件是什么?只有服务端完整保留了原始文件才能秒传。如果文件被清理了,秒传就失效。
- 前端计算哈希在超大文件下是否现实?现实,但必须放Worker。并且可以做一个MD5增量计算,不必一次性读入内存。
问这些问题的往往是后端架构师或者资深前端,他们关心的不是“能不能传”,而是“传的时候系统处在什么状态、出问题时怎么恢复”。
做完整套DEMO,我最大的体会是:在内网受限环境里做技术选型,往往不是选最好的,而是选最少坑的。vue-simple-uploader帮我把分片管理的复杂逻辑藏了起来,但恰恰是在排问题的时候,我才真正把分片、校验、合并这些底层机制吃透了。
如果你也接到类似的大文件断点续传需求,我的建议是别急着写代码,先把验收边界和文件规格问清楚,再用现成组件快速拉通一条链路,跑一遍真实大文件,把问题暴露出来,再决定哪些地方要自己补。最后留一个小技巧:调试时一定准备几个不同大小的真实文件,几百MB的、一两GB的都要有,分片逻辑很多坑,只有文件足够大、分片足够多的时候才会现身。