
大文件上传这块儿几乎每个做Web开发的朋友迟早都会撞上。网盘传视频、后台传安装包、医疗影像系统传DICOM文件、企业OA传设计稿凡是超过一两百兆的文件动不动就是上传超时、进度条卡死、网络一抖整个文件报废。我早年在某公司做过一个跨平台文件管理系统的上传模块里面有GB级别的海外设计原片需要从各地分公司回传总部那时候真的是被大文件上传折磨得够呛后来老老实实把秒传、断点续传、分片上传这套体系全部落地才算真正治好了这个心病。这篇文章我想把整套方案的来龙去脉讲透为什么说分片上传是大文件上传的地基秒传背后依赖的是什么原理断点续传要做到哪一步才算真正的“断点续传”。不是丢一堆概念给你而是把每一步怎么设计、参数怎么定、前后端怎么配合、会踩哪些坑都摊开来说清楚。1. 先从整体聊起这套方案到底解决了什么问题1.1 大文件上传的三个致命痛点常规的表单上传本质就是一次HTTP请求把整个文件塞进请求体里。文件小还好说一旦文件到了几百兆甚至几个GB三个问题就会接踵而来。第一个是网络稳定性。HTTP请求虽然底层走TCP但一次请求的生命周期很长中间任何一次抖动、断网、路由器重启都可能导致整条连接断开。而连接一旦断开浏览器端的请求状态立刻变成失败用户只能重新选择文件再传一次。这个体验在弱网环境下几乎等于不可用。第二个是服务端限制。绝大多数网关、Web服务器默认对请求体体积有上限比如Nginx默认的client_max_body_size是1MBTomcat有maxPostSize限制云厂商的SLB也有类似约束。就算你调大这些参数单请求传GB级别的数据也会占用巨大的内存缓冲后端接口很容易直接被拖垮。第三个是时间成本。一个500MB的文件在普通带宽下可能要好几分钟整个请求期间前端拿不到任何精细的进度反馈。浏览器对XHR有进度事件但也只能看到“当前总进度”一旦失败全部归零根本谈不上“进度管理”。这三个问题叠加在一起就形成了一个结论大文件不能走单请求上传的老路必须把文件拆小、把上传过程变成可重试、可校验、可追踪的一系列小任务。1.2 三套机制的分工与协同我落地这套体系时把整个目标拆分成了三个层次它们之间不是替代关系而是逐层叠加的关系。分片上传是地基。它把一个大文件按固定大小切成一串分片比如每片5MB逐个上传。每个分片都是独立的HTTP请求单个分片失败了只需要重传这个分片不会影响其他分片。分片全部上传完成后由后端触发合并流程还原成完整文件。断点续传是在分片基础上的状态记录机制。它把“哪些分片已经上传成功了”这个状态持久化下来。刷新页面、关闭浏览器、甚至换一台电脑只要你能拿到之前记录的状态就能跳过已上传的分片只传缺失的部分。秒传则是一个前置判断机制。它不做真正的上传而是根据文件的唯一标识去后端查询是否已经存在相同内容的文件。如果存在后端直接把这个文件关联到当前用户相当于“上传”瞬间完成。一句话总结就是秒传负责“不用传”分片负责“拆开传”断点续传负责“接着传”。三者组合起来才是完整的大文件上传方案。1.3 这套方案适配哪些场景从我的实践经验来看以下这些场景非常适合直接引入这套方案网盘类产品用户上传视频、压缩包、镜像文件文件体积天然很大且需要支持暂停恢复。企业协作平台设计稿、高清图片、项目资料动辄几百MB需要一个可靠的上传通道。音视频处理系统上传素材后需要转码文件完整性直接影响转码成功率。后台管理系统的附件模块如果业务里经常出现大附件不能指望用户一次性传完。如果你的业务场景里文件通常在几十MB以内、网络环境非常稳定、内网带宽充足那确实没必要过度设计。但只要是面向公网、面对不特定用户、文件可能超过100MB这套方案就不是可选项而是必选项。2. 核心细节解析与实操要点2.1 分片大小怎么定不是越碎越好分片大小是整个方案里第一个需要拍板的参数我见过不少人在这里栽过跟头。分片定太大比如50MB一片和整体上传没有本质区别断点续传的粒度太粗分片定太小比如512KB一片分片数量爆炸请求开销和数据库记录数都会成倍增长。我的建议是常规公网环境下4MB到10MB是一个比较甜点的区间。举个例子假设文件是200MB分片大小定为5MB那么分片总数是40片。每一片上传大约需要1到3秒40片的总请求量对后端完全可接受。如果把分片定为1MB分片数变成200片虽然重试粒度更细但每次请求的握手开销、鉴权开销累加起来效率反而不如5MB分片。实际业务里可以做两档适配默认5MB对于网络环境非常好的内网用户可以用10MB对于弱网用户可以做成前端根据网速动态调整检测到下载速度低于某个阈值时自动把分片降为2MB。这个方案我测试过效果很理想但初期版本没必要一上来就做动态调整先把固定分片跑通再说。2.2 文件唯一标识秒传和断点续传的共同基石秒传和断点续传都离不开文件唯一标识否则后端无法判断“这个文件是不是之前传过”。最常用的做法是取文件的MD5值。前端读取文件内容通过Web Crypto API或者第三方库计算MD5。这里要注意一个问题对大文件一次性计算MD5内存会爆掉。比如一个2GB的文件如果直接把它读进一个ArrayBuffer再计算摘要浏览器很可能直接崩溃。正确做法是分块读取逐块累加计算摘要。使用SparkMD5这个库可以这样操作const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); let currentChunk 0; const chunkSize 2 * 1024 * 1024; // 每次读取2MB function processNextChunk() { const start currentChunk * chunkSize; const end Math.min(file.size, start chunkSize); const slice file.slice(start, end); reader.readAsArrayBuffer(slice); } reader.onload function(e) { spark.append(e.target.result); currentChunk; if (currentChunk * chunkSize file.size) { processNextChunk(); } else { const md5 spark.end(); // 用计算出的md5去后端查询/注册 } }; processNextChunk();这个逐块读取累加的过程本质上和分片上传是同一个思路都是把“一次处理一个大任务”改为“多次处理小任务”。这也算是一种设计上的一致性。2.3 秒传的判定逻辑别只比对文件名很多人会误以为秒传是拿文件名去比对这是一个很常见的误区。文件名毫无可靠性可言同一个文件可以叫任何名字不同文件也可以重名。正确的秒传逻辑分三层第一层用文件内容的MD5去查文件指纹表。这张表记录全网或当前系统里所有已存在文件的MD5、文件大小、存储路径、引用计数。第二层同时校验文件大小。MD5相同但文件大小不同几乎不可能但加上大小作为辅助维度可以过滤掉极低概率的哈希碰撞。第三层对于命中指纹的文件做秒传关联。也就是在用户和文件之间建立一条引用记录而不是真的去复制一份存储。这里我额外强调一个业务上的细节秒传命中后文件的属主可能是另一个用户。如果是公共文件直接关联没问题如果涉及权限和隐私就需要注意文件粒度的访问控制。大多数网盘产品会选择在秒传的同时给文件增加一条“我拥有这个文件”的记录在文件系统层面做去重在业务层做归属隔离。2.4 分片上传的流程设计从初始化到合并一次完整的分片上传严格来说包含五个阶段初始化、预检、分片上传、合并、清理。初始化阶段前端把文件名、文件大小、分片大小、文件MD5一并提交给后端。后端返回一个uploadId这个ID贯穿整个上传流程后续所有分片请求都要带上它。预检阶段后端根据uploadId检查该文件已经收到了哪些分片。这个阶段对断点续传至关重要。前端拿到已上传分片的列表就能知道哪些分片需要补传。分片上传阶段前端逐个上传分片。每个分片请求带上uploadId、分片序号chunkIndex、分片内容。后端收到后写入临时目录或临时对象存储并记录分片状态。合并阶段前端告知后端“所有分片都传完了”后端检查分片完整性然后按序号把分片合并成完整文件。合并完成后再做一次MD5校验确认文件的完整性。清理阶段把临时分片数据删除释放存储空间。这一步经常被忽略但如果不做临时目录会越来越大磁盘很快被占满。这五个阶段看起来多其实每个阶段都很薄。一定不要把阶段合并或者简化掉尤其是预检阶段那是断点续传的命根子。3. 实操过程与核心环节实现3.1 前端整体架构上传管理器前端这部分我用一个UploadManager来管理整个上传流程而不是把逻辑散落在各个组件里。原因很简单大文件上传涉及并发控制、状态维护、事件回调如果每个页面写一套后期维护成本会很高。下面是一个简化版的结构class UploadManager { constructor() { this.tasks new Map(); // uploadId - uploadTask this.concurrentLimit 3; // 并发分片数 } addFile(file) { const fileMd5 null; // 实际由2.2节方法先计算 // 创建上传任务 const task new UploadTask(file); this.tasks.set(task.uploadId, task); task.onProgress this.updateUI.bind(this); return task.start(); } updateUI(task) { // 计算总进度已合并文件大小 已上传分片大小 const loaded task.uploadedChunks * task.chunkSize; const total task.file.size; task.progress (loaded / total * 100).toFixed(2); } }这里的核心思想是每一个上传任务内部维护自己的状态机等待计算MD5、查询秒传、初始化上传、上传分片、合并文件、完成或失败。UI层只需要监听任务的状态变化来刷新进度条。3.2 分片与并发控制跑满带宽但不压垮服务端并发分片的数量设计需要兼顾两端前端希望尽可能多地并发请求以跑满带宽后端的Tomcat线程池、网关连接数、数据库TPS又不可能无限扩大。我在实际项目里把并发数设成了3。这个数字看起来不高但对单个文件来说3个并发分片请求已经能很充分地利用常规宽带的上行带宽。如果同时有多个文件在上传总并发量还会叠加所以把单文件并发控制得保守一点是合理的。分片上传的代码逻辑如下async function uploadChunk(task, chunkIndex) { const chunk task.file.slice( chunkIndex * task.chunkSize, Math.min(task.file.size, (chunkIndex 1) * task.chunkSize) ); const formData new FormData(); formData.append(uploadId, task.uploadId); formData.append(chunkIndex, chunkIndex); formData.append(file, chunk); const response await fetch(/api/upload/chunk, { method: POST, body: formData }); return response.json(); } async function uploadChunksInBatches(task) { const totalChunks Math.ceil(task.file.size / task.chunkSize); let uploadedIndexes await queryUploadedChunks(task.uploadId); // 断点续传依赖这里 let index 0; while (index totalChunks) { const batch []; while (batch.length CONCURRENT_LIMIT index totalChunks) { if (!uploadedIndexes.includes(index)) { batch.push(uploadChunk(task, index).catch(err { // 记录失败分片稍后重试 task.failedChunks.push(index); })); } index; } await Promise.all(batch); } }这里有两个细节值得一提。第一失败分片不要立刻退出去重传而是放在一个failedChunks数组里。等这一批并发请求全部结束后统一对失败分片做重试。这样可以避免某一片失败时其他还在飞的请求被中断。第二查询已上传分片列表这一步一定要在开跑之前做。如果你先启动了一批分片请求再去查询就会有多余的分片被重复上传虽然后端可以做幂等但白白浪费了带宽。3.3 后端接口设计初始化、分片上传、合并后端我用Spring Boot为例把三个核心接口的骨架和关键逻辑写清楚。首先是初始化接口前端提交文件名、大小、MD5后端返回uploadId和已上传分片列表PostMapping(/api/upload/init) public Result initUpload(RequestBody InitRequest request) { // 1. 根据md5查询文件指纹表命中则走秒传 FileRecord record fileMapper.findByMd5(request.getMd5()); if (record ! null) { return Result.success(InitResponse.quickUpload(record.getId())); } // 2. 未命中生成uploadId初始化上传状态 String uploadId UUID.randomUUID().toString().replace(-, ); uploadMapper.createUploadRecord(uploadId, request.getFileName(), request.getSize()); return Result.success(InitResponse.normalUpload(uploadId, Collections.emptyList())); }然后是分片上传接口。分片文件的存储路径由uploadId和分片序号共同决定这样后续合并时可以直接按序号读取PostMapping(/api/upload/chunk) public Result uploadChunk(RequestParam(uploadId) String uploadId, RequestParam(chunkIndex) int chunkIndex, RequestParam(file) MultipartFile file) { String chunkPath uploadDir / uploadId / chunkIndex .part; File targetFile new File(chunkPath); if (!targetFile.getParentFile().exists()) { targetFile.getParentFile().mkdirs(); } // 已存在说明之前传过可以跳过 if (targetFile.exists()) { return Result.success(); } file.transferTo(targetFile); // 记录分片上传状态到数据库 uploadMapper.markChunkUploaded(uploadId, chunkIndex); return Result.success(); }最后是合并接口。合并前需要做一个完整性的校验判断已上传的分片数量是否等于总应上传数量然后再按序号合并PostMapping(/api/upload/merge) public Result merge(RequestParam(uploadId) String uploadId, RequestParam(fileName) String fileName) { Integer uploadedCount uploadMapper.countChunks(uploadId); Integer totalCount uploadMapper.getTotalChunks(uploadId); if (!uploadedCount.equals(totalCount)) { return Result.error(分片不完整已上传 uploadedCount / totalCount); } File mergedFile new File(uploadDir, fileName); OutputStream out new FileOutputStream(mergedFile); for (int i 0; i totalCount; i) { File part new File(uploadDir / uploadId / i .part); Files.copy(part.toPath(), out); } out.close(); // 合并后做MD5校验防止分片文件损坏 String mergedMd5 DigestUtils.md5DigestAsHex(new FileInputStream(mergedFile)); if (!mergedMd5.equals(initMd5)) { return Result.error(文件校验失败); } // 删除临时分片目录 deleteDirectory(uploadDir / uploadId); return Result.success(); }合并过程用的是简单的流复制。分片数量几百个以内时这种方式完全够用没必要引入复杂的工具。真正需要关注的是合并后的MD5校验这是保障文件完整性的最后一道防线。3.4 断点续传的完整状态落地方案要支持真正的断点续传光靠后端存储分片文件还不够。用户刷新页面后前端的内存状态全部丢失必须有一个地方能记录“这个文件传到一半了”。我用的方案是localStorage加后端状态双重记录。前端在初始化上传时把文件的md5、uploadId、分片信息写入localStorage。用户重新选择同一个文件时前端先计算md5然后去localStorage里找有没有未完成的任务记录。如果找到直接用旧的uploadId发起预检从后端拉取已上传分片列表跳过已传分片继续上传。这里有一个关键判断点同一个文件的md5只能作为索引线索不能当作uploadId。原因是可能有多个用户上传同一个文件或者同一个用户并发选择了一个文件两次。每次都新建uploadId避免任务之间互相污染。后端也需要定期清理那些长时间未完成、且没有任何分片新上传的upload任务。不然数据库的记录会越积越多。我是用一个定时任务扫描超过24小时没有新分片活动的uploadId将其临时目录删除、数据库记录标记为过期再定期清理过期数据。4. 常见问题与排查技巧实录4.1 分片乱序和重复提交在实际操作中分片上传接口被重复调用是常态。前端并发发起3个请求其中某一个请求因为超时被前端判断为失败但实际上后端可能已经写入了分片。当前端重试时同一个分片就会被再次提交。这个问题有两个解法都要做。第一个是后端的幂等处理。我在分片上传接口里先检查目标分片文件是否已存在存在就直接返回成功不做重复写入。这个判断虽然简单但能挡住绝大多数重复提交。第二个是唯一索引约束。在数据库里给uploadId和chunkIndex加联合唯一索引即使并发请求同时到达数据库层面也能保证只有一条记录成功。我遇到过文件系统判断和写入之间出现并发间隙的情况靠数据库约束兜底才稳下来。4.2 秒传误判与文件损坏秒传依赖MD5而MD5理论上存在碰撞可能。虽然概率极低但如果是对付费资料、合同文件这种不容有失的场景我建议在秒传接口命中后增加一个随机抽样校验的步骤后端打开已存在的文件读取前1KB、中间1KB、后1KB和前端上传时记录的对应片段做比对。比对通过才允许秒传不通过就走正常上传流程。这个抽样校验的成本极低但能把误判概率压到几乎为零。我并不是担心MD5碰撞更多是为了防止之前上传成功但因为各种原因存储里的文件被部分写坏的情况。4.3 进度条为什么会回跳前端进度条回跳是最容易被用户察觉的体验问题。它通常发生在这种场景分片A和分片B并发上传分片A先完成并触发进度更新此时进度条涨到了10%随后分片B因网络问题失败重试后再次上传进度条又跌回8%。处理办法是进度计算时只统计“后端确认已持久化”的分片数量。失败重试中的分片不计入已完成数量。这样进度条只会上涨或者停滞绝不会回跳。另一个细节是合并阶段进度条要保持在95%或99%而不是直接跳回0%。很多人在合并阶段忘记维护前端状态导致后端合并时前端进度条清零用户以为上传失败了。合并本身花费的时间可能不短对GB级别的文件合并操作也讲究效率。可以改用按序读流合并加缓冲写入的方式或者并发合并但这里不展开我一般让前端在分片全部上传完毕后就把进度条锁定在99%等合并完成后再跳到100%。4.4 服务端临时存储空间暴涨分片全部存起来之后在合并完成前这些分片占据的磁盘空间总和相当于整个文件的大小。如果同时有大量用户在传大文件服务端磁盘很快会被撑满。我的经验是要设置双上限单个upload任务的分片总大小不能超过业务允许的最大文件体积比如10GB所有未完成upload任务在磁盘上的占用总量设一个阈值比如总磁盘的30%超过后拒绝新的上传任务并提示用户稍后再试。另外一定要记得清理机制兜底。服务重启、用户关闭浏览器导致上传中断等场景后端无感知只能靠定时清理。我的清理频率是每小时一次扫描超过2小时没有新分片写入的任务直接清理。4.5 前后端时间与网络波动导致的长尾请求最后一个常见问题是长尾请求。即使你设置3个并发分片理论上总耗时应该接近总大小除以带宽但实际上总会有某一个分片卡在TCP重传、网络切换这些坑里导致整个批次等待它完成。我加了一个每片超时控制。前端fetch请求设置超时时间默认60秒。超过60秒未返回的分片直接判失败放入失败队列重试。重试次数我设了3次再失败就暂停整个任务提示用户检查网络后点击继续。这个策略比无限重试要好得多无限重试在弱网环境下会一直消耗设备电量和网络资源。给分片请求设置超时还有一个附带的好处用户体验上失败反馈更快而不是傻等一两分钟。5. 进阶优化与扩展实践5.1 文件合并的效率优化上面提到的合并方案在分片数量很大的时候会存在瓶颈。比如说1GB文件、5MB分片需要合并200个分片逐个打开和关闭文件流会有很多不必要开销。改成BufferedOutputStream并加大缓冲区后性能会有明显提升。对于分片数过千的场景可以考虑先把小分片合并成若干中分片再对中分片做二次合并减少文件句柄的频繁开关。我还有一个实践是合并时实时计算整体文件的MD5而不是合并完后再重新读一遍。这样少一次全文件IO对GB级别文件的体验差异巨大。5.2 后端存储选型本地磁盘还是对象存储如果项目部署在单机或自建机房用本地磁盘存放临时分片没问题。合并后的文件可以再转移到另一个存储区域或者直接交由后续业务使用。如果项目在云上我更建议把分片直接放到对象存储里比如OSS、COS这一类的产品。对象存储原生支持分片上传服务端只需作为调度中心把前端的分片请求转换成对对象存储的Part上传请求最后由服务端触发合并。这种架构的好处是临时分片不占应用服务器磁盘合并由对象存储内部完成不占应用服务器带宽和CPU天然支持跨地域容灾。当然引入对象存储之后上传接口的超时逻辑、签名机制、权限模型都会变复杂需要团队对基础设施的掌控力更强。根据团队规模选型不建议为了用对象存储而强行改架构。5.3 任务恢复与多端同步如果产品需要支持“在A设备上传了一半换B设备继续传”localStorage的方案就不够了。需要把上传任务状态同步到服务端并且让用户能够在另一个设备上通过文件列表找回未完成任务。我在项目里是这么做的用户登录状态下初始化上传时把uploadId、文件md5、文件名、大小、总分片数、已上传分片列表持久化到服务端。换设备后用户选择同一文件前端计算md5后先调服务端的任务恢复接口找到未完成任务并拿到已上传分片列表然后直接继续上传。这里有个细节值得注意弹窗提示用户恢复之前的上传进度是把“选择文件”这个动作从重新开始变成了接续执行。这个交互设计对用户体验的提升非常明显用户会直观地感受到断点续传的价值。5.4 上传限速与流量控制某些场景下上传模块不能占满用户所有上行带宽。比如用户在办公网络中同时需要视频会议如果上传任务占满了带宽会议体验会很差。我实现过一版基于令牌桶的限速逻辑。上传管理器维护一个令牌桶每个分片上传前先获取令牌令牌按照设定的速率生成。分片大小除以令牌生成速率就大致等于期望的上传速率。限制上传速度的代码大概长这样class TokenBucket { constructor(ratePerSecond, capacity) { this.ratePerSecond ratePerSecond; this.capacity capacity; this.tokens capacity; this.lastFilled Date.now(); } take() { this.refill(); if (this.tokens 1) { this.tokens - 1; return true; } return false; } refill() { const now Date.now(); const elapsed (now - this.lastFilled) / 1000; this.tokens Math.min(this.capacity, this.tokens elapsed * this.ratePerSecond); this.lastFilled now; } }上传每个分片前如果拿不到令牌就等待一段时间再试。把ratePerSecond设置为分片大小除以期望速率就能把实际上传速率控制在期望值附近。实测下来流量曲线平稳适合对网络占用有要求的办公场景。5.5 上传完后的处理链上传成功不等于业务完成。文件合并落盘后通常还需要接一堆后续动作病毒扫描、文件格式识别、缩略图生成、内容审核、归档转储等。我建议把合并完成后的动作都放到消息队列里异步处理不要让前端请求卡在合并接口里等待全部完成。这样接口响应更快而且一旦后续处理失败可以单独重试不会影响已上传成功的文件记录。我最初实现时是在合并接口里同步生成缩略图遇到超大视频文件时合并接口要跑几十秒才返回前端差点被用户骂死。改成异步之后整个世界清净了。收个尾这套方案的坑与体会做了几年大文件上传我的最大体会是技术上真没什么高深莫测的东西分片、MD5、合并、去重每个点单独拿出来都不难。难点在于把这些环节串成一个完整闭环并且把每一个边界情况都考虑进去。有几个点我要单独拎出来再强调一遍。MD5计算必须分块读避免大文件内存溢出秒传必须按内容标识而不是文件名分片接口后端必须幂等合并后必须校验MD5服务端必须有清理机制前端必须管理好并发和超时进度条必须基于已确认的分片数。这些点里面随便漏掉一个方案都会在某个场景下出问题。另外我建议做这套功能时先从固定分片大小、固定并发数、单机的磁盘存储开始跑通全链路后再考虑对象存储、动态分片、多端同步这些增强能力。一上来就把架构铺得很大往往会在调试时被各种外部因素干扰很难判断问题到底出在前端还是后端还是网络。先小步快跑把核心链路做实再逐步叠加能力这才是稳妥的落地路线。如果你也正准备做或者正在做大文件上传希望这篇文章能帮你少踩几个坑。尤其是断点续传那一节做好状态持久化用户的体验会有质的飞越。