做了快十年的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挂了就从表里或者磁盘扫描分片目录重建状态。对绝大多数项目来说,做到"Redis为主、磁盘分片文件为辅"就已经足够了。
3. 前端实现:文件切片、并发控制与续传恢复的代码细节
后端设计得再漂亮,前端不配合也白搭。我在这一节给出可以直接抄作业的前端核心代码,用原生JavaScript实现,不依赖特定框架,你无论用的是Vue、React还是传统页面都能迁移。
3.1 文件指纹计算:SparkMD5的工程化使用
计算文件MD5必须分片读取,不能一次性把整个文件塞进内存。核心思路是用FileReader配合Blob.slice()逐段读取,把内容喂给SparkMD5实例,最后得到完整摘要。
javascript复制function calcFileHash(file, chunkSize = 5 * 1024 * 1024) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const fileSize = file.size;
let currentChunk = 0;
const totalChunks = Math.ceil(fileSize / chunkSize);
function loadNext() {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, fileSize);
const blob = file.slice(start, end);
const reader = new FileReader();
reader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < totalChunks) {
loadNext();
} else {
resolve(spark.end());
}
};
reader.onerror = (err) => reject(err);
reader.readAsArrayBuffer(blob);
}
loadNext();
});
}
需要提醒的是,超过2GB的文件走全量MD5会非常慢,而且移动端设备的浏览器极容易因为内存占用过高直接崩溃。我在产品里是这样处理的:文件大于2GB时,只取文件头部256KB、中间256KB、尾部256KB加文件size组合成一个指纹字符串。这个指纹虽然理论上不如MD5严谨,但工程上已经能覆盖绝大多数"同一个文件重复上传"的识别场景。
3.2 切片上传与并发控制的实现
文件切片的API是Blob.prototype.slice,用起来非常简单:
javascript复制function createFileChunks(file, chunkSize = 5 * 1024 * 1024) {
const chunks = [];
let start = 0;
while (start < file.size) {
chunks.push(file.slice(start, start + chunkSize));
start += chunkSize;
}
return chunks;
}
但并发上传不能把分片一股脑全发出去。文件切的片太多时,同时发起几十个请求会把浏览器连接池打满,反而引起排队阻塞。我在实践中通常把并发数控制在3~5个,用一个极简的异步并发队列来控制吞吐。
这里给出一个控制并发上传的核心逻辑:
javascript复制async function uploadChunksWithConcurrency(uploadParams, chunks, concurrency = 3) {
const results = new Array(chunks.length).fill(false);
let cursor = 0;
async function worker() {
while (cursor < chunks.length) {
const index = cursor++;
const chunk = chunks[index];
const formData = new FormData();
formData.append('file', chunk);
formData.append('identifier', uploadParams.identifier);
formData.append('chunkIndex', index);
formData.append('totalChunks', chunks.length);
try {
await axios.post(uploadParams.uploadUrl, formData, {
onUploadProgress: (e) => {
// 记录每个分片的进度,可以用于计算整体进度
}
});
results[index] = true;
} catch (err) {
// 失败的片会留在false,最后统一重试
await retryUpload(uploadParams, index, chunks[index]);
results[index] = true;
}
}
}
const workers = [];
for (let i = 0; i < concurrency; i++) {
workers.push(worker());
}
await Promise.all(workers);
return results;
}
整体进度的计算也比单请求模式容易:已上传分片数 / 总分片数,再结合已完成的字节数展示百分比即可。这一步不需要额外开发,前端循环里实时统计就行。
3.3 续传的恢复逻辑:刷新页面后如何接上
要让用户刷新后还能继续传,最直接的做法是把"当前正在上传的任务元信息"存到localStorage里,下次进来先查本地有没有未完成任务,如果有,先调Check接口拉服务端的已上传分片列表,过滤掉已经传过的片,只传剩下的。
javascript复制const storageKey = `upload:${fileHash}`;
function saveTaskMeta(meta) {
localStorage.setItem(storageKey, JSON.stringify(meta));
}
function loadTaskMeta(fileHash) {
return JSON.parse(localStorage.getItem(`upload:${fileHash}`));
}
async function uploadWithResume(file) {
const identifier = await calcFileHash(file);
const savedMeta = loadTaskMeta(identifier);
const checkResult = await axios.post('/api/upload/check', {
identifier,
fileName: file.name,
fileSize: file.size
});
// uploadedIndexes: 服务端已经收到的分片序号数组
const uploadedSet = new Set(checkResult.data.uploadedIndexes || []);
const chunks = createFileChunks(file);
const pendingChunks = chunks.map((chunk, index) => ({ chunk, index }))
.filter(item => !uploadedSet.has(item.index));
// 只上传 pendingChunks 即可
}
注意一个细节:续传时前端必须重新检查文件大小和总数。如果用户续传时选了一个同名但内容不同的文件,指纹不一样固然会区分开,但如果指纹恰好相同而大小不同(极端情况),你拿新文件的分片去补旧文件的分片,合并出来就是个损坏的乱文件。所以我每次续传都会把fileSize传给后端,后端在Check阶段校验一致性,不一致直接返回失败。
4. 后端实现:Spring Boot下的分片接收、合并与幂等处理
后端是整个方案的真正核心,因为要做的事情远不止"收文件、存文件"这么简单:要接收分片、记录分片状态、校验分片顺序、提供查询接口、合并时还要保证效率。我在这一节给出一套基于Spring Boot的完整实现,代码可以直接复制改造。
4.1 Check接口和Upload接口:一段能跑的Java代码
我的Controller层一般会定义三个接口:
java复制@RestController
@RequestMapping("/api/upload")
public class UploadController {
@Resource
private UploadService uploadService;
/**
* 查询文件上传状态,返回已上传分片索引
*/
@PostMapping("/check")
public Result<CheckResultVO> check(@RequestBody CheckRequest request) {
return Result.success(uploadService.check(request));
}
/**
* 接收单个分片
*/
@PostMapping("/chunk")
public Result<Void> uploadChunk(@RequestParam("file") MultipartFile file,
@RequestParam("identifier") String identifier,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks) throws IOException {
uploadService.uploadChunk(file, identifier, chunkIndex, totalChunks);
return Result.success();
}
/**
* 所有分片传完后,合并分片为完整文件
*/
@PostMapping("/merge")
public Result<FileInfoVO> merge(@RequestBody MergeRequest request) throws IOException {
return Result.success(uploadService.merge(request));
}
}
分片接收的核心逻辑不复杂,但有几个必须注意的点。第一,分片文件落盘后要立刻更新Redis Bitmap;第二,后端的分片文件目录必须以identifier隔离,不能所有文件的分片堆在同一个目录里,否则重名冲突会让人排查到怀疑人生。
java复制@Service
public class UploadServiceImpl implements UploadService {
// 举个例子:/data/upload/tmp/{identifier}/{chunkIndex}.part
private static final String TMP_DIR = "/data/upload/tmp/";
private static final String FINAL_DIR = "/data/upload/final/";
@Resource
private StringRedisTemplate redisTemplate;
@Override
public void uploadChunk(MultipartFile file, String identifier,
int chunkIndex, int totalChunks) throws IOException {
// 1. 保证目录存在
File chunkDir = new File(TMP_DIR + identifier);
if (!chunkDir.exists()) {
chunkDir.mkdirs();
}
// 2. 分片落盘
File chunkFile = new File(chunkDir, chunkIndex + ".part");
file.transferTo(chunkFile.getAbsolutePath());
// 3. 用Redis Bitmap标记该分片已上传
String bitmapKey = "upload:bitmap:" + identifier;
redisTemplate.opsForValue().setBit(bitmapKey, chunkIndex, true);
// 4. 维护分片总数元信息,合并时校验用
String metaKey = "upload:meta:" + identifier;
redisTemplate.opsForHash().put(metaKey, "totalChunks", String.valueOf(totalChunks));
}
}
4.2 合并分片时避免写入一个损坏文件
合并接口要做的事情比较多:先检查所有分片是否齐全,然后用流式方式按顺序把分片写入目标文件,最后校验文件大小和源文件是否一致。
这里有个新手很容易踩的坑:直接用FileOutputStream循环写入没问题,但用FileChannel.transferTo效率高几个数量级。对于一个几个GB的文件,逐字节或者用8KB缓冲区读写的方案会让人等到怀疑人生,而transferTo是内核态的文件拷贝,速度差距可能在10倍以上。
java复制@Override
public FileInfoVO merge(MergeRequest request) throws IOException {
String identifier = request.getIdentifier();
String fileName = request.getFileName();
// 1. 校验分片是否已全部上传
int totalChunks = request.getTotalChunks();
String bitmapKey = "upload:bitmap:" + identifier;
for (int i = 0; i < totalChunks; i++) {
Boolean uploaded = redisTemplate.opsForValue().getBit(bitmapKey, i);
if (!Boolean.TRUE.equals(uploaded)) {
throw new IllegalStateException("分片 " + i + " 未上传,无法合并");
}
}
// 2. 按顺序合并分片
File chunkDir = new File(TMP_DIR + identifier);
File targetFile = new File(FINAL_DIR + System.currentTimeMillis() + "_" + fileName);
try (FileOutputStream fos = new FileOutputStream(targetFile);
FileChannel outChannel = fos.getChannel()) {
for (int i = 0; i < totalChunks; i++) {
File part = new File(chunkDir, i + ".part");
if (!part.exists()) {
throw new IllegalStateException("分片文件缺失:" + part.getAbsolutePath());
}
try (FileInputStream fis = new FileInputStream(part);
FileChannel inChannel = fis.getChannel()) {
inChannel.transferTo(0, inChannel.size(), outChannel);
}
}
}
// 3. 清理临时分片文件(或者事后定时清理)
FileInfoVO vo = new FileInfoVO();
vo.setUrl("/files/" + targetFile.getName());
vo.setSize(targetFile.length());
return vo;
}
合并时如果分片确实缺失,我建议直接抛异常让前端重新触发Check并补齐缺失分片,而不是尝试部分合并。因为部分合出来的文件是坏的,返给用户一个"上传成功"的假象,后续打开文件时才发现损坏,代价更大。
4.3 重复请求、乱序分片、服务重启怎么兜底
分片上传和合并看起来简单,但丢到真实网络环境里就全是脏数据问题。我在生产环境里踩过几个坑,对应的兜底策略也一起给出来:
- 同一分片重复上传:用户点几次重试,同一个分片可能被传到服务端两次。因为文件落盘的命名规则是
{chunkIndex}.part,第二次传会直接覆盖第一次的文件,只要接口处理不报错就是幂等的。 - 分片乱序到达:分片上传接口天然支持乱序,因为每个分片是独立保存的。合并时按索引顺序读取即可,不需要前端保证传完顺序。
- 服务重启导致Redis状态丢失:这是一个真实的高可用问题。我在服务启动的时候加了一个"扫描临时分片目录重建Bitmap"的兜底逻辑:遍历
/data/upload/tmp/{identifier}目录下的.part文件,把存在的分片索引重新setBit。这样即使Redis里的状态丢了,也能从磁盘现场恢复出上传进度。 - 用户在合并前关闭了浏览器:这种情况临时分片会一直留在磁盘上。必须配套一个定时清理任务,把超过24小时没有完成合并的临时目录删掉。
5. 生产环境必须调优的四个地方:网关、Web容器、存储与清理
代码跑通之后,真正的战斗才刚刚开始。我把上线前必须检查的四个位置列出来,每一个都是普通教程不会细讲但生产环境必踩的坑。
5.1 Spring Boot、Tomcat、Nginx三层默认限制要一口气调完
很多团队在测试环境一切正常,一上生产就报"文件太大",原因就是网关层和容器层的限制没放开。我见过有人调了后端Java代码,结果前端Nginx直接返回413,排查了半天,最后发现是Nginx的client_max_body_size默认值只有1MB。
| 层级 | 配置项 | 默认值 | 建议调整值 |
|---|---|---|---|
| Spring Boot | spring.servlet.multipart.max-file-size |
1MB | 10MB(分片单片大小) |
| Spring Boot | spring.servlet.multipart.max-request-size |
10MB | 10MB(单片请求大小) |
| Tomcat | maxPostSize |
2MB(表单) | 10MB |
| Nginx | client_max_body_size |
1MB | 10MB |
注意这里是"单分片大小",不是"整个文件大小"——这也是分段上传的另一个好处:只要分片足够小,各层的请求体限制都不需要调到离谱,安全性和可用性同时得到保障。如果单片大小定为5MB,上面所有配置统一改成8MB或10MB留出余量即可。这里面的数值不一定要完全一致,只要高于分片大小就行。
5.2 分片大小怎么定:不是越小越好
分片大小直接影响上传系统的整体表现。切得太小(比如256KB),分片数量会多到让HTTP连接和临时文件的数量都难以承受;切得太大(比如100MB),单个分片仍然面临超时和中途失败问题,分段的意义就打折了。
我的经验值:
| 场景 | 推荐分片大小 | 理由 |
|---|---|---|
| 普通办公网络上传(1~5GB文件) | 5MB | 平衡性好,推荐默认值 |
| 大文件优先的网盘类产品(10GB以上) | 10~20MB | 减少分片数量,降低状态维护开销 |
| 弱网环境(移动端、跨地域) | 1~2MB | 减少单次传输时间,断线重试成本更低 |
另外有个通用的参考:分片数量尽量控制在2000片以内。如果单文件20GB选了5MB分片,分片数达到4000,前端并发队列再控制不好,服务端临时文件的IO压力、Redis Bitmap的长度都会成倍增长。这时候宁可把单片提到10MB,也要把总数压下来。
5.3 多节点部署时临时分片不能只存在本机磁盘
如果你只部署一台服务器,上面的方案已经够用。但只要上了负载均衡,问题立刻暴露:用户第一次请求被转发到节点A,分片1存到了A的本地磁盘;第二次请求被转发到节点B,分片2存到了B的本地磁盘;合并时无论A还是B都找不到全部分片。
解决方案只有两类:一类是把临时分片目录放到所有节点共享的存储上(NFS、NAS,或者云上的对象存储OSS/S3);另一类是让分片状态和分片文件都带节点亲和性——比如把上传任务固定路由到某个节点。第二种方案实现复杂,而且节点宕机任务就断了。我更推荐的做法是在实现上传分片落盘时,把临时目录抽象成一个存储接口,本地磁盘和兼容S3的对象存储各实现一个,用配置切换,这样从单机到集群扩容的时候不用改业务代码。
5.4 磁盘保护和临时文件的定时清理
超大附件功能上线第一天可能很平静,第二周磁盘可能就满了。那些传了一半放弃的用户、合并完忘记清理的临时目录、重复上传产生的垃圾分片,都会悄悄吃满服务器磁盘。我习惯做两件事:
- 对上传入口做前置校验,文件大小超过系统配置上限直接拒绝,防止恶意用户传一些几百GB的垃圾文件占满磁盘。
- 写一个定时任务每隔几小时扫描一次临时目录,删除创建时间超过24小时且仍未合并的过期分片。
java复制@Component
public class TempFileCleanTask {
@Scheduled(cron = "0 0 */4 * * ?")
public void clean() {
File tmpRoot = new File("/data/upload/tmp");
File[] dirs = tmpRoot.listFiles(File::isDirectory);
if (dirs == null) return;
long deadline = System.currentTimeMillis() - 24 * 60 * 60 * 1000L;
for (File dir : dirs) {
if (dir.lastModified() < deadline) {
// 删除整个临时目录
deleteDir(dir);
}
}
}
}
这个定时任务在生产环境的必要性远超你的想象。没有它,再大的磁盘也会在某个周一的早上被填满。
5.5 秒传:被很多人忽略却最提升体验的功能
分段上传模型天然支持秒传,这才是它比普通上传高一个段位的地方。Check接口返回时,如果发现Redis Bitmap已经全为1,说明这个文件理论上已经完整上传过了。此时合并接口会在服务端找到完全相同的文件直接返回已有地址,用户端表现为"上传进度条一瞬间从0跳到100%"。
秒传的实现其实不需要额外存储,核心是文件名不重要,文件指纹才是唯一身份。当然这里要把文件大小也纳入组合标识的一部分,防止极端情况下不同文件算出同一个指纹。我还会额外判断一下服务器上真实文件是否存在,如果指纹对应的文件被清理了,就触发普通上传流程。
秒传做得好,对大文件用户来说体验提升极其明显。很多人第一次遇到"几十GB文件秒传完成"时,会觉得系统是不是坏了——这就是产品经理愿意看到的"魔法时刻"。
做了几个上千GB级别附件管理的项目之后,我现在的习惯是:只要涉及网页端大文件传输,上来就是分段上传加Check续传加秒传校验三件套,不再做任何单请求大文件传输的尝试。上面这套方案用Java实现起来成本不高,但换来的是用户永远不会再因为"传了几十分钟突然断了"来骂人。
最后分享两个小经验。一个是文件合并完成后,我总觉得硬盘里的最终文件和源文件的MD5不一致,后来发现是分片顺序错乱导致的——所以合并方法里写日志的时候一定要把chunkIndex打出来,方便排查。另一个是前端上传过程中,建议用navigator.onLine监听网络断连事件,断网时自动暂停队列,恢复时自动续传,这个细节能让上传体验再上一个台阶,而且实现成本很低。
