最近做文件上传功能时,我把原来最普通的 MultipartFile file 上传方式彻底改了一遍。
原因很简单。
上传几十 MB 的图片时完全没问题,但用户开始上传 1GB 视频、3GB 素材包、10GB 备份文件,问题马上就来了。
网络稍微抖一下:
上传到92%
↓
请求中断
↓
重新从0开始
一个 10GB 文件传了半个小时,最后因为断网几秒全部重来。这种体验显然不能接受。
后来我把上传流程改成了:
分片上传 + 断点续传 + 秒传。
最终效果是:
10GB文件拆成多个小块
↓
哪个失败只重新传哪个
↓
刷新页面仍然可以继续
↓
服务器已有同样文件
↓
甚至直接"秒传"
今天就用 Spring Boot 把这个功能完整拆开。
一、为什么普通 MultipartFile 扛不住大文件?
最简单的 Spring Boot 上传接口大家应该都写过:
@PostMapping("/upload")
public String upload(
@RequestParam MultipartFile file)
throws IOException {
file.transferTo(
Path.of(
"/data/uploads/",
file.getOriginalFilename()
)
);
return "success";
}
小文件很好用。问题在于,一个 10GB 文件相当于:
浏览器
↓
一次HTTP请求
↓
10GB数据
↓
Spring Boot
↓
最终文件
整个上传是一个长时间任务。中间任何地方出问题:
Wi-Fi断开
手机切网络
Nginx超时
服务重启
浏览器关闭
请求失败
都可能导致上传失败。
所以大文件上传的核心思想不是"怎么让一次 HTTP 请求坚持更久",而是:
不要一次上传整个文件。
二、先把 10GB 文件切成很多小块
假设文件大小 10GB,前端按照 10MB 一个分片,那么会得到大约 1024 个分片。
每一块都有自己的编号:
chunk 0
chunk 1
chunk 2
...
chunk 1023
浏览器上传时,不再发送 movie.mp4 的 10GB 整体,而是不断发送:
fileHash
chunkIndex
totalChunks
chunkData
例如:
fileHash:
7d52a87d...
chunkIndex:
37
totalChunks:
1024
于是整个流程从"一次 10GB 请求"变成"1024 次 10MB 请求"。
如果第 387 个分片失败,只重新上传 387。前面的 0~386 全部保留。这就是断点续传能够实现的基础。
三、Java 后端第一步:给每个文件一个唯一身份
这里不能只使用文件名,因为两个用户完全可能都上传 video.mp4,但文件内容不同。
所以前端通常需要先计算文件摘要,例如 SHA-256。假设最终得到 96e8b7c1a4....,那么这个 Hash 就可以作为文件身份。
Spring Boot 接口可以设计成:
public record ChunkRequest(
String fileHash,
Integer chunkIndex,
Integer totalChunks,
String fileName
) {
}
上传接口:
@PostMapping("/upload/chunk")
public void uploadChunk(
@RequestParam String fileHash,
@RequestParam int chunkIndex,
@RequestParam int totalChunks,
@RequestParam String fileName,
@RequestParam MultipartFile chunk)
throws IOException {
uploadService.saveChunk(
fileHash,
chunkIndex,
totalChunks,
fileName,
chunk
);
}
后端保存目录可以设计成:
/data/upload-temp/
↓
96e8b7c1a4/
↓
0.part
1.part
2.part
3.part
...
也就是说:一个文件对应一个临时目录,每个分片单独存储。
四、真正关键的断点续传,其实是"先问服务器传到哪了"
很多人认为分片上传就等于断点续传,其实还差一步。
用户上传了 0、1、2、3、4、5 分片,然后浏览器关闭。第二天再打开页面,前端怎么知道 0~5 已经上传过了?
答案是:先查询服务器。
增加接口:
@GetMapping("/upload/status/{fileHash}")
public UploadStatus status(
@PathVariable String fileHash) {
return uploadService.getStatus(
fileHash
);
}
返回:
{
"uploadedChunks": [
0,
1,
2,
3,
4,
5
],
"completed": false
}
前端拿到以后,0~5 直接跳过,从 6 继续上传。这才是真正的断点续传。
所以实现断点续传最核心的并不是某个高级 Java API,而是保存上传进度状态,让下一次请求可以恢复。
生产环境可以把状态放在 MySQL、Redis 或对象存储 Metadata 中,而不是完全依赖本地内存。
五、所有分片完成后,再让 Java 合并文件
假设 1024 个分片已经全部上传完成,这时候调用 POST /upload/complete,后端执行合并。Java 使用 NIO 就可以完成:
public Path merge(
String fileHash,
int totalChunks,
String fileName)
throws IOException {
Path chunkDir =
Path.of(
"/data/upload-temp",
fileHash
);
Path target =
Path.of(
"/data/uploads",
fileName
);
try (OutputStream output =
new BufferedOutputStream(
Files.newOutputStream(
target
)
)) {
for (int i = 0;
i < totalChunks;
i++) {
Path part =
chunkDir.resolve(
i + ".part"
);
Files.copy(
part,
output
);
}
}
return target;
}
流程就是:
0.part
↓
1.part
↓
2.part
↓
...
↓
最终movie.mp4
注意,千万不要这样写:
byte[] all =
new byte[(int) fileSize];
然后把所有分片全部读进内存。如果文件 10GB,JVM 基本就可以准备 OutOfMemoryError 了。
正确方式应该始终:流式读取,流式写入。
六、"秒传"其实没有大家想得那么神秘
现在很多网盘上传一个以前传过的文件,会突然显示"上传完成",几 GB 数据一秒钟都没有真正上传。这就是秒传。
它背后的逻辑非常简单。前端上传之前先请求:
POST /upload/check
参数:
{
"fileHash": "96e8b7c1a4...",
"fileSize": 10737418240
}
服务器查询:
SELECT id,
storage_path
FROM file_object
WHERE file_hash = ?
AND file_size = ?
LIMIT 1;
如果已经存在 fileHash 匹配 + fileSize 匹配,那么根本不需要再上传文件内容,只需要给当前用户创建一条文件引用记录。
例如:
真实文件:
file_object
id = 10001
hash = ABC123
path = /storage/ABC123
用户A:
user_file
user_id = 1
file_id = 10001
用户B再次上传同一个文件:
不重新存储
只增加:
user_file
user_id = 2
file_id = 10001
于是 10GB 文件 0 字节真正上传,用户看起来就是"秒传成功"。
所以秒传真正实现的是:内容寻址 + 文件去重。这套设计还能直接减少服务器存储空间。
七、但生产环境绝不能只相信前端传来的 Hash
这里有一个很重要的安全问题。前端告诉 Java"我的文件 SHA-256 是 ABC123",后端不能无条件相信。
否则攻击者完全可以自己构造请求:fileHash = 某个已经存在文件的 Hash,尝试绕过真正的上传过程。
所以至少应该验证 Hash、文件大小、当前用户权限、文件状态。对于高安全场景,文件合并以后还应该由服务端重新计算一次:
MessageDigest digest =
MessageDigest.getInstance(
"SHA-256"
);
然后验证服务端 SHA-256 是否等于客户端声明 SHA-256。如果不同,上传失败,同时删除错误文件。
另外,上传文件名也不能直接这样使用:
Path.of(
"/uploads/",
originalFilename
)
否则还可能面对 ../ 目录穿越、特殊字符、文件覆盖等问题。真正保存时最好使用内部生成的 fileId + 安全扩展名,而不是把用户文件名直接当磁盘路径。
八、并发上传后,我会再增加三个限制
分片以后还有一个很容易犯的错误:既然分片独立,那就一次把 1000 个分片全并发上传。
结果可能变成:
浏览器1000请求
↓
Nginx
↓
Spring Boot
↓
磁盘疯狂随机写
↓
CPU / IO / 连接全部上涨
前端通常限制 3~6 个分片同时上传。而 Java 后端在高并发场景下,至少还要增加三个控制。
第一:单个用户最大并发上传数,例如 5。
第二:单文件最大尺寸,例如 20GB。
第三:临时分片自动清理。例如用户传了一半就再也没回来,/data/upload-temp/xxx 不能永远留着。可以每天定时清理超过 24 小时且状态仍然 UPLOADING 的临时分片。
否则真正跑几个月后,最先爆的可能不是 Java Heap,而是磁盘。
九、最后
以前做文件上传时总感觉功能很简单:MultipartFile → transferTo()。真正做到大文件以后才发现,用户需要的不是"把一个文件 POST 到服务器",而是一整套上传系统:
文件Hash
↓
秒传检查
↓
查询历史分片
↓
分片上传
↓
失败重试
↓
断点恢复
↓
完整性校验
↓
分片合并
↓
生成文件记录
↓
清理临时数据
而 Spring Boot 真正适合做的,就是管理整个上传状态和业务生命周期。
如果你的系统以后会出现视频上传、设计素材上传、数据备份、网盘、文档中心、AI 模型文件、大型安装包等场景,我都不建议继续使用一个 @PostMapping("/upload") 加 MultipartFile file 硬扛到底。
小文件上传和大文件上传,本质上已经是两种完全不同的系统设计。
如果只记住一句话,我建议记住:
大文件上传真正要解决的,不是"怎么把 10GB 传上去",而是"传到 9.9GB 失败以后,怎么不用重新开始"。