ARTICLE DETAIL

资讯详情

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

大文件上传分片与断点续传实现方案全解析

大文件上传分片与断点续传实现方案全解析 1. 从“传不动了”说起大文件上传为什么必须分片我入行那几年被大文件上传坑得最狠的一次是给客户做一个视频素材管理平台。单个素材动辄2到3个GB最开始用的方案特别朴素前端拿到File对象一个POST请求直接往服务器怼。测了没几天问题全冒出来了——先是Nginx的client_max_body_size默认只有1MB配完以后本地能传外网一传就断接着是传了一半用户切了个Wi-Fi整个请求报废又得从头传最离谱的是服务器那边Tomcat接完请求直接内存溢出因为我图省事把文件先读进了byte数组。后来我才意识到这些问题的根源都是一样的一个2GB的文件被当成一个不可拆分的整体去传输任何一环抖动都会导致整体失败。大文件上传必须分片这不仅是工程习惯而是业务要求。1.1 完整上传方案的四个致命问题先说内存。一个2GB的文件如果服务端用MultipartFile去接底层其实已经把整个请求体塞进了内存或者临时文件。并发一上来线程一多JVM的堆瞬间就见了底。这不是调大Xmx能解决的因为文件大小是用户决定的你没法限制每个用户都传小文件。再说超时。外网环境不可能一直稳定一个请求从发起到结束可能持续十几分钟。无论是网关超时、负载均衡器的空闲超时还是移动端切后台导致连接被杀任何一个环节断掉整个传输就前功尽弃。HTTP这个协议本身是为“一问一答”设计的天生不适合承载超长连接的大文件直传。接着是体验问题。没有分片之前用户根本看不到上传进度或者说看到的进度是假的——浏览器XHR的upload.onprogress确实能报但那只是“当前请求”的进度文件一断进度归零用户的心态也跟着归零。最后是并发利用率问题。单请求直传时网络带宽只有这一个连接在用TCP的拥塞控制还会让传输速率慢慢爬升一旦抖动又降回来。把一个文件切成多个分片并发上传等于同时占用了多条TCP连接带宽利用率能明显提升。这也是为什么分片上传不仅仅是“为了断点续传”才存在的它本身就是一种性能优化手段。这些问题的共性在于你试图用一个原子操作完成一个本质上需要“可分步、可重试、可恢复”的长任务。分片上传的意义就是把长任务拆成一个个短事务每个短事务都能独立成功、独立重试。1.2 分片上传与断点续传、异步合并的关系这里要先把几个概念掰扯清楚很多人把它们混为一谈。分片上传是基础能力客户端把文件切成N个chunk逐个上传服务端先把chunk都存下来。断点续传是建立在分片之上的能力每次上传之前客户端先问服务端“这个文件我传过哪些chunk了”然后把没传的chunk补传完。它的本质是“跳过已完成的步骤”避免从头再来。异步合并是收尾能力所有chunk都传完后服务端不急着立刻拼文件而是把合并任务丢给一个后台队列等进程空闲了再按顺序把chunk拼接成完整文件。异步通知是交付能力合并完成后系统主动告诉业务方“文件已经可用了”。这四段连起来就是一个完整的大文件上传闭环。顺带提一句“秒传”。秒传和断点续传长得像但逻辑完全不同。秒传是上传前先算整个文件的MD5服务端发现自己库里已经有一模一样的文件直接返回“传完了”不传任何数据。断点续传则是在“这个文件真的没传完”的前提下跳过已传的分片。把这两个机制混着用容易把接口设计搞出四不像。1.3 先想清楚边界什么算“大文件”很多人一上来就定方案忽略了“大”的定义。我的经验是不同量级的文件方案复杂度完全不一样。小于10MB直接单请求上传不值得引入分片。10MB到100MB可以分片但不需要特别复杂的断点续传失败重试整个文件也才几秒。100MB到2GB必须分片必须断点续传建议异步合并。超过2GB除了分片和断点续传还要额外考虑服务端临时磁盘规划、对象存储的分段上传能力、以及客户端内存限制。如果你做的平台目标用户是上传手机拍摄的高清视频那几乎都在100MB以上这篇文章说到的每个环节都是刚需。2. 分片设计片大小、文件指纹与接口约定分片上传的接口设计直接决定后面断点续传和合并好不好做。我的建议是严格按照“三步走”来设计接口分别是初始化上传、上传分片、完成上传。别偷懒合并成两个接口后面每次加需求你都会后悔。2.1 初始化上传确定文件的唯一身份客户端在传第一个分片之前先调用一个初始化接口把文件的基本信息告诉服务端文件名、文件大小、分片总数、整个文件内容的MD5或SHA-1。服务端拿到这些信息以后要做三件事。第一判断这个文件是不是已经完整上传过。如果库里有一模一样MD5的文件直接返回“秒传成功”状态客户端一行数据都不用传。这就是秒传的入口。第二如果文件之前传过一半就把这个文件已经收到的分片编号列表返回给客户端。这是断点续传的关键依据。第三如果没有任何记录就新建一条上传记录分配一个全局唯一的uploadId然后把这个uploadId连同“已传分片列表”一起返回。此时已传分片列表是空的。我见过有人直接用文件名加MD5当上传标识看起来没问题但遇到同名文件、文件被修改过、两个用户同时传同一个文件的时候记录就串了。用服务端生成的uploadId配合文件MD5既能精确标识一次上传任务又能通过MD5判断文件是否真的独一无二这两者各司其职谁也不能替代谁。还有一点需要提醒整个文件MD5的计算不是免费的。一个5GB的电影算一次MD5在低端手机上可能要十几秒。所以初始化接口可以设计成“如果客户端传来一个合法的MD5就用它如果没传服务端就不做秒传判断直接走分片上传”。秒传是锦上添花分片上传才是保底能力。2.2 分片大小的选型逻辑分片大小没有标准答案但有一个经验区间2MB到20MB之间。选的依据是三个因素的平衡。第一个因素是网络抖动恢复成本。分片越小单个分片失败重传的成本越低但分片数量越多请求数也越多。一个1GB的文件切成1MB一片就是1024个请求切成10MB一片就是103个请求。1024个请求意味着1024次握手、1024次鉴权、1024次额外的网络往返HTTP开销占比会明显变大。第二个因素是服务端接收临时文件的开销。分片太大比如50MB一片内存和临时磁盘的压力又回到了“小号完整上传”的尴尬。第三个因素是客户端/浏览器稳定性。在移动端弱网环境我倾向于用2MB到4MB的分片并限制并发为2到3在PC端内网可以用10MB到20MB分片并放宽并发到5到8。我自己常用的默认值是5MB。原因很简单5MB对大多数业务场景来说单个分片即使重传代价也可控一个10GB的文件也就2000个分片对MySQL来说完全不是压力。如果你用对象存储的分段上传能力做合并还要看对象存储对单段大小的限制比如S3要求除了最后一段以外每段至少5MB那5MB就是契合的选择。2.3 上传分片与完成上传的接口细节上传分片的接口参数大致是这些uploadId、chunkIndex第几片、chunkSize、以及分片文件的二进制内容。这里有个小细节很多人忽略每个分片最好也附上自己的MD5。服务端收到分片后可以先做一次完整性校验不符合就直接拒绝。别觉得这浪费算力一个分片几MB算一次MD5在毫秒级带来的好处是合并时不用猜“这片到底全不全”。上传分片接口必须做幂等处理。同一个chunkIndex被重复上传时服务端不能报错而应该返回“已存在”的确认状态。这依赖分片记录表里的唯一索引具体表结构后面专门说。完成上传的接口更简单客户端把所有分片传完后调用completeUpload把uploadId和分片总数再确认一遍。服务端这时不立即拼文件而是创建一个合并任务扔进任务队列然后立刻返回一个“任务已受理”的状态。客户端看到这个状态就知道上传这半边已经闭环了不用傻等合并结果。3. 断点续传的关键服务端怎么知道“你传过哪些分片”断点续传的实现难度不在客户端在服务端怎么可靠地记录和查询“已传分片”的状态。这一块做不好客户端再卖力重试服务端也会给你传重或者传丢。3.1 两张记录表和一个唯一索引我常用的表结构分两张一张上传记录表一张分片记录表。上传记录表字段大致是id、uploadId、fileMd5、fileName、fileSize、chunkTotal、status、createTime、updateTime。这张表记录“一次完整的文件上传任务”的总体信息。分片记录表字段大致是id、uploadId、chunkIndex、chunkMd5、chunkSize、createTime。这张表记录“这个上传任务已经收到了哪些分片”。分片表以uploadId为外键一张记录表对应多条分片记录。查询“已传分片”时直接按uploadId查分片表根据chunkIndex排序返回给客户端。客户端拿这个列表和自己本地的分片列表做差集只传缺失的部分断点续传就完成了。这个设计的核心是幂等。同一个uploadIdchunkIndex只能有一条记录所以分片表必须建立联合唯一索引类似ALTER TABLE chunk_record ADD UNIQUE INDEX uk_upload_chunk (upload_id, chunk_index);这个唯一索引是我踩过坑后的硬性要求。生产环境里客户端因为网络抖动会重试同一个分片并发上传时多个请求可能同时落到同一个chunkIndex如果不加唯一索引数据一定会出现重复记录。有了它插入时用INSERT ... ON DUPLICATE KEY UPDATE或者先UPDATE影响行数为0再INSERT都能保证幂等。3.2 断点续传的完整时序与边界情况断点续传流程串起来是这样客户端重新打开应用拿到之前没传完的File对象先通过文件大小和修改时间做粗判断复用本地缓存的MD5如果判断文件没变就直接用旧MD5。调用“查询上传进度”接口参数是文件MD5。服务端通过MD5找到uploadId和已传分片列表。客户端把自己要传的chunk列表与已传列表做差集得到待传清单。对待传清单重新执行并发上传。所有分片传完后调用completeUpload。这里的第2步有个陷阱如果服务端通过MD5找不到记录说明要么文件没上传过要么上传记录已经因为过期被清理了。无论哪种情况客户端都只能回到初始化接口重新创建上传任务。已传分片如果还在临时目录里就属于“孤儿分片”需要服务端的定时清理任务处理。还有一种特殊情况值得注意用户重新选中的文件内容和之前传过一半的文件确实一样但文件名改了。由于我们匹配的是文件MD5而不是文件名所以断点续传依然能命中这是设计上刻意为之。反过来如果依赖文件名匹配改名后就得重新传这种体验在业务上很难接受。再补充一个实战经验查询上传进度接口的响应要包含服务端已收到分片的“最大连续性”。有的客户端实现会自作聪明认为只要最后一个分片传完了中间就一定全传完了。实际上在网络异常时完全可能出现chunkIndex99传完了、中间第50片丢了的情况。服务端返回已传分片列表是最稳妥的让客户端老老实实做差集别做任何假设。4. 异步合并把“拼文件”这个重活放到后台当所有分片都上传完成后最关键的一步来了合并。多数初学者会直接在completeUpload接口里同步合并几千个分片在一个请求里拼成一个完整文件。我当初也这么干过结果是接口经常跑几十秒网关都超时了客户端那边还傻等着一个永远等不到的响应。4.1 为什么合并必须异步化合并是个典型的IO密集型操作。合并1GB的文件即使顺序读写磁盘也需要好几秒如果分片存在对象存储或者网络盘上耗时会更夸张。如果把合并放在HTTP请求里同步执行有几个问题。第一HTTP连接被长时间占用。网关、代理、负载均衡器都有超时时间通常60秒左右超出就被断开。客户端收到的是连接重置根本不知道服务端合并是成功还是失败。更糟的是如果服务端其实已经合并完了只是响应没能送达客户端重试completeUpload时就会遇到“任务已存在”的歧义状态。第二并发一高合并操作全部挤在Web线程池里把正常上传分片的小请求也拖死了。上传分片本来是个毫秒级操作被合并任务一挤延迟变成秒级用户体验直接崩。所以正确的做法是completeUpload接口只负责把任务写入任务表返回“合并中”的受理状态真正合并交给后台任务队列处理。这里的关键词是“受理”而不是“成功”——客户端看到受理状态就结束上传流程后续结果靠消息通知或主动轮询获取。4.2 合并任务的状态机与全文件校验我习惯给合并任务设计四个状态PENDING、MERGING、SUCCESS、FAILED。状态机逻辑如下PENDING任务刚写入等待消费者领取。MERGING正在合并消费者已领取。SUCCESS合并完成校验通过。FAILED合并失败允许重试。后台消费者从任务表取出PENDING状态的任务先把状态改成MERGING开始按chunkIndex排序依次读取分片写入目标文件。合并完成后计算目标文件的MD5和初始化记录里的整个文件MD5比对一致则把状态改成SUCCESS不一致或者中间抛出IOException就把状态改成FAILED并把失败原因记录下来。这个全文件MD5校验不能省。分片上传过程中虽然服务端对每个分片做过分片MD5校验但分片MD5校验覆盖的是单个分片的数据完整性合并后的整体校验才是最终兜底。磁盘坏道、位翻转、分片序号错乱这些问题只有合并后校验整个文件才能发现。合并时文件句柄的管理也要注意。最忌讳的做法是用一条条小循环反复open/close文件句柄几千个分片每个都单独打开再关闭光文件打开的损耗就够受。正确做法是目标文件打开后保持句柄不关按顺序读入分片流并写入整个过程只open一次目标文件。4.3 合并任务的幂等与并发控制既然任务支持重试就必须考虑幂等。我的做法是合并任务以uploadId为唯一业务键任务表给uploadId加唯一索引。消费者在同一个uploadId上只能有一个合并任务在执行通过条件更新来保证。具体实现思路是消费者取任务时先执行类似这样的条件更新把占用权抢到自己手里UPDATE merge_task SET status MERGING WHERE upload_id ? AND status PENDING如果影响行数为0说明任务已经被别的消费者抢走了直接跳过。这个条件更新本身就是一个最轻量的分布式锁比引入Redis锁要简单可靠得多适合中小规模场景。并发消费时还要注意磁盘IO的峰值。如果同时有20个合并任务在跑每个任务都要读几十个分片再写一个大文件磁盘IO很容易被打满。我建议合并消费者的并发数控制在2到4个宁可让任务排队也别把磁盘扛死。还有一个细节合并失败重试前要把之前合并出来的半成品文件先删掉或改名否则第二次合并时目标文件已存在写入方式处理不当会造成数据残留。我习惯把目标文件写成临时文件名校验通过后再原子rename成正式文件名这样即使中间失败也不会留下一个“看起来像正式文件”的残缺品。5. 异步通知文件完成之后怎么安全地交给业务方合并任务成功之后分片上传这件事从技术角度看已经结束了但从业务角度看才刚开始。文件传完要转码、要扫描、要入素材库、要触发审核这些后续动作都需要知道“文件已经可用了”。异步通知就是干这个的。5.1 Webhook回调与轮询的选择通知业务方有两种常见形态。第一种是Webhook回调。服务端合并成功后主动向业务方预先配置的URL发一个POST请求把uploadId、文件名、文件大小、文件MD5这些数据带过去。业务方收到回调后自己去存储里拉文件处理。这种方式实时性好是事件驱动架构的标准做法。第二种是状态查询轮询。业务方定期调用查询接口问这个uploadId现在是什么状态。这种方式简单粗暴但延迟高、白白浪费大量无效请求业务量大了以后服务端还得扛住高频查询。我现在的项目里两者都保留了但主推Webhook轮询只是兜底。原因很简单文件上传完成是个低频事件用推的模式最省资源实时性也最好。但要注意Webhook默认是“尽力而为”的投递网络抖动、业务方接口故障都会导致通知丢失。所以不能只发一次就完事必须有重试机制。关于重试策略后面专门说。5.2 通知验签与防重放的具体做法回调通知有个绕不开的问题你怎么确认这个通知真的来自你的服务器而不是别人伪造的尤其是当回调URL暴露在外网时伪造通知会让业务方触发一堆不必要的处理流程风险很大。验签方案我推荐HMAC签名具体步骤如下业务方在平台注册回调地址时双方约定一个AppSecret这个密钥只在服务端存储绝不下发到客户端浏览器或App。服务端构造通知内容后取uploadId、fileMd5、固定时间戳拼成字符串用HMAC-SHA256加AppSecret算出一个签名。签名随通知请求的Header或参数一起发送业务方收到后用同样的方法重算签名并比对。如果签名不一致业务方必须丢弃这个通知。这里的核心原则是密钥只在服务端之间共享任何一端都不能把密钥以明文形式暴露给客户端。如果你把AppSecret塞到前端JS里那验签就形同虚设因为谁都能从浏览器里翻出来。还有防重放。网络抖动可能导致服务端重发通知或者攻击者截获通知以后重复发送。光有签名还不够业务方需要记录已经处理过的通知ID。每次收到通知先检查缓存里有没有这个通知ID有就直接返回成功没有才处理业务处理完再把通知ID写进缓存设置一个合理的过期时间比如一小时。这里有一个容易被忽略的顺序问题必须先验签再查重再做业务处理。如果先查重攻击者可以用伪造的通知ID把真正的通知顶掉形成“通知屏蔽”攻击如果先做业务处理再查重那重复通知就真的执行了多次。顺序必须是“验签 → 查重 → 处理 → 记录”。5.3 阶梯重试与通知幂等通知发出去业务方可能正宕机、正在重启、接口超时这些都要考虑。我采用的策略是阶梯重试第一次失败后等待30秒重试第二次1分钟第三次5分钟之后每10分钟一次最多重试10次超过就标记为通知失败由人工或者定时任务介入。每次重试的请求要带上相同的通知ID这确保了幂等。业务方重复收到相同通知ID时可以从缓存直接判断已处理不会重复执行转码或审核流程。重试时机的选择也有讲究。通知失败后不要立即重试业务方接口可能只是短暂抖动等30秒恢复的概率比较高。如果是服务端重启等几分钟反而更合理。阶梯式设计就是让重试频率从快到慢兼顾及时性和压力控制。另外通知内容建议包含一个快照字段比如文件大小、MD5、上传时间业务方可以拿这些信息和存储里的实际文件做二次校验防止存储数据被篡改或者同步延迟导致业务方读到不完整的文件。6. 落地实战我踩过的坑与调优参数最后这部分没有固定顺序都是我在不同项目里踩过、填过、又总结出来的东西。你照着实现一遍能少走很多弯路。6.1 客户端并发与网络切换的坑并发数不等于越快越好。分片上传通常用并发池控制但并发数不是越高越好。我试过在PC端开10个并发传一个2GB文件速度反而比8个并发慢因为网络带宽到了瓶颈请求排队反而带来额外开销而且TCP多连接之间互相争抢窗口整体吞吐并不线性增长。移动端建议2到3个并发PC内网5到8个并发通常就够了。网络切换导致的请求中断是移动端最容易踩的坑。用户在Wi-Fi和4G/5G之间切换时正在传输的分片大概率会失败。一定要在传输层做失败重试而不是发现请求失败就整个流程重来。重试时指数退避是基本操作比如第一次失败等1秒第二次2秒第三次4秒最多等10秒。还要注意分片读取和内存的平衡。在前端用File.slice()读分片时slice是惰性读取不会一次性载入整个文件进内存但如果把每个分片都通过FormData append到请求里浏览器依然会在内存中缓冲整个分片。对于20MB的大分片同时并发5个就是100MB内存占用移动端容易白屏。所以前端分片不要设计得太大2到5MB对移动端更友好。6.2 服务端存储与清理策略分片上传的所有临时文件服务端是按分片存储的。如果不做清理存储早晚会被塞满。我的清理策略是按时间超过24小时还没完成合并的分片统一删除并把对应的上传记录标记为过期。清理任务每天凌晨跑一次避免占用业务高峰时段的IO。临时目录建议放在独立磁盘上不要和系统盘放一起。我见过一个项目把分片临时文件放在应用根目录下结果磁盘满了直接把整个应用拖垮。临时目录挂在单独的挂载点上即使写满也只是上传功能不可用不会把整个系统拖死。Web服务器和网关的参数也要配合。很多人问Nginx的client_max_body_size是不是要调特别大分片上传场景下根本不需要。单个分片请求体只有几MB保持默认的1MB或调到10MB就够。真正需要注意的是网关或反向代理的空闲超时和代理缓冲配置这些参数会影响单个分片传输途中短暂停顿时的稳定性。6.3 合并I/O与对象存储的取舍如果你用了对象存储而不是本地磁盘合并逻辑就要换思路。对象存储本身不提供“把多个对象拼接成一个”的通用接口常见做法有两个方向。一种是把分片下载到本地再合并合并完再上传完整文件到对象存储。这个方案需要临时磁盘空间带宽成本高不推荐用于大文件。另一种是直接利用对象存储自带的分段上传能力比如S3 Multipart Upload。客户端上传分片时每个分片直接PUT到对象存储最后调用CompleteMultipartUpload接口由对象存储自己在服务端完成合并全程不占用你应用服务器的带宽和IO。这个方案在云原生环境里几乎是必选。这个选择的本质是把合并压力转移到对象存储的服务端还是本地IO要结合成本和网络带宽来权衡。我的建议是如果项目跑在云上优先用对象存储的分段上传如果是自建机房、本地磁盘IO充足那就自己写合并逻辑控制力更强。6.4 测试要覆盖的异常场景清单大文件上传的测试不能只测“一切顺利”的路径否则上线第一天就被打脸。我分享一个我在项目里必须测试用的场景清单几乎每个都能挖出问题上传一半时浏览器崩溃重新打开后断点续传是否准确。上传一半时服务端重启客户端再次查询进度是否正常。同一个分片被并发上传两次服务端是否保持幂等并且没有脏数据。所有分片都传完但缓冲的残留分片混在临时目录里合并时会不会被误用。合并任务执行到一半进程退出重启后任务是否从PENDING重跑而不是卡死。回调通知接口连续返回500阶梯重试是否按预期执行通知ID是否一致。客户端伪造一个不存在的通知ID回放验签和查重是否能挡住。这些场景看着多其实大部分在设计阶段就想清楚的话实现起来并不费劲。关键是别抱着“用户不会这么倒霉”的心态真实环境里这些事每天都会同时发生。我个人在实际操作中最深的体会是这套方案的每一环都必须为“异常”设计而不是为“正常流程”设计。分片上传、断点续传、异步合并、异步通知每个环节单独拎出来都不难难的是把它们串起来之后在断网、重启、并发、重复请求这些意外面前系统还能给出正确的结果。只要分片记录表唯一索引建好了、合并任务幂等控制做好了、回调验签和通知去重实现了这套体系基本就站稳了剩下的大多是调参和清理的体力活。
返回列表