做上传功能最怕什么?最怕用户传了半个多小时、进度条跑到93%,结果网络一抖,全部归零重来。我因为这个被业务方找过好几次,后来下定决心把大文件上传里的断点续传彻底做透。这篇文章就是我落地这套方案的完整记录,包括切片思路、前端代码实现、服务端配合要点,以及我在并发控制、秒传判断、异常恢复这些环节里踩过的坑和优化策略。如果你也在用Vue做上传模块,而且文件动不动就上GB,这篇应该能帮你少走不少弯路。
1. 断点续传的整体设计思路
1.1 为什么不能直接post整个文件
先说一个最直观的问题:为什么大文件不能像普通表单那样,拿到File对象就直接post?
第一个瓶颈是请求体大小。虽然HTTP协议本身没有限制请求体长度,但服务端、网关、代理层基本都有默认的body大小限制。Nginx默认的client_max_body_size是1m,Tomcat默认的maxPostSize是2MB,Spring Boot里spring.servlet.multipart.max-file-size默认也只有1MB。也就是说,你不改配置,超过1MB的文件POST过去直接被拒。就算你把这些限制全调大,一个2GB的文件在传输过程中只要断一次,TCP连接断开,整个请求就废了,前面传的几十GB流量全部白费。
第二个瓶颈是内存。浏览器里File对象对应的数据并不驻留在JS内存里,它是一段磁盘或者内存映射的资源。但如果你直接通过formData.append('file', file)上传,浏览器要把文件数据流式读出来塞进请求体。一旦文件很大,浏览器、服务器两端都要承受巨大的内存和带宽压力,很容易直接把页面卡死或者服务OOM。
第三个瓶颈才是断点续传本身。用户辛辛苦苦传了80%,网络断了,服务端已经把前面80%的分片落盘了,但客户端没有任何记录,只能重来。断点续传的核心,就是要把“整个文件的一次性传输”拆成“很多个独立小块的多次传输”,每一块传完就记录状态。下次打开页面发现某个分片已经传过了,就直接跳过。
所以大文件上传的第一步,永远是切片。切片是现代大文件上传方案的基石,没有切片,后面的秒传、续传、并发控制全都无从谈起。
1.2 核心方案选型:为什么选分片上传+分片校验
市面上的方案其实就三大类:formData一把梭、流式上传、分片上传。
formData一把梭上面说了,只适合小文件。流式上传通常依赖WebSocket或者fetch的stream能力,实现复杂,而且断点续传依然要自己实现,收益不大。真正被验证过的成熟方案就是分片上传,具体来说包含三个环节:
- 上传前:把File切成固定大小的Blob分片,每个分片都有独立的序号。
- 上传中:每个分片单独请求,支持并发,也支持单个分片失败后重试。
- 上传后:服务端把所有分片按序号合并成完整文件,合并完成后标记文件状态。
这里面最关键的是分片校验。因为网络传输是有可能出错的,TCP虽然保证数据不丢不乱,但中间经过的代理、网关如果做了一些奇怪的处理,或者浏览器端读Blob出现异常(这种概率很低但不是零),你传上去的分片可能已经损坏了。所以每个分片除了data,还要带上它的唯一标识——通常是分片的MD5。服务端收完分片先校验MD5,不一致就返回错误,客户端重新传这个分片。这样能保证合并出来的文件一定是完整的。
分片的大小也有讲究。我实测下来的经验是:一般文件用1MB到5MB的分片比较合适,大文件(超过2GB)用5MB到10MB。分片太小,请求数爆炸,光HTTP握手和请求头开销就吃掉很多性能。分片太大,单次传输时间变长,失败重试的成本变高,断点续传的粒度也变粗了。我做完一轮压测后固定用的是5MB一片,后面会详细说这个值是怎么定的。
1.3 断点续传的状态标记机制
断点续传要说清楚,得先搞清楚“断点”到底记录在哪。是记录在客户端,还是记录在服务端?答案是两边都要有,但它们各自负责的东西不一样。
客户端要记录的是:这个文件我打算传哪些分片,其中哪些已经传成功了,哪些还在传,哪些失败了需要重试。这个信息存在浏览器本地,最合适的地方就是localStorage或者IndexedDB。localStorage简单,但容量只有5MB左右,而且只能存字符串,存一个包含成千上万个分片状态的对象可能会超。IndexedDB容量大得多,但API是异步的,操作繁琐。我的做法是:用一个ref对象在内存里维护当前上传任务的分片状态,同时做节流,每隔一段时间把状态快照写入localStorage。文件指纹加任务ID作为key,这样刷新页面、关闭浏览器重开,都能把进度恢复回来。
服务端要记录的是:这个文件在服务器上已经存在哪些分片。因为客户端本地状态是不可信的——换一台机器、清掉浏览器缓存,本地状态就没了。只有服务端才是“到底传了哪些数据”的最终裁判。服务端可以用Redis或者数据库记录分片状态,也可以直接扫描分片临时目录里的文件。
用一句话概括整个断点续传流程:上传前先问服务端“这个文件传过没有,传了哪些分片”,服务端返回已上传分片列表,客户端过滤掉这些分片,只传剩下的。这就是断点续传的全部秘密,没有黑魔法。
2. Vue前端核心功能实现
2.1 文件切片与哈希计算的落地代码
讲完原理,直接上代码。我用的技术栈是Vue 3 + Vite + Pinia,但你用Vue 2也没关系,核心逻辑都是通用的。
先说两件准备工作:计算文件哈希、文件切片。这两个可以同时做,但在大文件场景下,哈希计算往往比切片更耗时间,所以我们把切片放在哈希计算之后,这样用户等待哈希的期间内存开销最小。
计算文件哈希,我用的库是spark-md5。这是目前前端计算文件指纹最常用的库,纯JS实现,兼容性非常好。不过要注意一个细节:1GB的文件如果一次性读进内存算MD5,浏览器直接崩溃给你看。所以spark-md5也提供了增量计算的能力,配合FileReader分块读取,可以一边读一边喂给spark,内存占用始终可控。
先看切片和哈希计算的代码:
// utils/file.js import SparkMD5 from 'spark-md5' const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB // 计算文件哈希(增量计算,内存友好) export function calcFileHash(file) { return new Promise((resolve, reject) => { const chunkSize = 2 * 1024 * 1024 // 每次读2MB const chunks = Math.ceil(file.size / chunkSize) let currentChunk = 0 const spark = new SparkMD5.ArrayBuffer() const fileReader = new FileReader() fileReader.onload = (e) => { spark.append(e.target.result) currentChunk++ if (currentChunk < chunks) { loadNext() } else { resolve(spark.end()) } } fileReader.onerror = (err) => { reject(err) } const loadNext = () => { const start = currentChunk * chunkSize const end = Math.min(start + chunkSize, file.size) fileReader.readAsArrayBuffer(file.slice(start, end)) } loadNext() }) } // 把文件切成固定大小的分片数组 export function createFileChunks(file, chunkSize = CHUNK_SIZE) { const chunks = [] let cur = 0 while (cur < file.size) { chunks.push({ file: file.slice(cur, cur + chunkSize), index: chunks.length, size: Math.min(chunkSize, file.size - cur) }) } return chunks }这里有两个编码上的细节要说明。第一,file.slice拿到的是Blob对象,它不占用JS堆内存,底层是文件系统或者内存映射的引用。但是当你把它塞进FormData,浏览器序列化请求体时,数据就会流进内存。所以切片数组本身别一直抱着不放,上传完当前批次就释放引用。第二,readAsArrayBuffer读出来的ArrayBuffer是真实占用内存的,每次2MB问题不大。如果你用readAsBinaryString,在老浏览器上性能反而差,而且编码容易出问题,不推荐。
2.2 上传状态管理与并发控制的实现
好,现在有切片了,有指纹了。接下来要解决的是“这些分片怎么发出去”。
很多人第一个版本是for循环挨个传,每个分片要等上一个完成才发起下一个。这种方式极慢,2GB文件切成5MB一片,就是410个分片,每个请求就算只要1秒,串行也要7分钟,用户根本等不了。并发是必须的,但并发也不能无限大。浏览器对同一域名的并发连接数有限制,HTTP/1.1下Chrome是6个,HTTP/2虽然能多路复用,但客户端的XHR并发连接池依然有上限。把全部400个分片一次性丢出去,浏览器要排队,内存要爆炸,服务端也要被冲垮。
我的做法是搞一个简单的并发池,控制同时发起的分片上传请求数量。这个并发池不依赖第三方库,几十行代码就够:
// 并发池实现 export async function runWithConcurrency(tasks, limit) { const queue = [...tasks] const workers = new Array(Math.min(limit, queue.length)).fill(null).map(async () => { while (queue.length) { const task = queue.shift() await task() } }) await Promise.all(workers) }然后在Pinia的store里,我把上传分成了几个状态:pending等待上传、uploading正在上传、success上传成功、failed上传失败。每个分片上传成功后,更新store里的状态,同时写入本地记录。
具体上传逻辑长这样:
// stores/upload.js(Pinia写法,Vuex类似) export const useUploadStore = defineStore('upload', { state: () => ({ fileHash: '', fileName: '', totalChunks: 0, chunkSize: 0, chunkList: [], // 每个分片的状态对象 uploadProgress: 0, isUploading: false, isPaused: false }), actions: { // 初始化上传任务 async initUpload(file) { const hash = await calcFileHash(file) this.fileHash = hash this.fileName = file.name this.chunkSize = CHUNK_SIZE const chunks = createFileChunks(file, CHUNK_SIZE) this.totalChunks = chunks.length // 问服务端哪些分片已经传过了 const uploadedList = await checkExist({ fileHash: hash, fileName: file.name }) this.chunkList = chunks.map((chunk, index) => ({ ...chunk, index, status: uploadedList.includes(index) ? 'success' : 'pending' })) await this.startUpload() }, // 启动上传 async startUpload() { this.isUploading = true const pendingChunks = this.chunkList.filter(c => c.status === 'pending') await runWithConcurrency( pendingChunks.map(chunk => () => this.uploadChunk(chunk)), 6 ) }, // 上传单个分片 async uploadChunk(chunk) { const formData = new FormData() formData.append('file', chunk.file) formData.append('fileHash', this.fileHash) formData.append('chunkIndex', chunk.index) formData.append('totalChunks', this.totalChunks) try { await request({ url: '/api/upload/chunk', method: 'post', data: formData, timeout: 60000 }) chunk.status = 'success' this.saveLocalProgress() } catch (err) { chunk.status = 'failed' // 失败的分片在下一轮重试 throw err } } } })注意到runWithConcurrency这里有个坑,任务函数里抛错会导致Promise.all直接reject,整个池子里还没执行的任务也会被丢弃,并不是“一个失败全部失败”我们想要的。正确做法是在uploadChunk内部捕获错误,把失败分片记录下来,但不让异常冒泡出去打断并发池。上面代码里我保留了throw,但实际项目里应该改成收集错误、让并发池跑完、最后统一处理失败分片。
2.3 进度计算与文件合并的触发机制
上传进度看起来是个老话题,但在大文件断点续传里,进度的计算方式直接决定了用户体验。
最土的办法是把所有分片上传请求挂在同一个onUploadProgress上,用XMLHttpRequest的upload.onprogress去累加。这个方案在大文件下有一个致命问题:如果用户上次传过60%,本次只需要传剩下40%,但进度条从0开始,用户会以为上传进度丢失了。正确做法是——每个分片独立计算进度,然后加权汇总到总进度里。
总进度的公式是:
总进度 = 已上传成功的分片数 / 总分片数这个公式看起来简单,但要注意一个衍生的需求:如果服务端支持“部分分片已上传”,那么初始化时,已上传的分片要直接算进已完成的进度里。也就是:
const doneCount = this.chunkList.filter(c => c.status === 'success').length this.uploadProgress = ((doneCount / this.totalChunks) * 100).toFixed(2)每一个分片上传成功,doneCount就加一,重新算进度。这种做法在断点续传场景里最可控、最准确。如果你想做得更精细,可以把每个分片内部的字节级进度也算进来,但说实话收益不大,显示逻辑还复杂。
然后说合并。分片全部传完,前端要做的事就一件——通知服务端“可以合并了”。
// 所有分片上传完成后,触发合并 async function notifyMerge() { const res = await request({ url: '/api/upload/merge', method: 'post', data: { fileHash: store.fileHash, fileName: store.fileName, totalChunks: store.totalChunks, chunkSize: store.chunkSize } }) return res.data }服务端收到合并请求后,把所有分片按顺序拼接成完整文件。这一步可能在服务端耗时较长(几个GB文件合并需要秒级到十几秒),所以合并接口最好是异步的——先返回“合并中”,前端轮询查询合并状态,合并完成再返回最终文件地址。同步合并的话,请求超时风险很大,这块后面展开讲。
3. 服务端配合要点与接口设计
3.1 分片上传、校验、合并的接口约定
虽然标题是Vue断点续传,但服务端接口的约定不搞清楚,前端写多少都是白搭。我用的后端是Java Spring Boot,但接口设计是语言无关的,你可以平移到任何后端。
我设计了三个核心接口:
POST /api/upload/chunk 上传单个分片 GET /api/upload/check 检查文件是否上传过、哪些分片已上传 POST /api/upload/merge 合并所有分片分片上传接口接收的参数:file分片文件、fileHash文件MD5、chunkIndex分片序号、totalChunks总分片数。服务端收到分片后,先按fileHash和chunkIndex生成分片临时文件名,验证MD5(这里有两个MD5,一个是文件哈希,一个是分片哈希,要注意别搞混),一致就落盘到临时目录。
合并接口接收:fileHash、fileName、totalChunks。服务端按顺序读取所有分片,写入目标文件。合并完成后,删掉临时分片目录。
核心伪代码:
// ChunkUploadController.java 关键代码 @PostMapping("/upload/chunk") public Result uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("fileHash") String fileHash, @RequestParam("chunkIndex") Integer chunkIndex, @RequestParam("totalChunks") Integer totalChunks) { String chunkDir = uploadPath + "/" + fileHash; File dir = new File(chunkDir); if (!dir.exists()) dir.mkdirs(); String chunkFileName = fileHash + "_" + chunkIndex + ".part"; File chunkFile = new File(chunkDir, chunkFileName); file.transferTo(chunkFile); return Result.success(); } @PostMapping("/upload/merge") public Result merge(@RequestParam("fileHash") String fileHash, @RequestParam("fileName") String fileName, @RequestParam("totalChunks") Integer totalChunks) { File chunkDir = new File(uploadPath + "/" + fileHash); File mergeFile = new File(uploadPath + "/" + fileName); try (FileOutputStream fos = new FileOutputStream(mergeFile, true); BufferedOutputStream bos = new BufferedOutputStream(fos)) { for (int i = 0; i < totalChunks; i++) { File chunkFile = new File(chunkDir, fileHash + "_" + i + ".part"); try (FileInputStream fis = new FileInputStream(chunkFile); BufferedInputStream bis = new BufferedInputStream(fis)) { byte[] buffer = new byte[1024 * 1024]; int len; while ((len = bis.read(buffer)) != -1) { bos.write(buffer, 0, len); } } } } // 合并完成后清理分片目录 deleteDir(chunkDir); return Result.success(); }3.2 已传分片查询与秒传逻辑
断点续传的前提是“能知道历史状态”,而查询已上传分片的接口是这一切的入口:
GET /api/upload/check?fileHash=xxx&fileName=xxx返回结构:
{ "code": 0, "data": { "uploaded": true, "uploadedChunks": [0, 1, 2, 5, 6, 8] } }服务端拿到fileHash后,到临时目录里扫一遍,看哪些.part文件存在,返回它们的序号数组。前端拿到这个数组后,把对应分片的status直接置为success,不用重新上传。
这里有一个关键优化:秒传。如果服务端扫描临时目录发现文件完整存在,或者文件已经在目标目录里,就直接返回uploaded: true并且uploadedChunks是完整数组。前端检查到这种情况,直接跳过上传过程,显示“秒传成功”。这个体验非常爽,尤其是文件重传场景——用户拖入一个已经传过的文件,瞬间完成,连哈希计算都别重复做,因为fileHash一致就直接判定是同一个文件。
关于存分片状态的位置,大项目我会推荐用Redis,把每个分片的上传状态存成一个bitmap或者set。小项目直接用文件系统扫描也行,简单粗暴,但注意分片多到几千个时,文件扫描会有延迟。生产环境下更稳的做法是边上传边在一个chunk_upload_record表里记录分片状态,接口直接查表,不走磁盘扫描。
3.3 服务端合并超时与异步化处理
上面那段合并代码在文件较小(几百MB以内)时问题不大。一旦文件超过1GB,合并时间可能超过十几秒甚至更久,这时候前端发一个HTTP请求等在那,分分钟超时。
我踩过这个坑之后,把合并改成了异步流程:
- 前端发合并请求,服务端收到后,把合并任务丢进线程池。
- 服务端立刻返回
{"code": 0, "message": "合并中", "data": {"mergeId": "xxx"}}。 - 前端拿到mergeId后,轮询
GET /api/upload/merge/status?mergeId=xxx。 - 服务端线程池里的任务合并完,更新状态为完成,轮询接口返回最终文件地址。
这个异步合并的改造特别重要。因为我发现很多团队只改了上传的超时时间,没意识到合并也是个耗时操作。你把spring.servlet.multipart.max-file-size调到10GB、把Nginx的proxy_read_timeout调到300秒,能解决一部分问题,但治标不治本。异步化之后,无论文件多大,前端都不用担心HTTP连接断开。
线程池的配置也要注意。别用无界队列,否则大量合并任务同时进来,内存直接撑爆。我的配置是核心线程2、最大线程4、队列容量100,合并任务的优先级可以低于上传任务,毕竟用户已经在传文件了,合并不差那几秒。
4. 断点续传的优化策略与实践经验
4.1 哈希计算的性能优化
我前面给的calcFileHash是老实计算全量MD5,文件多大就计算多大。2GB文件算全量MD5,在普通电脑上大概要20到40秒,这个时间用户是干等着的,体验很差。
优化方案有两种。第一种是抽样哈希。比如2GB的文件,我每隔一定字节取一段数据,每段大小可配置,总取样量控制在2MB左右,然后对这2MB做MD5。这样做的好处是速度快了十倍不止,坏处是理论上存在碰撞风险——两个不同的文件抽样结果撞上了。但实际工程里,抽样碰撞的概率低到可以忽略,因为每个文件的头部、中间、尾部都采样后,相同结果的概率极低。我的做法是取文件头部1MB、中间1MB、尾部1MB,再加文件大小,一起拼起来算MD5,碰撞概率基本可以无视。
第二种是全量计算但配合Web Worker。如果你必须做全量校验,那就别让哈希计算阻塞UI线程。Vue项目里可以用Web Worker,把文件读取和MD5计算全丢到worker线程:
// worker.js import SparkMD5 from 'spark-md5' self.onmessage = function (e) { const file = e.data.file const chunkSize = 2 * 1024 * 1024 const chunks = Math.ceil(file.size / chunkSize) let currentChunk = 0 const spark = new SparkMD5.ArrayBuffer() const fileReader = new FileReader() fileReader.onload = function (event) { spark.append(event.target.result) currentChunk++ if (currentChunk < chunks) { loadNext() } else { self.postMessage({ hash: spark.end() }) } } function loadNext() { const start = currentChunk * chunkSize const end = Math.min(start + chunkSize, file.size) fileReader.readAsArrayBuffer(file.slice(start, end)) } loadNext() }主线程里:
const worker = new Worker(new URL('./worker.js', import.meta.url)) worker.postMessage({ file }) worker.onmessage = (e) => { console.log('文件哈希:', e.data.hash) worker.terminate() }这里注意Vite和Web Worker的配合方式。Vite下用new URL('./worker.js', import.meta.url)是官方推荐的写法,打包时会自动处理worker文件的产物。如果用Vue CLI,写new Worker('@/utils/worker.js')就行。
用户等待哈希计算时,前端可以显示一个进度条,告诉用户“正在分析文件”。虽然分析阶段不传数据,但给用户一个心理预期,体验会好很多。我见过一些产品连这个提示都没有,用户拖入大文件后界面卡住几秒,以为网站崩了,直接关页面。
4.2 并发数的动态调整
固定并发数是6还是10,不是拍脑袋定的,应该基于你的服务器带宽和网络环境动态调整。
如果你的服务器下行带宽是100Mbps,理论每秒能接收12.5MB。每个分片5MB,一次并发6个分片同时传,最多能打满带宽。但这种满载状态对服务端压力很大,尤其是磁盘IO,6个流同时写入再加上哈希校验,很容易出现IO瓶颈。
我的策略是:并发数做成可配置项,默认6,同时加一个简易的动态调节逻辑——如果最近连续N个分片上传成功且平均耗时低于某阈值,就把并发数往上调一点;如果出现失败,就降低并发。这个逻辑不复杂,但很实用,尤其适合网络环境不稳定的场景,比如用户在公司内网传一半拔了网线换Wi-Fi。
具体代码思路:
let concurrency = 6 let successCount = 0 let failCount = 0 function adjustConcurrency() { if (failCount > 0) { concurrency = Math.max(2, Math.floor(concurrency / 2)) failCount = 0 } else if (successCount >= 10) { concurrency = Math.min(12, concurrency + 2) successCount = 0 } }注意动态调并发的前提是,你的上传队列得支持在运行中调整worker数量。最简单的实现是每次调整时暂停队列、重新创建并发池,但这会导致正在传输的分片被中断。更好的方式是维护一个信号量,正在执行的请求让它跑完,队列调度时根据当前并发数决定是否取出新任务。
4.3 失败重试与网络恢复策略
分片上传最大的优势之一是失败成本低——一个5MB的分片失败了,重传也就5MB。但前提是你得正确地处理重试。我的重试基础策略是:
- 单个分片失败,立即重试,最多重试3次。
- 如果连续失败超过3次,暂停该分片的自动重试,保留状态,等待用户手动“重试失败任务”或“全部重新上传”。
- 如果整个上传任务失败了,记录进度状态到本地,用户重新进入页面时,从本地读取状态,继续上传未完成的分片。
关于“网络恢复后如何继续”,我建议有两个层次的实现。第一层是在线重试:定时检查navigator.onLine和window.addEventListener('offline'/'online')。断网时自动暂停队列,恢复网络后自动继续。第二层是离线恢复:用户关掉页面再回来,本地存储的进度状态让整个任务可以从断点继续。
这里有一个坑:localStorage存储进度的时机。如果你的进度写入过于频繁,会卡UI。因为localStorage是同步写的,几MB的状态数据频繁写入,在低端手机上会有明显卡顿。我做了节流,每3秒存一次,或者收到visibilitychange事件(用户切走页面或关闭标签页)时立刻保存一次。这样既保证了掉线后最多丢失3秒的进度,又不会影响性能。
4.4 内存与垃圾回收的处理经验
大文件上传页面最容易出现的问题就是内存暴涨、页面卡死。大部分原因不是上传本身,而是开发者在过程中创建了大量临时对象没有释放。
最常见的内存泄漏点有三个:
第一是分片数组。文件切片后,如果把整个文件的所有分片都放进数组并保持引用,几GB文件的内存映射全部常驻。2GB文件切成5MB就有410个Blob,每个Blob是一个底层数据的引用。虽然Blob不直接占堆内存,但太多引用会让GC压力很大。解决方法是:不要一次性生成所有分片,而是按需生成,上传到哪个分片,就file.slice哪一段。分片用完了,立刻置空引用。
第二是FormData对象。每个分片上传时创建的FormData在请求结束后应该被释放,但如果闭包里意外捕获了FormData,它内部的Blob引用就一直在。上传函数别用闭包持有整个分片对象,用参数传递就好。
第三是worker的垃圾回收。每一次哈希计算都new Worker,算完一定要worker.terminate()。不terminate的worker会一直停留在后台,累计多了内存占用非常惊人。我见过线上环境传几次大文件后浏览器直接崩溃的案例,查到最后就是worker没销毁。
4.5 用户取消与暂停的交互处理
断点续传不是只有“断了再续”,还包括用户主动“暂停”和“取消”。这两个动作在交互层看起来只是按钮,但实现上有完全不同的处理逻辑。
暂停:点击暂停后,不再发起新的分片上传请求,正在传输的请求让它们自然完成,然后整个队列停下来。已上传的分片进度保留,下次点继续,从任务断点恢复。暂停时要把当前进度状态写入本地存储,防止用户暂停后直接关页面。
取消:点击取消后,先终止所有正在传输的请求,然后清空前端状态,告诉服务端删除该文件的临时分片。这里务必调用服务端的清理接口,否则服务端临时目录里会堆积大量无人认领的.part文件,硬盘几天就被吃满。
服务端清理接口:
DELETE /api/upload/cancel?fileHash=xxx前端取消后的状态重置也要彻底,内存里的分片引用、本地存储的记录、UI的进度显示全部清空。不清干净,用户重新拖入同一个文件,会莫名出现“已经传了80%”的奇怪状态。
5. 常见问题与排查技巧实录
5.1 上传请求经常超时,怎么定位
大文件上传里最让人头大的就是请求超时。我遇到过的情况太多了:有些是Nginx的proxy_read_timeout默认60秒,一个分片传了59秒还没传完,nginx直接把连接掐了;有些是服务端的Multipart解析用了大量内存,GC停顿好几秒,前端等不到响应;还有一些是用户网络本身慢,一个5MB分片要传两分钟。
排查这类问题的第一件事是看请求耗时。浏览器DevTools的Network面板里,每个分片请求的耗时、TTFB、content download时间一目了然。如果TTFB特别长,说明服务端处理慢;如果download时间特别长,说明带宽是瓶颈。定位到环节后,才能对症下药。时间长的调大timeout,服务端处理慢的要看线程池、看GC日志,带宽瓶颈就只能压缩并发数或者提示用户换网络。
5.2 并发一高就报内存溢出
高并发下前端内存溢出,主要是FormData对象的堆积和响应数据没有及时清理。后端的内存溢出,症状是接口502,日志里出现OutOfMemoryError,一般就是一次上传了太多分片,服务端把分片临时文件直接读进内存做校验导致的。
后端处理分片文件的时候,务必用流式处理,不要FileUtils.readFileToByteArray()一把梭。另外,服务端接收分片的MultipartResolver也要注意,Spring Boot默认的StandardServletMultipartResolver会把临时文件写到磁盘,但如果你配置了max-in-memory-size,小于这个值的分片会直接留在内存里。max-in-memory-size默认是0,也就是所有内容都落磁盘,反而比较安全。别为了提高性能改大这个值,大文件场景下内存安全比那点性能重要得多。
5.3 进度条回退和不准的问题
进度条回退的经典场景是:并发上传时,你计算的进度是“已成功分片数/总分片数”,某个分片被并发池取出开始上传,但还没完成,此时它不在成功列表里,重新计算进度时这部分没有纳入,用户就会看到进度条突然回跳。本质是进度计算逻辑把上传中和成功两个状态混为一谈。
修正方案是进度统计使用乐观策略:
function getProgress() { const uploadedCount = chunkList.filter(c => c.status === 'success' || c.status === 'uploading').length return Math.floor((uploadedCount / totalChunks) * 100) }把正在上传的分片也算进进度,进度条就不会回退了。虽然这样进度可能“虚高”一点点,但用户体验远好于看到进度条回退。
5.4 合并后文件损坏,多半是分片顺序或数据问题
合并后文件打不开、内容乱码、视频花屏,这类问题很常见。按我的经验,95%的情况出在以下三处:
第一,合并时没有从0开始写,导致旧数据残留。合并前先删除目标文件,或者打开文件流时用覆盖模式而不是追加模式。我上面代码里用了new FileOutputStream(mergeFile, true),这里true是追加模式。如果之前某次合并失败留下半个文件,再次合并时会从旧文件尾部继续写,文件必坏。正确做法是先删除已存在的目标文件,再以非追加模式创建。
第二,分片之间没有按照chunkIndex严格排序。不管是前端上传还是后端合并,顺序必须由chunkIndex决定,不能依赖文件名顺序。文件名是fileHash_1.part、fileHash_10.part、fileHash_2.part这种,如果按字符串排序,10会排在2前面,合并出来的文件就乱了。一定要解析出数字索引再排。
第三,分片数据本身就是坏的。前面提到过分片MD5校验,如果没做校验或者校验逻辑写过等于没写,网络错误产生的脏数据就会混进合并文件。排查时可以对已上传的分片批量重算一次MD5,和上传前的MD5比对,就能定位到是哪个分片出的问题。
5.5 服务端临时目录堆积了很多未合并分片
这个问题属于运维层面的,但前端也要负责。用户在传输过程中关掉了浏览器、上传失败后直接放弃、或者根本没有走取消接口,服务端临时目录里的.part文件就成了垃圾。如果每个文件有几百个分片,垃圾文件几天就能堆积几个GB。
解决思路有两个层面:
第一,服务端做定时清理任务。扫描临时目录,找出创建时间超过24小时且没有对应合并记录的分片目录,直接删除。注意删除条件要慎重,不能把正在上传的文件删了。可以加一个活跃标记,每次该文件有分片上传,就更新文件的最后活跃时间,清理任务只清超过一定时间未活跃的目录。
第二,前端做好善后。组件卸载时、上传失败放弃时、页面隐藏时(visibilitychange),如果任务还没完成,主动调用取消接口,通知服务端清理临时分片。这能大幅减少垃圾文件的产生。
写在最后
断点续传这套方案,原理上一点都不玄乎:切片、标记、并发、恢复,四个词就能概括。但真正把它做扎实,需要打磨的细节非常多,尤其是异常场景的处理——网络断了怎么恢复、并发高了怎么控速、服务端挂了怎么善后、用户取消了怎么清理。这些才是断点续传在生产环境里真正值钱的部分。
我自己的体会是,不管用什么框架,前端的核心拼图无非就是:哈希计算不阻塞UI、并发池可调速、状态持久化可靠、失败重试有兜底。把这四张拼图拼好,再跟服务端约定清楚接口语义,大文件上传这块就稳了。
最后再分享一个小技巧:如果你在做视频、压缩包这种动辄几GB的格式,建议把分片大小调成10MB,并发数降到4。大文件分片太碎反而拖慢速度,这是我压了多次基准测试得出来的,不同场景差异挺大,别照搬一个值就用。