文章目录
当我们在系统中上传大文件的时候,我们系统可能会顶不住,例如几个GB的视频通过后端接口上传到我们的服务器,我们进行InputStream 和 OutputStream 操作的时候会占用我们的内存,当内存不够的时候可能就会触发我们经典的报错: OutOfMemoryError(OOM异常)。那么我们怎么去避免这种异常呢?
除了从前端限制上传的大小的产品设计限制方案以外,那如果我们的业务就是需要上传几个GB的文件呢?我们是否还可以通过技术手段支持?有的:分片上传
分片上传是将大文件切割成多个小块(如30MB/片),分别上传到服务器,最后合并成完整文件的技术。
核心优势:
- 大文件支持:避免单次上传超大文件失败。
- 断点续传:网络中断后可只传失败的分片,而非整个文件。
- 更快上传:可并行上传多个分片,提高效率。
- 避免一次性把文件全部load到内存中引起OOM
典型流程:
- 客户端切割文件 → 2. 逐个上传分片 → 3. 服务端合并。
而大部分分片上传都是前端切割好了之后把每片上传到后端服务器,就像我们公司就是这么做的,但是我们有个这样的需求:我们拿到一个url,里面是一个很大的视频,我们需要把这个url里面的链接打开然后上传到我们的服务器中,这个时候就没有经过前端的分片,而直接从后端去进行切割文件 → 逐个上传分片 → 服务端合并。
例如作者之前做过的一个业务就是将快1GB的文件一次上传到我们公司的OSS存储服务器中,结果在复制流的时候把内容全部加载到内存中而导致了生产的OOM事故。
那么这次我们就使用MINIO来实现文件的分片上传。我们的流程图大致如下:

