在处理几个 GB 级别的超大文件时,传统的 MultipartFile 直接上传会面临内存溢出(OOM) 、连接超时 和重试成本极高的问题。一旦网络波动,用户可能需要从 0% 重新开始,体验极差。
本篇我们将深入 MinIO 的分片上传(Multipart Upload)机制,通过 Java SDK 结合断点续传逻辑,实现一套生产可用的超大文件上传方案。
一、 核心设计思路
1. 为什么选择分片上传?
- 容错性:某个分片失败,只需重传该分片,无需重头再来。
- 并发性:可以多线程并行上传不同分片,充分利用带宽。
- 秒传基础:基于文件 MD5 校验,如果服务器已存在该文件,直接返回成功。
2. 断点续传的标准流程
- 前端预检:计算文件全局 MD5,询问后端:"这个文件传过吗?"
- 后端响应 :
- 秒传状态:文件已存在,直接返回成功。
- 断点状态:返回已成功上传的分片编号(PartNumber)列表。
- 分片上传:前端过滤掉已上传编号,并发上传剩余分片。
- 最终合并:所有分片上传完成后,通知后端请求 MinIO 执行合并。
二、 核心代码实战
我们将基于 MinIO Java SDK (minio-8.x) 进行关键逻辑实现。
1. 初始化分片上传请求
在 MinIO 中,分片上传需要先获取一个 uploadId。建议将 uploadId 与文件 MD5 绑定存储在 Redis 中,有效期设为 24 小时。
java
/**
* 初始化或获取断点信息
*/
public Map<String, Object> initMultiPartUpload(String bucket, String objectName, String fileMd5) {
try {
// 1. 检查 Redis 中是否已有该文件的 uploadId
String uploadId = redisTemplate.opsForValue().get("UPLOAD_ID:" + fileMd5);
if (StrUtil.isBlank(uploadId)) {
// 调用 MinIO 底层逻辑开启分片上传(需自定义扩展 MinioClient)
uploadId = customMinioClient.initMultipartUpload(bucket, objectName);
redisTemplate.opsForValue().set("UPLOAD_ID:" + fileMd5, uploadId, 1, TimeUnit.DAYS);
}
// 2. 查询该 uploadId 下已经成功上传的分片编号
List<Integer> finishedParts = customMinioClient.listParts(bucket, objectName, uploadId);
Map<String, Object> result = new HashMap<>();
result.put("uploadId", uploadId);
result.put("finishedParts", finishedParts); // 返回给前端,用于断点续传
return result;
} catch (Exception e) {
throw new RuntimeException("初始化分片失败", e);
}
}
2. 生成分片上传的"通行证"(进阶方案)
为了减轻 Java 后端的带宽压力,推荐使用 预签名 URL。后端只负责给前端发"准考证",前端直接把分片丢给 MinIO。
java
/**
* 为特定的分片生成预签名 PUT URL
*/
public String getPresignedUrl(String bucket, String objectName, String uploadId, int partNumber) {
Map<String, String> queryParams = new HashMap<>();
queryParams.put("uploadId", uploadId);
queryParams.put("partNumber", String.valueOf(partNumber));
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket(bucket)
.object(objectName)
.expiry(60, TimeUnit.MINUTES) // URL 1小时内有效
.extraQueryParams(queryParams)
.build()
);
}
3. 完成合并
当所有分片上传完毕,必须显式调用合并操作,文件才会真正对用户可见。
java
/**
* 合并所有分片
*/
public void completeMultipartUpload(String bucket, String objectName, String uploadId) {
try {
// 1. 获取所有已上传分片的 Part 数组
Part[] parts = customMinioClient.listPartsArray(bucket, objectName, uploadId);
// 2. 调用 MinIO 完成合并
customMinioClient.completeMultipartUpload(bucket, objectName, uploadId, parts);
// 3. 清理 Redis 缓存
redisTemplate.delete("UPLOAD_ID:" + objectMd5);
} catch (Exception e) {
log.error("合并文件失败:{}", objectName, e);
throw new RuntimeException("文件合并异常");
}
}
三、 避坑与性能优化指南
- 分片大小限制 :MinIO 规定除最后一个分片外,每个分片必须 ≥ 5MB。建议生产环境设为 10MB 或 20MB。
- 分片排序 :合并时的
Part[]数组必须严格按照partNumber升序排列,否则合并后的文件会损坏(MD5 校验不通过)。 - 垃圾分片清理 :如果用户上传到一半放弃了,MinIO 会保留这些碎分片。
- 建议 :在 MinIO 控制台配置 Lifecycle(生命周期) 规则,设置
AbortIncompleteMultipartUpload为 7 天,自动回收磁盘空间。
- 建议 :在 MinIO 控制台配置 Lifecycle(生命周期) 规则,设置
- 前端性能 :前端在计算大文件 MD5 时建议使用
spark-md5库,并配合Web Worker在后台线程执行,防止浏览器页面卡死。
四、 总结
优雅地处理大文件,核心在于 "分而治之" 与 "状态记录"。
- Redis:记录上传进度。
- MinIO:负责高性能存储与原子合并。
- 预签名 URL:解决后端网卡带宽瓶颈。
掌握了这套方案,面对几百兆甚至几个 GB 的业务需求,你也能从容应对,写出真正"优雅"的工业级代码。