做了快十年的Java Web开发,我到现在还记得第一次接到"实现超大附件上传"需求时的场景:用户往系统里传一个2.7G的压缩包,前端页面转圈转了四十多分钟,最后Tomcat抛了个Connection reset,用户当场就炸了。从那之后我就意识到,网页开发里处理超大附件的分段上传与续传,根本不是一个"把上传接口的size限制调大"就能解决的问题,它牵扯到前端分片策略、后端临时存储、断点状态管理、并发控制、最终合并校验一整条链路。
这篇文章我就把这一整套思路和可复用的代码方案完整写出来。文章会聚焦在Java Web开发场景下,如何用分段上传解决超大文件传输问题,以及断线之后如何从断点继续传,而不是让用户从头再传一遍。内容覆盖从原理到前后端实现、从单机到集群部署的注意事项,无论你是刚接触上传功能的新手,还是已经写过上传接口但被大文件搞崩溃的老手,应该都能从里面找到能直接用的东西。
1. 先病根再看药:普通上传方案到底败在哪
很多人一上来就改spring.servlet.multipart.max-file-size,改完发现还是不行,或者传小文件没问题、传大文件就卡死。这说明没搞清楚问题的真正来源。我先把几个最根本的原因拆开讲清楚。
1.1 一个HTTP请求扛几GB数据,压力全在请求模型上
普通的表单文件上传,本质上是把整个文件作为HTTP请求体一次性发送给服务端。浏览器端把文件读进内存、分成TCP包往外发,服务端Tomcat或者Spring Boot再通过MultipartResolver把整个请求体接收下来。听起来挺顺,但这套模型在超大附件面前至少卡在三个地方:
- 内存压力:
MultipartFile默认先落临时文件还好,但很多代码写得糙,直接在内存里getBytes(),几GB的文件一进来JVM直接OOM。搜索引擎里天天有人搜java: outofmemoryerror: insufficient memory,一大半就是这样来的。 - 请求超时:一个几GB的请求体,传输时间可能是几分钟甚至几十分钟。中途只要出现一次网络抖动、Nginx的
proxy_read_timeout超时、浏览器连接被回收,整个请求就废了,用户只能痛苦地从头再传。 - 没有断点恢复:HTTP请求本身是无状态的,请求断了就断了,服务器不会记得你刚才传了多少数据。这跟"用网易远程传输文件传着传着就停住了,要暂停再开始才能继续"完全是同一个底层问题。
所以超大附件的核心矛盾不是"文件大",而是"一个网络请求活着的时间太长了"。任何长连接都会面临中断、超时、内存占用等问题。
1.2 断点续传真正要解决的是这三个问题
把需求拆开看,一个可靠的大文件上传方案至少要同时满足三个目标:
| 目标 | 说明 |
|---|---|
| 失败重传成本可控 | 传了一半断了,再次上传能跳过已传部分,而不是从0开始 |
| 内存占用有界 | 无论文件多大,JVM的堆内存占用都应该保持在一个稳定范围,跟文件大小无关 |
| 用户可感知进度 | 用户能看明白"正在上传哪个分片、整体进度是多少",而不是一个永远转圈的loading |
这三个目标同时指向同一个技术方案:分段上传。把一个大文件切分成若干小块,逐块独立上传,服务端收齐后按顺序合并。每个分片是一个独立的小请求,生命周期短、内存占用低、断了只需要重传没传完的那几片——这就是整个方案的核心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分段上传的整体设计:从切片、标识到合并的闭环
设计方案之前,脑子里要有一张完整的大图。分段上传不是简单地把文件切碎再拼起来,它还需要解决"怎么识别同一个文件""怎么知道哪些片传过了""什么时候可以合并"这些看起来不起眼、实际决定成败的细节。
2.1 一次上传任务的生命周期
一个标准的分段上传流程,从前端选文件开始到最终合并成功,大致走这么几步:
- 用户选择文件,前端按固定大小(比如5MB)把文件切成N个分片。
- 前端计算文件的唯一标识(一般是MD5或某种抽样指纹),调后端
Check接口查询"这个文件传过没、传到了什么程度"。 - 后端返回状态:如果是全新文件,返回需要上传的全部分片索引;如果之前传了一半,返回缺失的分片索引列表。
- 前端拿到的未上传分片列表,控制并发数依次上传,每个分片携带着文件标识和分片序号。
- 所有分片传完后,前端调
Merge接口,后端校验分片齐全后按顺序合并成完整文件。 - 合并完成后清理临时分片,返回最终文件访问地址。
这里面最容易忽略的是第2步。很多第一次做分段上传的人,直接把切片上传和合并两个接口写完就以为完事了,结果用户传了50%刷新了一下页面,前端不知道之前传过哪些片,又重新传了一遍。没有Check接口,续传就是空中楼阁。
2.2 文件唯一标识怎么算:全量MD5需要付出的代价
判断"是不是同一个文件",最可靠的做法是计算整个文件的MD5。但这里有个很现实的性能问题:一个10GB的文件,全量算一遍MD5可能要几十秒甚至几分钟,而且在浏览器端算会占用大量CPU,页面都会卡顿。
实际项目里我见过两种思路:
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 全量MD5 | 用SparkMD5分片读取整个文件计算完整摘要 | 对文件一致性要求极高,且文件一般不超过2GB |
| 抽样指纹 | 取文件大小 + 文件头部若干KB + 中间若干KB + 尾部若干KB的hash,组合成标识 | 大文件、产品希望"秒传"体验 |
抽样指纹存在理论上的碰撞概率,但工程上配合文件大小、文件名、上传时间等信息,碰撞概率已经低到可以接受。我个人的习惯是:2GB以内全量MD5,超过2GB走抽样指纹加上文件大小组合标识,用"快速通道"换取体验。后面小节会给出具体的实现方式。
2.3 分片状态存哪里:Redis Bitmap是最顺手的选择
服务端要回答"这个文件已经传了哪些分片",最简单的做法是每收到一个分片就往数据库插一条记录。但在高并发分片上传场景下,这种方案会瞬间产生几千上万条INSERT,数据库压力很大。另一个更轻量的方案是Redis的Bitmap。
Bitmaps在Redis里本质上就是字符串,每一位代表一个分片的上传状态。假设文件被切成100个分片,就维护一个长度为100的bit数组,分片5上传成功就SETBIT upload:文件标识 5 1,查询哪些分片没传时用BITFIELD或者逐位扫描即可。一次上传任务只需要一个key,内存占用极小,一个10GB文件切成5MB分片也就2000个bit,完全在可控范围内。
当然,纯Redis方案有个前提:Redis不会丢数据。如果Redis持久化配置是默认的RDB快照模式,极端情况下可能丢失最近几秒的上传状态,这时分片文件其实已经在磁盘上了,但状态丢了。稳妥做法是为上传任务维护一套数据库元数据表(记录文件大小、分片数、状态),用Redis bitmap做高并发下的实时状态查询,Redis挂了就从表里或