首先,我们需要做好准备一个部署了minio的linux机器,Linux安装MinIO(图文解说详细版)
第一步,准备环境
SpringBoot入门:如何新建SpringBoot项目(保姆级教程) 项目导入minio 和httpclient的jar包
<!--minio-->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.2.0</version>
</dependency>
<!--minio-->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>
第二步,自定义client
我们需要继承 io.minio.MinioClient这个类自己去实现一个自定义client,因为原生的类中很多方法都是 protected属性,所以我们需要自定义一个client把 protected方法暴露出去变成 public方法
package com.wangfugui.apprentice.common;
import com.google.common.collect.Multimap;
import io.minio.*;
import io.minio.errors.*;
import io.minio.messages.Part;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public class CustomMinioClient extends MinioClient {
/**
* 继承父类
*/
public CustomMinioClient(MinioClient client) {
super(client);
}
/**
* 初始化分片上传即获取uploadId
*/
public String initMultiPartUpload(String bucket, String region, String object, Multimap<String, String> headers, Multimap<String, String> extraQueryParams) throws IOException, InvalidKeyException, NoSuchAlgorithmException, InsufficientDataException, ServerException, InternalException, XmlParserException, InvalidResponseException, ErrorResponseException {
CreateMultipartUploadResponse response = this.createMultipartUpload(bucket, region, object, headers, extraQueryParams);
return response.result().uploadId();
}
/**
* 上传单个分片
*/
public UploadPartResponse uploadMultiPart(String bucket, String region, String object, Object data,
int length,
String uploadId,
int partNumber,
Multimap<String, String> headers,
Multimap<String, String> extraQueryParams) throws IOException, InvalidKeyException, NoSuchAlgorithmException, InsufficientDataException, ServerException, InternalException, XmlParserException, InvalidResponseException, ErrorResponseException {
return this.uploadPart(bucket, region, object, data, length, uploadId, partNumber, headers, extraQueryParams);
}
/**
* 合并分片
*/
public ObjectWriteResponse mergeMultipartUpload(String bucketName, String region, String objectName, String uploadId, Part[] parts, Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams) throws IOException, NoSuchAlgorithmException, InsufficientDataException, ServerException, InternalException, XmlParserException, InvalidResponseException, ErrorResponseException, ServerException, InvalidKeyException {
return this.completeMultipartUpload(bucketName, region, objectName, uploadId, parts, extraHeaders, extraQueryParams);
}
public void cancelMultipartUpload(String bucketName, String region, String objectName, String uploadId, Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams) throws ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, IOException, InvalidKeyException, XmlParserException, InvalidResponseException, InternalException {
this.abortMultipartUpload(bucketName, region, objectName, uploadId, extraHeaders, extraQueryParams);
}
/**
* 查询当前上传后的分片信息
*/
public ListPartsResponse listMultipart(String bucketName, String region, String objectName, Integer maxParts, Integer partNumberMarker, String uploadId, Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams) throws NoSuchAlgorithmException, InsufficientDataException, IOException, InvalidKeyException, ServerException, XmlParserException, ErrorResponseException, InternalException, InvalidResponseException {
return this.listParts(bucketName, region, objectName, maxParts, partNumberMarker, uploadId, extraHeaders, extraQueryParams);
}
}
接着就把我们自定义的类注册为一个bean
package com.wangfugui.apprentice.config;
import com.wangfugui.apprentice.common.CustomMinioClient;
import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MinioConfig {
@Value("${minio.url}")
private String url;
@Value("${minio.accessKey}")
private String accessKey;
@Value("${minio.secretKey}")
private String secretKey;
@Bean
public CustomMinioClient getMinioClient() {
MinioClient minioClient = MinioClient.builder().endpoint(url)
.credentials(accessKey, secretKey).build();
CustomMinioClient customMinioClient = new CustomMinioClient(minioClient);
return customMinioClient;
}
}
然后我们就可以愉快地使用这个client了
@Autowired
private CustomMinioClient minioClient;
第三步,大文件分片
我们写一个MinioUtil类,里面写一个方法:
/**
* 将上传的文件分割成多个 InputStream 对象,每个 InputStream 对应文件的一个分片。
*
* @param uploadfile 需要分割的 MultipartFile 对象,通常是通过 HTTP 上传的文件。
* @return 返回一个包含所有分片 InputStream 的列表,每个 InputStream 对应文件的一个分片。
* @throws IOException 如果读取文件时发生 I/O 错误,则抛出此异常。
*/
public List<InputStream> splitFileToInputStreams(MultipartFile uploadfile) throws IOException {
long CHUNK_SIZE = 5 * 1024 * 1024; // 每个分片的大小为5MB
List<InputStream> inputStreams = new ArrayList<>(); // 用于存储分片的 InputStream
// 使用 BufferedInputStream 读取文件,并将其分割成多个分片
try (BufferedInputStream bis = new BufferedInputStream(uploadfile.getInputStream())) {
byte[] buffer = new byte[(int) CHUNK_SIZE];
int bytesRead;
// 循环读取文件内容,直到文件末尾
while ((bytesRead = bis.read(buffer)) != -1) {
// 将读取到的数据复制到一个新的字节数组中,确保每个分片的大小正确
byte[] chunkData = new byte[bytesRead];
System.arraycopy(buffer, 0, chunkData, 0, bytesRead);
// 将分片数据转换为 InputStream 并添加到列表中
InputStream chunkInputStream = new ByteArrayInputStream(chunkData);
inputStreams.add(chunkInputStream);
}
}
return inputStreams; // 返回分片的 InputStream 列表
}
将上传的文件按5MB大小分片,并将每个分片转换为InputStream,最后返回所有分片的InputStream列表。例如一个600MB的文件会被我们分成 120 个 InputStream 。这样方便后面每次我们上传的时候可以一个一个InputStream 去单独上传了。
第四步,申请大文件上传
准备分片上传时,我们先获取上传任务id,这样后期我们上传到这个任务id的时候minio才知道这是一个大文件。
/**
* 准备分片上传时,在此先获取上传任务id
*/
private String getUploadId(String objectName, String bucketName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, XmlParserException, InvalidResponseException, InternalException {
String contentType = "application/octet-stream";
HashMultimap<String, String> headers = HashMultimap.create();
headers.put("Content-Type", contentType);
return minioClient.initMultiPartUpload(bucketName, null, objectName, headers, null);
}
第五步,获取分片上传的url
例如上面我们的120个分片,则我们需要获取120次的每一片上传的url地址:
/**
* 获取分片上传的预签名URL。
*
* 该方法通过MinIO客户端生成一个用于分片上传的预签名URL。预签名URL允许客户端在指定的时间内直接上传文件到指定的存储桶,而无需通过服务器进行身份验证。
*
* @param fileName 要上传的文件名,该文件名将作为对象存储在MinIO中。
* @param reqParams 额外的查询参数,这些参数将包含在生成的预签名URL中。
* @param bucketName 目标存储桶的名称,文件将被上传到该存储桶中。
* @return 返回一个预签名的URL,客户端可以使用该URL直接上传文件。
* @throws ServerException 如果与MinIO服务器的通信失败。
* @throws InsufficientDataException 如果从服务器接收的数据不完整。
* @throws ErrorResponseException 如果服务器返回错误响应。
* @throws IOException 如果发生I/O错误。
* @throws NoSuchAlgorithmException 如果请求的加密算法不可用。
* @throws InvalidKeyException 如果提供的密钥无效。
* @throws InvalidResponseException 如果服务器返回的响应无效。
* @throws XmlParserException 如果解析XML响应时发生错误。
* @throws InternalException 如果发生内部错误。
*/
private String getPresignedObjectUrl(String fileName, Map<String, String> reqParams, String bucketName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
// 使用MinIO客户端生成预签名URL,设置HTTP方法为PUT,并指定文件、存储桶、过期时间及额外查询参数
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket(bucketName)
.object(fileName)
.expiry(1, TimeUnit.DAYS)
.extraQueryParams(reqParams)
.build());
}
第六步,申请一个大文件上传
有了第四步和第五步的操作,我们在这一步开始将第四步和第五步组合起来返回一个 SplitFileDto类,这个类里面主要放了一个上传的id和每一片分片上传的url
package com.wangfugui.apprentice.common;
import lombok.Data;
import java.util.List;
/**
* @Author: masiyi
* @Date: 2025/4/7
* @Describe:
*/
@Data
public class SplitFileDto {
/**]
* 上传id
*/
private String uploadId;
/**
* 分片上传的url
*/
private List<String> chunkUploadUrls;
}
我们来编写第二步的操作:
/**
* 将分片文件逐个上传到指定的URL。每个分片文件通过HTTP PUT请求直接上传到MinIO存储,避免通过服务端转发,减少网络IO开销。
* 在文件合并之前,分片文件可以重复覆盖上传。
*
* @param uploadUrlList 包含每个分片文件上传URL的列表,URL已签名。
* @param chunkFiles 包含每个分片文件输入流的列表,每个输入流对应一个分片文件。
* @throws IOException 如果在上传过程中发生IO错误,则抛出此异常。
*/
public void upload(List<String> uploadUrlList, List<InputStream> chunkFiles) throws IOException {
// 创建默认的HTTP客户端,用于执行上传请求
CloseableHttpClient httpClient = HttpClients.createDefault();
// 遍历每个分片文件的上传URL和对应的输入流
for (int i = 0; i < uploadUrlList.size(); i++) {
// 创建HTTP PUT请求,将分片文件上传到指定的URL
String chunkUploadUrl = uploadUrlList.get(i);
HttpPut httpPut = new HttpPut(chunkUploadUrl);
httpPut.setHeader("Content-Type", "video/mp4");
// 生成随机的文件名,并设置Content-Disposition头
UUID uuid = UUID.randomUUID();
//todo 可以根据自己的文件加后缀
String name = uuid + ".mp4";
httpPut.addHeader("Content-Disposition", "filename=" + urlEncode(name, "UTF-8"));
// 将输入流转换为字节数组,并设置为请求体
byte[] chunkData = toByteArray(chunkFiles.get(i));
ByteArrayEntity byteArrayEntity = new ByteArrayEntity(chunkData);
httpPut.setEntity(byteArrayEntity);
// 执行上传请求,并获取响应
CloseableHttpResponse chunkUploadResp = httpClient.execute(httpPut);
// 打印上传响应信息
System.out.println("[分片" + (i + 1) + "]上传响应:" + JSON.toJSONString(chunkUploadResp));
// 释放连接资源
httpPut.releaseConnection();
}
}
自定义一个输入流转字节数组方法,因为需要上传一个已经知道大小的文件,如果上传是inputsteam这种流式数据就会失败:
/**
* 将输入流(InputStream)转换为字节数组(byte[])。
*
* 该函数通过读取输入流中的数据,并将其写入到字节数组输出流(ByteArrayOutputStream)中,
* 最终将输出流的内容转换为字节数组返回。
*
* @param inputStream 要转换的输入流,不能为null。
* @return 包含输入流数据的字节数组。
* @throws IOException 如果读取输入流时发生I/O错误。
*/
public static byte[] toByteArray(InputStream inputStream) throws IOException {
// 创建一个字节数组输出流,用于缓存从输入流读取的数据
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
int nRead;
byte[] data = new byte[1024];
// 从输入流中读取数据,直到读取到流的末尾(返回-1)
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
// 将读取到的数据写入到字节数组输出流中
buffer.write(data, 0, nRead);
}
// 确保所有数据都已写入到输出流中
buffer.flush();
// 将字节数组输出流的内容转换为字节数组并返回
return buffer.toByteArray();
}
自定义方法 urlEncode对给定的字符串进行URL编码,并使用指定的字符编码。编码过程中,会将字符串中的特殊字符替换为对应的百分号编码形式。
/**
* 对给定的字符串进行URL编码,并使用指定的字符编码。
* 编码过程中,会将字符串中的特殊字符替换为对应的百分号编码形式。
* 如果输入的字符串为null,则返回空字符串。
*
* @param value 需要编码的字符串,可以为null
* @param encoding 使用的字符编码,例如"UTF-8"
* @return 编码后的字符串,如果输入为null则返回空字符串
* @throws IllegalArgumentException 如果指定的字符编码不被支持
*/
public static String urlEncode(String value, String encoding) {
if (value == null) {
return "";
}
try {
// 使用URLEncoder对字符串进行编码
String encoded = URLEncoder.encode(value, encoding);
// 替换编码后的字符串中的特定字符为对应的百分号编码形式
return encoded.replace("+", "%20").replace("*", "%2A").replace("~", "%7E").replace("/", "%2F");
} catch (UnsupportedEncodingException e) {
// 如果指定的字符编码不被支持,抛出IllegalArgumentException异常
throw new IllegalArgumentException();
}
}
第七步,合并文件
这是最后一步了,我们把每一片分片文件上传之后就可以调用这个方法,将所有分片合并为一个完整的对象。
/**
* 分片上传完后合并
*
* 该函数用于在分片上传完成后,将所有分片合并为一个完整的对象。
*
* @param objectName 对象名称,即上传的文件名
* @param uploadId 上传任务的唯一标识符
* @param bucketName 存储桶名称,即文件存储的容器
* @return ObjectWriteResponse 对象写入响应,包含合并后的对象信息
* @throws ServerException 服务器异常
* @throws InsufficientDataException 数据不足异常
* @throws ErrorResponseException 错误响应异常
* @throws IOException 输入输出异常
* @throws NoSuchAlgorithmException 无此算法异常
* @throws InvalidKeyException 无效密钥异常
* @throws XmlParserException XML解析异常
* @throws InvalidResponseException 无效响应异常
* @throws InternalException 内部异常
*/
public ObjectWriteResponse mergeMultipartUpload(String objectName, String uploadId, String bucketName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, XmlParserException, InvalidResponseException, InternalException {
System.out.println("ready to merge <" + objectName + " - " + uploadId + " - " + bucketName + ">");
// 查询上传后的分片数据
ListPartsResponse partResult = minioClient.listMultipart(bucketName, null, objectName, 1000, 0, uploadId, null, null);
int chunkCount = partResult.result().partList().size();
Part[] parts = new Part[chunkCount];
int partNumber = 1;
// 将分片数据转换为合并所需的格式
for (Part part : partResult.result().partList()) {
parts[partNumber - 1] = new Part(partNumber, part.etag());
partNumber++;
}
// 合并分片并返回合并后的对象信息
ObjectWriteResponse objectWriteResponse = minioClient.mergeMultipartUpload(bucketName, null, objectName, uploadId, parts, null, null);
return objectWriteResponse;
}
第八步,暴露接口
经过前面这些步骤之后我们就可以编写一个controller接口了:
@Autowired
private MinioUtil minioUtil;
/**
* 处理大文件上传请求,将文件分片上传并合并。
*
* @param uploadfile 上传的文件,类型为MultipartFile,包含文件的原始数据。
* @param bucket 存储文件的桶名称,类型为String,指定文件存储的目标桶。
* @return 返回一个JSON格式的字符串,包含合并后的文件对象名称和存储桶名称。
* @throws RuntimeException 如果在上传或合并过程中发生异常,则抛出运行时异常。
*/
@PostMapping("/uploadLargeFile")
public String uploadLargeFile(@RequestParam MultipartFile uploadfile, @RequestParam String bucket) {
ObjectWriteResponse mergeResult;
try {
// 将上传的文件分片为多个输入流,便于后续分片上传
List<InputStream> inputStreams = minioUtil.splitFileToInputStreams(uploadfile);
// 获取上传分片的URL信息,包括原始文件名、分片数量和目标桶
String originalFilename = uploadfile.getOriginalFilename();
SplitFileDto splitFileDto = minioUtil.applyUploadPsiResult2Minio(originalFilename,
inputStreams.size(), bucket);
// 将分片文件上传到MinIO服务器
minioUtil.upload(splitFileDto.getChunkUploadUrls(), inputStreams);
// 合并已上传的分片文件,完成整个文件的上传
mergeResult = minioUtil.mergeMultipartUpload(originalFilename, splitFileDto.getUploadId(), bucket);
} catch (Exception e) {
log.error("uploadLargeFile error", e);
throw new RuntimeException(e);
}
// 构造返回结果,包含合并后的文件对象名称和存储桶名称
JSONObject result = new JSONObject();
result.put("objectName", mergeResult.object());
result.put("bucketName", mergeResult.bucket());
return result.toJSONString();
}
这个时候我们看一下我们的流程图:

第九步,验证分片上传接口

我们验证一下,上传一个MP4文件,可以看到调用完成之后成功返回给了用户,我们这个时候到minio控制台看一下也是没有问题的:

总结
至此,我们使用minio进行了文件的分片上传,那么其实后面我们有很多优化的点,例如可以创建一个mysql的表将大文件和分片文件的信息记录下来,方便后续断点续传。
文章的项目代码作者已经开源,代码仓库为:SpringBoot+MinIO
如果小伙伴有兴趣的话,作者后续可以分片上传整合到 :minio-spring-boot-starter给大家使用 ,就可以免这么多的步骤了,引入即用!!!


被折叠的 条评论
为什么被折叠?



