分块上传这个需求,只要是做过文件系统的后端,基本都绕不开。我最早遇到是在做一个管理后台,要支持上传几百MB的安装包和培训视频,用户传着传着进度条就卡死,刷新页面又得从头再来,后台还被撑爆过内存。后来我把上传模块彻底重构成一个可扩展的分块上传组件,才把这个问题按下去。这篇文章就把这套组件的链路拆解、扩展边界和工程细节一次说清楚,适合正在改造成分块上传的后端、全栈同学,也适合那些“明明接了OSS但还是要自己写服务端合并逻辑”的团队参考。
分块上传组件扩展开发,核心不是“把文件切碎”这个动作,而是你要在组件里留好哪些扩展点,以及在并发上传、校验、合并、清理这些环节上把好关。很多博客讲分块上传只讲前端怎么切片、后端怎么收,实际上服务端组件的设计难度远高于客户端。你面对的是一堆乱序到达的分片、超时重传、磁盘写满、合并时OOM、还有和各种存储后端对接的问题。
1. 分块上传组件要解决的根本问题:链路拆解与原始需求
1.1 大文件直传的三大痛点
先说结论:在大文件上传场景里,分块不是一种“优化方案”,而是基本前提。要是你的系统还允许用户通过一个普通的POST请求直接上传1GB的文件,那你会连续踩中三个坑。
第一是连接超时。单个HTTP请求从发起到响应,中间要经过浏览器/App、网关、负载均衡、应用服务器,任何一个节点都可能有超时限制。文件越大,传输时间越长,被打断的概率指数上升。而且一旦超时,服务端可能已经收了一半的数据,客户端却不知道,只能整个重来。这个失败成本在几十GB的文件上几乎是不可接受的。
第二是内存占用。Spring MVC默认的multipart解析,会把上传数据先写入内存或临时文件。线程池里同时来几个大文件,内存直接飙高。很多人配置了spring.servlet.multipart.max-file-size,以为限制住大小就安全了,其实那只是拒绝了超限请求,体面地拒绝,不代表你能处理大文件。
第三是重试成本。没有分块,就没有“局部重试”的概念。断了就是全断,重传就是全传。网络环境越差,用户体感越糟糕。分块之后,失败只需要重传那一块,成功块可以复用。
分块上传最核心的价值就在这里:把一个不可控的长任务,拆成一组可控的短任务。每个分片的大小和数量都可以规划,服务端可以边收边合并,客户端可以并行加速,失败时只重试受影响的分片。而“断点续传”也自然就有了——只要服务端能告诉客户端“哪些分片已经收到”,客户端就能跳过这些分片继续传。
1.2 分块上传的完整链路
抛开具体的前端框架,服务端分块上传组件至少要支持这样四条核心链路。
第一步是初始化上传。客户端先请求一个init接口,带上文件名、文件大小、预计分片大小等信息。服务端生成一个uploadId,把文件元数据记录下来,同时算出总的分片数,返回给客户端。这个uploadId是整个上传任务的身份标识,后面所有分片请求都要带着它。
public class UploadInitResponse { private String uploadId; // 全局唯一 private long chunkSize; // 建议分片大小(字节) private long totalChunks; // 总分片数 private List<Integer> uploadedChunkIndexes; // 已收到的分片,用于断点续传 }第二步是分片上传。客户端按约定的分片大小把文件切块,每个请求携带uploadId、chunkIndex、chunkData。服务端收到后,先做参数校验,再把文件块写入临时存储,更新该任务的接收进度。这一步是并发发生的,所以服务端必须处理分片乱序、重复、缺失的情况。
第三步是合并。客户端在确认所有分片都成功后,调用complete接口。服务端按分片序号把临时文件拼接成完整文件,再执行整体校验(比如MD5比对),最终把文件移动到正式存储位置,更新任务状态。
第四步是可选的回调。如果业务需要,在合并完成后触发一个回调事件,比如更新数据库、推送消息、通知审批流等。这个回调不应该同步阻塞在合并线程里,不然大文件合并会拖垮整个请求链路。
1.3 断点续传、秒传与分块的边界
很多人在讨论里把分块上传、断点续传、秒传混在一起说,其实它们是不同层次的能力。
分块上传是传输机制,解决的是“怎么把一个大文件安全地传完”。断点续传依赖分块机制,解决的是“失败后从哪继续”,核心是服务端要能响应queryUploadedChunks这类查询,告诉客户端哪些分片已存在。秒传则是另一条路:客户端先用spark-md5之类工具算整个文件的指纹,服务端查一下库里有没有相同指纹的文件。有的话直接把这个文件关联到当前用户,返回成功,根本不用传。秒传和分块没有必然关系,但一个成熟的上传组件通常会把这两个能力都收进去,因为它们的判断时机都在“真正上传之前”。
理解了这三者的边界,你再去设计组件就会清醒很多。分块组件不一定要做秒传,但一定要把分片的接收、查询、合并接口设计好,否则后面想加断点续传会很痛苦。
2. 扩展开发第一步:把变化点抽象成组件边界
2.1 固定流程与变化点分离
扩展开发最容易犯的错误,是把所有可能性都塞进一个类,然后在方法里写满if (storageType == OSS)。这样做确实能撑过第一次迭代,但等你要接第二个存储、换校验算法、加文件命名规则的时候,这个类就会膨胀到不可维护。
正确的思路是:先把上传组件的固定流程定死,再把变化点抽象成接口。
固定流程是稳定的:初始化任务 -> 接收分片 -> 校验分片 -> 记录进度 -> 合并文件 -> 触发回调。这个流程对所有存储后端都一样。变化点却不稳定:存储后端(本地磁盘、MinIO、OSS、NAS)、分片校验算法(无校验、CRC32、MD5)、文件命名策略(按日期、按业务ID、带随机串)、回调方式(同步、MQ、Webhook)。
这两类东西必须分开。固定流程用模板方法模式收敛在一个核心执行器中,变化点通过接口注入。后面扩展时,大多数情况下你只是新增一个接口实现类,而不是去修改核心流程的代码。
2.2 核心扩展接口设计
我实际沉淀下来,最小的必要接口有四个。
第一个是ChunkStorage,负责分片临时存储和合并时的数据读取。这是最关键的一个扩展点,因为不同环境的存储能力差别非常大。
public interface ChunkStorage { void saveChunk(String uploadId, int chunkIndex, InputStream inputStream, long size) throws IOException; InputStream openChunk(String uploadId, int chunkIndex) throws IOException; List<Integer> listUploadedChunks(String uploadId) throws IOException; void deleteTask(String uploadId) throws IOException; }本地磁盘实现就是按目录写文件;OSS实现就是分片上传到临时Prefix;MinIO实现类似。组件核心只依赖接口,不关心具体写在哪。
第二个是ChecksumStrategy,定义分片校验逻辑。接收分片时,客户端通常会带一个分片MD5,服务端需要重新计算并比对。这个策略可以分场景切换:内网传输可靠,可能干脆跳过校验省CPU;公网传输,则必须校验。
第三个是FileNamingStrategy,定义合并后的正式文件命名规则。有的业务希望不暴露原始文件名,有的希望保留原名的同时加日期前缀,有的希望按业务系统传过来的bizId命名。设计成接口后,这些都能通过配置切换。
第四个是UploadTaskRepository,定义上传任务的元数据存取。任务状态、已接收分片列表、文件大小、创建时间这些信息,可以放数据库,也可以放Redis,也可以只放内存(单机、允许重启丢失的场景)。但接口定义是稳定的,这样组件就不关心底层是MySQL还是Redis。
这四个接口定义好,组件的核心流程代码就基本不用动了。扩展开发的核心工作,变成了“新增实现类 + 配置绑定”,而不是“到处打补丁”。
2.3 上传任务的状态机与组件内部通信
既然叫组件,就不能只是一个“工具类”,它内部还应该有一套清晰的任务状态变化逻辑。我习惯用一个最小状态机来约束上传任务的生命周期。
任务状态至少要有INIT、UPLOADING、MERGING、SUCCESS、FAILED这五个。分片接收和合并两个操作都要更新状态,但要避免直接互相调Service。一个比较干净的做法是事件驱动:接口层把事件发布出去,状态机监听并流转。
比如chunkReceived事件发生后,状态机判断如果所有分片都已接收,就把任务状态从UPLOADING变更为MERGING,并触发合并逻辑。mergeDone事件再触发SUCCESS和后续回调。这个状态机的好处是,当你要加“取消上传”“人工重新触发合并”这些操作时,只需要新增对应的事件和状态转移规则,而不是去改分片接收和合并的核心代码。
我见过不少失败的上传组件,问题都不是单点功能缺失,而是状态散落在各个业务逻辑里。分片上传一个方法里直接改库,合并另一个方法里再改库,中间的状态完全靠猜。组件化改造的第一步,其实不是接口抽象,而是先把状态机画清楚。状态机清楚了,接口边界自然就清楚了。
3. 并发上传与分片合并的工程细节
3.1 客户端并发窗口与连接池配置
分片上传允许并发,但不代表可以让客户端一次性把所有分片全发出去。我见过有同事把1000个分片同时发起上传的,结果网关连接数直接被打满,连普通接口都开始超时。
一般我会建议客户端把并发窗口控制在3到5个分片。这个数字对大多数场景足够:既充分利用带宽,又不会把服务端连接池冲垮。而且分片上传的瓶颈通常在带宽和磁盘IO,不在并发数。并发数拉满,收益是边际递减的,风险却是线性上升的。
服务端这边,如果用的是RestTemplate或HttpClient访问存储后端,连接池参数一定要调。默认的HttpClient连接池很小,并发上传分片时大概率会遇到连接排队。我常用的配置是setMaxTotal(200)、setDefaultMaxPerRoute(50),同时把连接建立超时设为3秒,读取超时根据分片大小设为60秒到120秒。这里的逻辑很简单:分片大小是固定的,读取时间长了大概率是网络卡住或对端处理出问题,设一个合理的上限能快速失败,给重试留出时间。
3.2 服务端分片接收与幂等处理
并发上传必然带来重复请求。客户端超时重试、用户网络抖动导致的半包重发,都会让同一个chunkIndex的片段到达服务端多次。设计组件时,这个过程必须幂等。
服务端接收分片的常规做法是:根据uploadId和chunkIndex生成一个临时文件路径,比如/tmp/upload/{uploadId}/{chunkIndex}.part。每次请求到达,直接覆盖写这个文件。从效果上看,后到的请求确实会覆盖先到的请求,但这里有个隐藏问题:并发写同一个分片文件,会导致写一半的文件被另一个线程截断,最终存下来的分片是损坏的。
我处理的办法是按uploadId加细粒度锁,保证同一个分片的写操作串行化,不同分片之间可以并行。
private final ConcurrentHashMap<String, Object> taskLocks = new ConcurrentHashMap<>(); private Object getTaskLock(String uploadId) { return taskLocks.computeIfAbsent(uploadId, k -> new Object()); } public void saveChunk(String uploadId, int chunkIndex, InputStream in, long size) { synchronized (getTaskLock(uploadId)) { // 写临时文件,并校验size与md5 } }细粒度锁比给整个任务加锁要高效得多。单个分片的写操作很快,锁冲突很小;但如果没有锁,并发环境下分片文件基本是必损坏的。
另外,服务端每次接收分片时,还可以做一个轻量校验:客户端上传时会传一个partMd5,服务端写入临时文件后计算实际MD5做比对。不一致的要么是传输损坏,要么是客户端算错了,直接返回失败,让客户端重传这个分片。这个校验成本不高,但能挡住大量“合并后才发现的静默损坏”。
3.3 合并策略与存储顺序
所有分片接收完成后,进入合并阶段。合并最容易出的问题是内存溢出:有人图省事,把所有分片一次性读入内存再Files.write拼成一个byte[]。这种写法在100MB以下的文件可能没问题,上了GB必然会出问题。
正确的合并姿势是流式拼接,让数据从磁盘到磁盘直接流动,不要进堆内存。Java实现里效率最高的是FileChannel.transferTo或transferFrom:
try (FileChannel out = FileChannel.open(targetPath, WRITE, CREATE)) { for (int i = 0; i < totalChunks; i++) { Path partPath = taskDir.resolve(i + ".part"); try (FileChannel in = FileChannel.open(partPath, READ)) { long position = 0; long size = in.size(); while (position < size) { position += in.transferTo(position, size - position, out); } } } }transferTo在Linux上会走sendfile系统调用,数据在操作系统内核层直接搬运,用户态几乎没有内存拷贝。合并几十GB的文件也不会有OOM风险。如果存储后端支持分片合并API(比如OSS的completeMultipartUpload),那就连这个循环都省了,直接把分片信息列表交给存储后端去合并,速度会更快。
还有一点,合并完成后,临时目录里的分片文件记得清理。如果把清理步骤漏了,短期可能只是磁盘多了几个文件,长期就是inode爆炸。那个uploadId对应的临时目录,是必须由组件负责打扫的。
4. 参数调优与真实踩坑记录
4.1 分片大小怎么选
分片大小没有绝对标准,但我实测下来,大体可以参考下面这张表:
| 分片大小 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 128KB ~ 512KB | 弱网、移动端 | 失败重试代价小 | 请求数过多,元数据开销大 |
| 1MB ~ 4MB | 常规Web端 | 均衡,最推荐 | 无 |
| 8MB ~ 16MB | 内网、对象存储直传 | 请求数少,吞吐高 | 单分片失败重传代价较大 |
如果你是做通用上传组件,给个默认值2MB基本不会出大错,同时允许业务方在初始化上传时自定义分片大小。这里有个容易被忽略的点:分片大小一旦在初始化时确定,客户端在后续上传过程中就不能改变,否则已经上传成功的那批分片就全部作废了。所以客户端切分逻辑要和初始化接口的参数严格保持一致,前后端最好都用同一个配置来源。
4.2 超时重试与网关层的隐藏坑
分片上传最容易踩的坑,其实不在应用层,而在网关和负载均衡层。
Nginx默认的client_max_body_size只有1MB。如果分片大小是2MB,客户端每传一个分片,Nginx都会直接返回413,而应用日志里根本看不到任何请求。我排查过好几个“上传突然全失败”的问题,最后都是部署环境里某个Nginx配置没同步。更新这个配置还不够,改完必须nginx -t并reload,不然不会生效。
另一个坑是proxy_read_timeout。默认的60秒对于大分片来说是不够的,尤其当分片大小被你调大到8MB,而客户端上行带宽只有1MB/s时,物理上就需要8秒以上,但Nginx到后端的读取超时可能先触发。建议把上传路径的proxy_read_timeout设置到300秒以上,同时对网关层的内存缓冲也要注意。一些网关默认会把请求体缓冲到内存,来一个大分片就直接占掉几十MB内存,并发稍微高一点就会把网关搞崩。
我常用的调优方案是,上传相关的路径单独配置一份Nginx Location,不跟普通接口共用超时参数。一个上传接口和一个查询接口的超时时间本来就不该一样,全都用默认值必然出事。
应用层的超时设置也不能缺。请求分片上传接口时必须设置读超时,而不是只设连接超时。连接超时只是解决“连不上”的问题,分片传了一半卡住,只能靠读超时兜底。配合客户端重试,就能保证单个分片失败后被快速重新上传。
4.3 临时文件清理与任务生命周期
最后必须说临时文件。分片上传过程中,临时文件是必然存在的,但如果不管理好生命周期,这些临时文件就会变成磁盘上的“僵尸”。
两个基本手段:第一个是合并完成后立即删除分片临时目录,这是组件内主动清理;第二个是定时任务兜底,扫描所有上传任务,对超过一定时间仍处于UPLOADING状态的任务做过期处理。用户传了一半退出、网络断了、浏览器关了,都有可能让任务卡在中间态,定时清理能把磁盘空间释放掉。
我常用的过期策略是:任务创建后30分钟内没有新的分片上传事件进来,就判定为僵尸任务,清理临时文件并标记FAILED。这个策略要注意边界,比如大文件断点续传场景下,用户可能确实会隔几分钟再传下一批分片,所以过期时间不能太短。按业务实际调整,我这个30分钟在大多数ToB场景是够的。
还有磁盘水位的保护。写分片临时文件前,可以先查一下磁盘剩余空间,低于阈值就直接拒绝新的分片接收。不做这个保护,一个被遗忘的僵尸任务就能在几个小时里把磁盘写满,搞挂整个服务。
4.4 前端配合与前后端约定
组件扩展开发不止后端的事,前端配合方式同样要纳入设计。至少在分片拼接规则上,前后端必须对齐。我个人踩过最痛的坑是,前端用一个中间件做分片,后端也实现了分片接口,但两边的chunkIndex一个从0开始,一个从1开始,导致合并出来的文件总是缺一小块。这类问题排查起来非常绕,因为分片本身都能上传成功,只有最后拼出来的文件打不开。
比较好的做法是,在初始化接口返回的元数据里,明确包含chunkIndexStart字段和分片大小,前端直接按这个约定执行,而不是各自写死规则。同理,进度展示也建议按分片数来算,而不是按字节数。网络抖动时,按字节的进度条会忽前忽后,按分片数的进度条虽然跳跃但最终能到100%,用户体感反而稳定。
前端如果要支持断点续传,还需要在上传前查询已接收的分片列表。前端拿到这些序号后,就可以直接跳过已经传过的分片,从未传的分片开始。这个查询接口的响应要尽量轻量,因为客户端在每次页面加载后都可能触发一次。
5. 写在最后
分块上传组件做起来不难,难的是把边界画清楚。流动的状态、稳定的接口、可控的并发、可靠的重试——这几个点抓住了,组件基本就立住了。
我个人的体会是,不要一开始就追求对接各种存储后端和复杂的断点续传。先把“初始化、传分片、合并、删除临时文件”这条最小闭环跑通,再逐步把存储适配、校验策略、秒传能力作为扩展点接进去。这样组件核心代码会一直保持稳定,而新需求进来时,你只需要关心新增哪个实现、注册哪段配置,而不是动不动就把上传核心逻辑改一遍。
顺带说一句,如果你正在面试或者给组里做技术分享,讲分块上传组件时别总盯着前端怎么切片,能把任务状态机、并发锁、分片幂等、合并流的边界讲清楚,才真正体现了你对这个组件的掌控力。