1. 为什么超大附件必须分块上传
先说一个我早年踩过的坑:一个文件上传功能,页面上传一个几百MB的文件,结果Tomcat直接抛 java.lang.OutOfMemoryError: Insufficient memory,应用瞬间不可用。当时第一反应是调大JVM堆内存,但治标不治本。后来才意识到,问题不在于堆内存不够大,而在于上传方案本身就不该让大文件一次性挤进内存。
传统表单上传走的是 multipart/form-data,Servlet容器会把整个请求体读入内存或者临时文件。Spring MVC 的 MultipartFile 如果不对 max-file-size 做限制,大文件会瞬间打爆堆内存;就算你设置了 max-file-size,也会直接返回一个让人摸不着头脑的异常,用户上传的大文件根本没法落地。
分块上传的核心思路其实特别朴素:把一个大文件在客户端切成若干小块,逐个上传到服务器,等所有分块传完之后,再由服务器按顺序把分块合并成一个完整文件。整个过程中,任何时刻占用的内存、带宽、单次请求体积都是可控的。
我习惯用一个类比来解释这个方案:你往另一个城市寄一个特大号沙发,快递公司不会把整个沙发塞进一辆车里,而是拆成几个部件分车运输,到了目的地再组装。分块上传本质就是“拆件运输,到站组装”。如果你的业务涉及视频素材、高清图纸、数据库备份、安装包这类动不动几百MB甚至上GB的文件,分块上传不是可选项,而是刚需。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分块上传的整体设计思路
2.1 先拆解需求:超大连附件到底卡在哪
在设计方案之前,先搞清楚超大连附件到底卡在哪几个环节。我把它拆成四个维度:
- 内存维度:整个文件一次性进入内存,JVM堆被撑爆,即使不爆也会频繁GC,拖垮整个应用。
- 超时维度:一个文件传几十分钟,无论是Nginx的
proxy_read_timeout还是网关层的超时配置,很难容忍一个HTTP请求持续这么久,中途断线就前功尽弃。 - 网络维度:弱网环境下,一次大文件传输失败的概率随文件大小指数上升。断了就全重来,用户体验极差。
- 校验维度:大文件在传输过程中一旦出现比特位翻转、数据损坏,传统上传无法定位损坏位置,只能全量重传。
这四个维度如果不处理,无论你用多好的服务器、多大的带宽,大文件上传依然不稳。
2.2 分块上传的三个核心阶段
整个方案分成三个阶段,形成一套完整的传输闭环。
第一阶段:客户端切片
客户端读取文件,按固定大小(比如每块5MB)切成分片,每一个分片拥有独立的序号(index)。这一步不会对文件本身做任何转码或压缩,只是逻辑上的切割。在浏览器端用 File.slice() 就能做到,不需要服务端参与。
第二阶段:分片上传
客户端逐个或并发地把分片通过HTTP请求发送到服务端。服务端把每个分片先存到一个临时目录里,并记录该分片的上传状态。这一步里最关键的任务是:不合并、不校验完整性、不做额外处理,只负责接收和落盘。
第三阶段:合并与校验
当服务端确认某个上传任务的全部分片都已到位时,触发合并逻辑:按分片顺序把临时文件的内容拼接成完整文件。合并完成后计算整个文件的MD5,与客户端上报的MD5比对。一致则上传成功,不一致则说明有分片损坏,定位后补传对应分片。
2.3 关键参数怎么定
分块大小是方案里最重要的参数。它不是拍脑袋定的,需要考虑几个因素。
- 单分片太小(如512KB):分片数暴涨,HTTP请求数量过多,网络开销和服务器处理开销都很大,反而降低效率。
- 单分片太大(如100MB):分片上传的优势几乎消失,一旦失败重试成本很高,而且单请求需要更多内存缓冲。
- 通用选择:我实测下来,5MB-20MB是一个比较合理的区间。局域网上传可以选20MB,公网弱网环境建议选5MB,具体可以用上传测试数据来调整,不要盲从。
关于并发上传数量,不要无脑调高。浏览器对同一域名的TCP连接数有限制(HTTP/1.1通常是6个),并发上传超过这个数量并不会提速,反而会因为排队和内存占用导致页面卡死。我的建议是并发控制在3-5个,现代浏览器配合HTTP/2可以适当放宽,但收益有限。
另一个容易被忽略的参数是临时文件的存储空间。分片全部落盘到服务器,一个2GB的文件、5MB一个分片,就是400个临时文件。如果同时有几十个人上传,磁盘空间消耗非常快。必须有清理机制,超时未合并的分片要定时删除。
3. Java后端的核心实现
3.1 接口设计总览
后端需要提供三个核心接口,职责分离是设计的关键。
| 接口 | 职责 | 关键入参 | 返回 |
|---|---|---|---|
| 初始化上传 | 创建上传任务,返回全局唯一标识 | fileName, fileSize, chunkSize, md5 | uploadId |
| 上传分片 | 接收单个分片并落盘 | uploadId, chunkIndex, chunk | 接收结果 |
| 合并分片 | 所有分片到位后合并 | uploadId, fileName | 文件路径、MD5 |
初始化接口十分重要却被很多人省略。有的项目不做初始化,直接让客户端把 uploadId 通过UUID自行生成传上来,这样做的缺陷在于:服务端无法在初始化阶段就知道这个文件有多大、分了多少片,也没法提前做配额校验和重复检查。
3.2 初始化上传任务
java复制@PostMapping("/upload/init")
public
