ARTICLE DETAIL

资讯详情

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

大文件上传最佳实践:分片上传、断点续传与异步合并方案

大文件上传最佳实践:分片上传、断点续传与异步合并方案 做视频平台、网盘、文档协作这类产品大文件上传是绕不过去的命题。两三年前我还在用最原始的方式处理一个POST把整个文件丢给后端服务器在nginx后面等着用户上传1个GB的文件进度条爬到90%再掉头那种场面经历过一次就不想碰第二次。后来我把上传链路改成“大文件分片上传 断点续传 异步合并 异步通知”的组合拳上线后基本没有再因为上传问题被业务方找过。这篇文章把方案的设计思路、接口定义、关键代码、状态机和问题排查全部摊开希望能帮正被大文件传输折磨的同学少走弯路。整套方案解决的事情一句话大文件不再是一次性的长连接传输而是拆成若干独立分片并发上传服务端记录已到达的分片断网后只补未上传的部分分片齐全后服务端后台异步合并合并完成再通过带验签的异步通知把结果推给业务方触发转码、入库、发通知等后续动作。这套链路对网盘同步、视频教学资源上传、报表导入、数据集投递这类场景都非常合适前端、后端、运维同学都可以参考。1. 为什么大文件必须换一种上传姿势1.1 完整上传的失败成本有多高很多人第一反应是大文件直接传不就行了HTTP本身支持大bodynginx里把client_max_body_size调大服务端用流式接收文件再大也能接。理论上没问题但真实网络环境不是理论模型。CDN、网关、代理服务器都有自己的超时时间默认60秒到300秒不等。一个500MB的文件在10Mbps的上传带宽里需要400秒这还没算网络丢包重传的开销。也就是说只要单个请求耗时超过网关超时阈值连接就被强制断开但服务端可能已经收到了大部分内容却拿不到完整文件只能把残骸清掉。更气人的是客户端毫不知情进度条卡死在99%用户刷新页面重新上传几十分钟的流量就全浪费了。大文件上传真正的坑在于“失败成本”极高。完整上传的方式下一次失败的代价等于“重新传输整个文件”这个代价随着文件体积线性增长。100MB的文件失败一次重传100MB2GB的文件失败一次重传2GB。弱网环境下丢包率稍微升高完整上传几乎等于不可用。我们还遇到过一种更隐蔽的场景手机网络在电梯、地下车库切换基站时TCP连接直接断开用户以为传完了其实服务端那边只接到了几百MB的碎片。这个问题用完整上传是永远无解的必须从传输协议层面换思路。1.2 断点续传和完整上传的本质区别所以就有了分片上传。严格来说“断点续传”和“完整上传”不是同一维度的策略而是传输单元层面的根本差异。完整上传把整个文件当作一个传输单元服务端要么收到全部要么收到一部分但无法使用分片上传把文件按固定大小切成N个分片每个分片是一个独立传输单元服务端每收到一个分片就把它记录在案并落盘分片之间互不影响。既然分片是独立传输单元断点续传就成了自然而然的副产品网络中断后客户端重新发起上传时不需要重新传那些已经落盘的分片只需要请求一次“哪些分片已到齐”的列表把剩下的分片传完即可。这个动作看起来很像“续传”但它本质上是“精确跳过已成功传输的数据”。区别体现在四个层面失败重放成本从“整个文件”降为“单个分片”低网速环境下体验天差地别。传输可校验性每个分片可带独立MD5或SHA-256服务端逐个校验完整上传一般只能整体校验。并发度可调N个分片可并行传输整体吞吐率比单连接高得多。文件级秒传变得容易实现服务端如果发现已有同指纹文件直接返回“无需上传”。断点续传还有一层容易被忽略的优势它天然具备“进度持久化”能力。完整上传的唯一进度信息在客户端内存里页面一关什么都没了分片上传的进度存在服务端数据库或Redis里换一台设备、换一个浏览器只要能计算同一个文件指纹照样能接着传。这一点在移动端和PC端交替上传的场景里实测下来的体验提升非常明显。1.3 异步合并与异步通知解决了什么分片传完了如果服务端在HTTP请求里同步做合并会再次踩进超时的坑。合并一个2GB的文件需要把几十上百个分片按顺序写入最终文件再计算全文件哈希做完整性校验这个过程少则几秒多则几十秒生产环境的负载均衡器很少允许一个连接挂这么久。把合并放到HTTP请求之外异步执行接口只需要快速确认“分片已齐、合并任务已入队”然后返回202前端收到后就可以结束上传流程。这就是异步合并。异步通知解决的问题则是“业务方如何感知结果”。文件合并完成后后续往往还挂着一串连锁动作视频转码、图片生成缩略图、附件入库、发消息通知用户。总不能让客户端一直轮询或让前端页面去拉一个几分钟才返回的结果更合理的做法是服务端合并完成后再主动推一个回调解耦。但这会引出安全性的问题一个公开的回调地址如果不做校验任何人都能伪造“上传成功”的通知来触发计费、转码、发短信这类操作。因此异步通知的验签设计不是锦上添花而是必须项。把同步HTTP链路拆成三段前端传分片、服务端异步合并、回调通知业务每一段都不长连接、不阻塞、不靠猜整套方案的可用性和可维护性才会真正落地。2. 方案选型与关键参数设计2.1 分片大小和并发数怎么定分片大小是一个典型的权衡问题。理论上分片越小单分片失败后重传成本越低但分片数量越多HTTP请求次数、数据库记录条数、临时文件数量都成倍增长。一个4GB文件如果按512KB切会切出8192个分片光占用的临时文件条目就能把服务器拖得很吃力如果按64MB切分片数量只有64个但任何一个分片失败就要重传64MB且超大分片在网络传输中更容易触发代理缓存限制。我的经验值是普通办公网络用4MB到8MB弱移动网络用1MB到2MB内网上传或专线场景可以用16MB到32MB。别全公司统一一个值分片大小应该能通过配置中心下发不同场景选不同档位。并发数控制也不是越大越好。我见过有人一次开20个分片并发结果把家用路由器上传队列挤爆所有分片集体超时进度反而比串行还慢。经过反复实测3到5个并发是比较稳妥的范围。为什么不是1个并发因为TCP单连接在拥塞窗口没起来之前吞吐率很低5个并发能把上行带宽吃满为什么不是20个因为服务端和本地都有句柄开销、磁盘寻址开销而且并发拉高时丢包反而放大整体重传消耗。这个参数也建议可配置并允许客户端根据最近一次上传的成功率自动降级弱网用户自动切到2并发甚至1并发。2.2 核心数据表与状态机设计先看数据模型。项目里我用了两张表第一张是上传任务表记录一次上传任务的元信息第二张是分片明细表记录每个分片的落盘情况。上传任务表核心字段是字段说明upload_id任务ID全局唯一建议UUID或雪花ID不要自增file_name / file_size文件元信息file_hash文件内容哈希用于秒传和最终校验chunk_size / chunk_count分片大小与总分片数storage_path最终文件存储路径notify_url合并完成后要回调的地址status任务状态create_time / update_time时间戳分片明细表核心字段是upload_id、chunk_index、chunk_hash、chunk_path、is_uploaded、upload_time。这张表必须建联合唯一索引(upload_id, chunk_index)这个索引是整个方案幂等的关键。没有它重复上传的分片会变成两条记录后面合并和统计都会乱套。状态机可以这样走INIT - PARTIAL_UPLOADED - READY - MERGING - SUCCESS/FAILED - NOTIFIED/NOTIFY_FAILED。初始化接口创建任务时写入INIT每接收到一个分片就把对应chunk标记为已上传同时任务流转为PARTIAL_UPLOADEDcomplete接口触发时检查所有分片就位后把任务置为READY并投递到合并队列合并线程处理时置为MERGING合并成功后置为SUCCESS并触发通知通知回调成功后置为NOTIFIED若失败则按策略重试并保留NOTIFY_FAILED标记。这个状态机虽然简单但好处是任何一步挂了都能从数据库里一眼看出问题不会出现“前端以为传完了、后端其实没合并”的悬案。2.3 接口规划与交互时序接口按职责拆成四个initUpload前端计算文件指纹后调用服务端判断是否秒传如果秒传直接返回existstrue前端整个上传流程可以立刻结束否则创建uploadId返回已存在的分片列表供断点续传使用。uploadChunk上传单个分片form-data里带uploadId、chunkIndex、chunk文件可带chunkHash服务端落盘后做校验。completeUpload前端所有分片发送完毕后调用服务端检查分片齐全后投递异步合并任务。notifyUrl回调接口由调用方传入合并完成后服务端向这个地址发起带签名的异步通知。交互时序大致是前端计算文件哈希 - 调initUpload- 秒传则结束 - 否则并发上传缺失分片 - 全部完成后调completeUpload- 服务端异步合并 - 合并完成后回调notifyUrl- 业务方验签后返回成功。前端如果想展示最终状态可以在completeUpload之后再轮询几次状态或者等服务端通过WebSocket推送完成事件。整体链路每一步都不依赖长连接这是它能扛住大流量和弱网的核心原因。3. 实操实现前端分片与后端合并3.1 前端文件指纹与秒传判断秒传判断不能只拿文件名做必须基于文件内容计算指纹。浏览器端一般用FileReader加上crypto.subtle.digest计算SHA-256也可以引入hash-wasm这类WASM库来加速。注意几百MB的文件在浏览器主线程上算哈希会卡页面建议扔到Web Worker里做。计算完文件指纹后把fileName、fileSize、fileHash和期望的chunkSize传给initUpload后端先查是否存在同hash的任务或同hash的已合并文件存在就直接返回existstrue。前端分片本身很简单File的slice方法天然支持const chunkSize 8 * 1024 * 1024; // 8MB const chunkCount Math.ceil(file.size / chunkSize); for (let i 0; i chunkCount; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); const blob file.slice(start, end); const formData new FormData(); formData.append(uploadId, uploadId); formData.append(chunkIndex, i); formData.append(chunk, blob, chunk_${i}.part); // 交给并发队列上传 uploadQueue.push(formData); }这里有一个小坑分片的Blob是懒读取的如果你在循环里把所有分片一次性存进数组内存会被瞬间占满更好的做法是边切边传每个分片构造完FormData立即交给上传队列保持内存占用稳定。我一开始就是没注意这个一个4GB文件把浏览器内存顶到快3GB页面直接崩掉。3.2 并发分片上传与进度管理前端并发调度我建议直接用任务队列控制最大并发数自实现也很简单不必引入太重的前端框架。核心逻辑是从待上传分片列表里取分片如果该分片已经被initUpload返回的已上传列表包含就跳过上传成功后标记完成全部完成后调用completeUpload。伪代码大致长这样const concurrency 3; let index 0; async function worker() { while (index chunks.length) { const current index; if (uploadedChunkSet.has(current)) { onProgress(); continue; } await uploadOne(chunks[current]); onProgress(); } } for (let i 0; i concurrency; i) { worker(); }进度管理分两层已上传分片计数除以总分片数以及当前正在传输的字节数。前者用于显示大进度条后者用于实时速度展示。不要用“已发送字节数/文件总大小”直接算因为合并前的临时文件都算“已发送”但文件还没真正可用按分片数来算更符合用户预期。另外前端要监听beforeunload事件在页面关闭时把当前任务的状态存到sessionStorage或IndexedDB下次进页面先取本地缓存的uploadId直接调completeUpload续传而不是重新initUpload这样断点续传的体验才完整。3.3 服务端异步合并实现服务端我用Node.js加express临时分片文件放在upload_tmp/{uploadId}目录下最终文件放upload_data/目录。uploadChunk接口把分片写入临时文件按索引命名例如chunk_5.part。这里有几个细节分片写入必须校验chunkHash不匹配直接返回失败否则合并出的最终文件是脏的。写入方式建议用fs.createWriteStream一次写入该分片分片本身已经是独立完整的数据块不需要用append拼碎片。写入完成后更新分片状态用唯一索引保证同一个分片重复上传不会产生两条记录。合并的核心逻辑是把所有分片按chunkIndex升序写入最终文件同时累加计算整体文件哈希。每合并完一个分片就删除一个临时分片文件这样即使合并到一半进程崩溃临时空间也能释放一部分。不过严谨起见合并中途崩溃我建议直接把整个任务重置为READY后重来一次因为删除分片和写入最终文件不是原子的半途续合容易把最终文件搞脏重新合并的代价只是时间但正确性最重要。核心代码// Node.js 服务端异步合并 async function mergeUpload(uploadTask) { const { uploadId, chunkCount, storagePath } uploadTask; const tmpDir path.join(globalConfig.tmpRoot, uploadId); const output fs.createWriteStream(storagePath); const hash crypto.createHash(sha256); for (let i 0; i chunkCount; i) { const chunkFile path.join(tmpDir, chunk_${i}.part); if (!fs.existsSync(chunkFile)) { throw new Error(chunk ${i} missing); } const data await fs.promises.readFile(chunkFile); hash.update(data); output.write(data); await fs.promises.unlink(chunkFile); // 合并一个删一个 } await new Promise((resolve, reject) { output.end(resolve); output.on(error, reject); }); return hash.digest(hex); }合并前还有一步不能省查磁盘剩余空间。用statfs或者定时统计分区可用空间如果剩余空间比文件总大小还小直接返回错误并提前告警避免合并到一半磁盘写满最终文件和临时文件一起报废。磁盘写满这种问题线上事故里最容易发生提前预检可以省掉很多麻烦。3.4 异步通知验签设计合并完成后进入通知阶段。通知内容至少包含fileId、fileName、fileSize、status、notifyTime。为了防止伪造、防篡改、防重放通知必须带签名。常见做法是通知方对参数按key字典序排序拼接成keyvaluekey2value2的查询串末尾拼上约定密钥用HMAC-SHA256生成签名放进sign字段一并发出去接收方用同样的方式重算签名并比对不相等直接丢弃。签名计算function generateSign(params, secret) { const keys Object.keys(params).sort(); const raw keys.map(k ${k}${params[k]}).join() key${secret}; return crypto.createHmac(sha256, secret).update(raw).digest(hex); } // 通知时带上 timestamp nonce sign const notifyData { fileId, fileName, fileSize, status, timestamp: Date.now(), nonce: crypto.randomUUID(), }; notifyData.sign generateSign(notifyData, secret);timestamp用于约束通知时效性超过5分钟的通知直接拒绝防止有人把旧通知重放一遍nonce用于解决同一通知多次发送的问题接收方可以用Redis记录已处理过的nonce重复通知直接幂等返回成功。有一个容易被忽视的点接收方验签通过后一定要返回一个表示成功的响应体比如{code:0}否则通知方会一直认为没送达并持续重试。重试策略我采用指数退避第一次失败后30秒重试然后2分钟、10分钟、30分钟最多重试8次仍失败则标记NOTIFY_FAILED并进入人工告警队列。4. 常见问题与排查技巧实录4.1 分片重复与乱序的兜底策略分片重复上传是非常常见的情况原因包括前端并发重试、用户重复点击上传按钮、网络抖动导致的超时重发。如果后端不做幂等重复上传会污染临时文件甚至让分片记录数大于chunkCount导致completeUpload时的判断出错。我的做法是分片明细表对(upload_id, chunk_index)建唯一索引写入分片文件前先检查该分片是否已存在存在则直接返回成功文件写入也保持覆盖重写分片内容以最后一次成功到达并校验通过的为准。这样无论重试多少次最终态都是确定的。分片乱序倒是不会造成问题因为合并阶段会按chunkIndex升序读文件前端上传顺序无关紧要。唯一要注意的是服务端不能依赖“先传0再传1”的顺序去做任何前置判断否则一批并发上传就会把服务端搞挂。我见过有同事在uploadChunk里做“前置分片必须已上传”的业务校验结果前端并发一上来就大量报错。记住分片之间必须完全独立。4.2 合并任务丢失与幂等保证异步队列合并任务理论上不丢但生产环境里确实会遇到一次合并进程在处理中途被运维重启内存里的任务队列全没了有几张上传任务卡在MERGING状态前端不停轮询也没结果。从那以后我加了两个保障第一合并任务投递前先把任务状态持久化为READY任何时刻从数据库重启都能恢复未完成任务第二服务启动时扫描状态为READY或MERGING的任务重新投递合并队列。合并动作本身要求幂等如果最终文件已经存在且哈希校验通过直接跳过合并并把任务转为SUCCESS。合并的幂等还要考虑重入问题如果两个合并线程同时处理同一个任务会出现重复写文件。解决办法是在任务表上加一个merging_lock字段或利用数据库行锁/Redis分布式锁只有拿到锁的节点才能执行合并。锁的过期时间要大于单文件合并的最大耗时否则锁提前释放第二个线程又进来两个线程同时写同一个文件文件就花了。锁过期时间我给的是合并预估耗时的两倍并加一个绝对上限。4.3 磁盘空间与性能监控临时目录占用磁盘是分片方案的一大特点。所有分片都在服务端落盘只有合并完成后才释放所以一个2GB的文件在传输高峰期可能同时占用2GB临时空间。这块必须监控起来至少做到上传任务初始化时记录预估占用量。定时任务清理超过72小时没有任何分片更新且状态不是上传中的临时文件。磁盘使用率超过80%时对新上传请求直接返回“服务繁忙请稍后再传”而不是等到磁盘满了才被动降级。高并发场景建议上传临时目录和最终文件目录放在不同磁盘。临时目录和最终文件目录分开这件事实际收益特别明显。合并时读临时盘、写最终盘能同时利用两块磁盘的吞吐能力避免同一块盘读写互相抢IO。我们上线初期因为临时文件和最终文件在同一块SSD上1GB文件的合并耗时一直在20秒左右后来拆到两块盘直接降到11秒上下吞吐提升接近一半。4.4 通知丢失与重复通知的平衡异步通知“丢了重发”和“重复通知”看着矛盾其实是同一个问题的两面发送方不确定接收方是否收到所以要重试重试必然带来重复。解决方案前面提到过接收方做幂等。发送方这边则要控制好重试节奏不能疯狂重发也不能重试到一半就放弃。我的策略是通知发送结果无论成功失败都要落库成功记录notify_time失败记录next_retry_time由定时任务扫描到期重试重试上限设置8次超过后转人工。这种方案比单纯在异步通知方法内部while循环重试要稳得多因为进程重启后重试状态仍在数据库里。另外一个特别重要的点通知的接收方不能要求“所有业务都在通知回调里同步处理完才返回成功”。如果业务方在回调里做了转码、写库、发通知一整套操作一个耗时的下游就能把通知方卡死回调接口应该只做验签、落库、投递到自己的业务队列然后立刻返回成功后续业务再异步处理。这一点我在好几个合作方对接时反复强调否则两边都在等对方链路延迟高得离谱。5. 实测数据与调优心得5.1 压测数据和我调整过的参数在我们内部测试环境2核4G的应用服务器加2核4G的文件服务器模拟用户20Mbps上行带宽用1GB测试文件做了多轮对比分片大小定在8MB并发数取3整体上传耗时比完整上传节省了40%左右。断点续传场景我在上传到37%时主动断开客户端网络重连后上传从断点继续整个重传的流量只有不到20MB主要来自最后一个未完成分片和连接开销换做完整上传至少要重传800MB。服务端合并1GB文件8MB分片共128片拆分后合并耗时大约11秒如果合并走同步HTTP在网关60秒超时的情况下还能勉强通过文件再大一点就必然超时所以异步合并不是选项而是必需。参数上我踩过的一个具体教训是分片大小从16MB降到8MB之后弱网场景成功率提升了近10个百分点。16MB分片在20%丢包的网络里单分片重传成本很高而且分片越大TCP重传对整条连接的影响越明显8MB是当时平衡出的比较舒服的值。网络太差的用户还可以通过配置切成2MB分组上传交给前端动态适配。这里我强烈建议做一套简单的探底机制上传前先测两个小分片到服务端的往返时延和丢包率根据结果动态决定分片大小和并发数比固定参数自适应得多。5.2 我踩过的三个真实的坑第一个坑是前端计算文件哈希太慢导致上传界面“卡死”。一开始直接在主线程算1GB文件的SHA-256结果页面点了上传按钮后五六秒没有任何反应用户以为没点中又点了一次。后来把哈希计算放到Web Worker里并在界面提示“正在分析文件”体验才正常。建议所有超过100MB的文件都强制走Worker别省这一步。第二个坑是合并时临时文件路径和最终文件路径写错导致两个不同任务的最终文件内容互串。排查后发现是拼接路径时用了相对路径不同请求的工作目录上下文有偏差改掉所有文件路径为基于目录常量的绝对路径之后问题就消失了。这个看起来很低级的错误真实环境下会因为Node进程的工作目录起始位置不同而隐藏得很深排查起来非常费劲。第三个坑是通知回调里对同一条通知“先验签后记nonce”和“先记nonce后验签”的实现顺序搞反过。当时为了减少重复请求先把nonce写进Redis再验签结果伪造请求可以拿一个随机nonce占坑导致正常通知被幂等拦截。正确的顺序是验签通过后再写nonce缓存顺序一错整个通知链路就是废的。最后再补一个心得做完这套方案我最大的体会不是某个单个环节多厉害而是“把链路拆短再拼长”。上传链路拆成多个短请求虽然请求总数变多了但每个环节都简单、可控、可重试整体可靠性反而比一个长链路好得多。你在自己的项目里落地的时候也尽量别依赖太多“黑科技”每一步都用最朴素的校验、落库、状态流转做好系统的抗风险能力自然就上来了。
返回列表