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排序,返回给客户端。客户端拿这个列表和自己本地的分片列表做差集,只传缺失的部分,断点续传就完成了。
这个设计的核心是幂等。同一个uploadId+chunkIndex只能有一条记录,所以分片表必须建立联合唯一索引,类似:
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而不是文件名,所以断点续传依然能命中,这是设计上刻意为之。反过来,如果依赖文件名匹配,改名后就得重新传,这种体验在业务上很难接受。
再补充一个实战经验:查询上传进度接口的响应要包含服务端已收到分片的“最大连续性”。有的客户端实现会自作聪明,认为只要最后一个分片传完了,中间就一定全传完了。实际上在网络异常时,完全可能出现chunkIndex=99传完了、中间第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回放,验签和查重是否能挡住。
这些场景看着多,其实大部分在设计阶段就想清楚的话,实现起来并不费劲。关键是别抱着“用户不会这么倒霉”的心态,真实环境里这些事每天都会同时发生。
我个人在实际操作中最深的体会是:这套方案的每一环都必须为“异常”设计,而不是为“正常流程”设计。分片上传、断点续传、异步合并、异步通知,每个环节单独拎出来都不难,难的是把它们串起来之后,在断网、重启、并发、重复请求这些意外面前,系统还能给出正确的结果。只要分片记录表唯一索引建好了、合并任务幂等控制做好了、回调验签和通知去重实现了,这套体系基本就站稳了,剩下的大多是调参和清理的体力活。