【分片上传】用户在生产上传了一个超大文件给我干OOM了


当我们在系统中上传大文件的时候,我们系统可能会顶不住,例如几个GB的视频通过后端接口上传到我们的服务器,我们进行InputStream 和 OutputStream 操作的时候会占用我们的内存,当内存不够的时候可能就会触发我们经典的报错: OutOfMemoryError(OOM异常)。那么我们怎么去避免这种异常呢?

除了从前端限制上传的大小的产品设计限制方案以外,那如果我们的业务就是需要上传几个GB的文件呢?我们是否还可以通过技术手段支持?有的:分片上传

分片上传是将大文件切割成多个小块(如30MB/片),分别上传到服务器,最后合并成完整文件的技术。

核心优势:

  1. 大文件支持:避免单次上传超大文件失败。
  2. 断点续传:网络中断后可只传失败的分片,而非整个文件。
  3. 更快上传:可并行上传多个分片,提高效率。
  4. 避免一次性把文件全部load到内存中引起OOM

典型流程:

  1. 客户端切割文件 → 2. 逐个上传分片 → 3. 服务端合并。

而大部分分片上传都是前端切割好了之后把每片上传到后端服务器,就像我们公司就是这么做的,但是我们有个这样的需求:我们拿到一个url,里面是一个很大的视频,我们需要把这个url里面的链接打开然后上传到我们的服务器中,这个时候就没有经过前端的分片,而直接从后端去进行切割文件 → 逐个上传分片 → 服务端合并。

例如作者之前做过的一个业务就是将快1GB的文件一次上传到我们公司的OSS存储服务器中,结果在复制流的时候把内容全部加载到内存中而导致了生产的OOM事故。

那么这次我们就使用MINIO来实现文件的分片上传。我们的流程图大致如下:

image

首先,我们需要做好准备一个部署了minio的linux机器,Linux安装MinIO(图文解说详细版)

第一步,准备环境

SpringBoot入门:如何新建SpringBoot项目(保姆级教程) 项目导入miniohttpclient的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();
    }

这个时候我们看一下我们的流程图:

在这里插入图片描述

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

image-20250412145916569

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

image-20250412150031493

总结

至此,我们使用minio进行了文件的分片上传,那么其实后面我们有很多优化的点,例如可以创建一个mysql的表将大文件和分片文件的信息记录下来,方便后续断点续传。

文章的项目代码作者已经开源,代码仓库为:SpringBoot+MinIO

如果小伙伴有兴趣的话,作者后续可以分片上传整合到 :minio-spring-boot-starter给大家使用 ,就可以免这么多的步骤了,引入即用!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

掉头发的王富贵

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值