SpringBoot中基于Minio、Redis、Mysql实现大文件分片上传,断点续传,秒传功能

一:业务流程

1、上传准备与秒传判断

  1. 前端选择文件,计算整个文件 MD5(唯一标识)
  2. 按固定大小(默认 5MB)切割文件,计算每个分片 MD5
  3. 调用检查接口,传入:文件总 MD5、分片索引、分片 MD5
  4. 后端查询 Redis:
  • 查询到了 → 前端跳过上传
  • 未查询到(可能过期了)→ 从数据库查询然后重新写入Redis
  • 数据库也未查到→ 执行上传

2、分片上传流程

  1. 前端上传分片 + 元数据(总 MD5、索引、起止位置、总分片数等)
  2. 后端接收分片,直接上传至 MinIO(chunks/ 目录)
  3. 后端将分片信息存入 Redis Hash 结构:
  • 分片 MD5、存储路径、起止位置
  • 总分片数、总文件大小(仅 0 号分片记录)

3、合并前校验

  1. 前端所有分片上传完成,请求合并接口
  2. 后端执行强校验:
  • 校验总分片数是否完整
  • 校验所有分片 MD5 记录是否存在

校验不通过 → 拒绝合并;校验通过 → 进入合并流程

4、分片合并

  1. 分片索引顺序(0、1、2...) 从 MinIO 读取分片
  2. 写入服务器本地临时文件,完成文件合并
  3. 将合并后的完整文件上传至 MinIO 正式目录(merged/
  4. 记录完整文件路径到 Redis
  5. 自动删除 MinIO 分片
  6. 自动清理 Redis 临时分片数据
  7. 返回文件可访问路径

二:核心功能实现

1、实体类

java 复制代码
import lombok.Data;

/**
 * 分片检查请求 DTO ------ POST /file/check 的 @RequestBody
 *
 * 用于秒传判断和断点续传判断,三个字段作用说明:
 * 1. fileMd5:用于秒传,判断完整文件是否已存在
 * 2. chunk:当前分片序号
 * 3. chunkMd5:用于断点续传,校验当前分片是否已上传且未损坏
 */
@Data
public class ChunkCheckDTO {
    /** 文件整体 MD5 值,作为 Redis key 和秒传索引 */
    private String fileMd5;
    /** 当前分片序号,从 0 开始计数 */
    private String chunk;
    /** 当前分片的 MD5 值,用于断点续传时校验分片完整性 */
    private String chunkMd5;
}

/**
 * 分片上传请求 DTO ------ POST /file/upload 的 metadata 部分
 *
 * 通过 @RequestPart("metadata") 以 JSON 格式传递,与二进制分片文件分离。
 * 全部使用 String 类型以适配前端 WebUploader 的默认字段格式。
 */
@Data
public class ChunkUploadDTO {
    /** 文件整体 MD5 值(Redis key) */
    private String md5Value;
    /** 当前分片序号,从 0 开始 */
    private String chunk;
    /** 分片起始字节偏移 */
    private String start;
    /** 分片结束字节偏移 */
    private String end;
    /** 总分片数量 */
    private String chunks;
    /** 文件总大小(字节),仅在首个分片时使用 */
    private String size;
    /** 当前分片的 MD5 值(MinIO 对象名 + 断点续传校验) */
    private String chunkMd5;
}

/**
 * 合并分片请求 DTO ------ POST /file/merge 的 @RequestBody
 *
 * 所有分片上传完成后由前端调用,或由最后一片分片上传自动触发。
 */
@Data
public class MergeRequestDTO {
    /** 文件整体 MD5 值(Redis key + MySQL 唯一索引) */
    private String md5Value;
    /** 原始文件名,用于合并后的 MinIO 对象命名 */
    private String originalFilename;
}

/**
 * 上传文件记录实体 ------ 秒传的 MySQL 持久层
 *
 * 映射 upload_file 表,合并完成后写入,作为秒传的永久记录。
 * 当 Redis 30天有效期过期后,/check 接口会通过 file_md5 回退查询此表,
 * 命中后回写 Redis,恢复秒传能力。
 *
 * file_md5 是唯一索引,同一文件仅保存一条记录。
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("upload_file")
public class UploadFile {
    @TableId(type = IdType.AUTO)
    private Long id;

    /** 文件 MD5 值(唯一索引,秒传匹配键) */
    private String fileMd5;

    /** 原始文件名 */
    private String fileName;

    /** MinIO 存储路径(merged/ 目录下) */
    private String filePath;

    /** 文件大小(字节),合并后从临时文件获取实际大小 */
    private Long fileSize;

    /** 合并完成时间 */
    private LocalDateTime createTime;
}

2、分片检查(秒传/断点续传)

java 复制代码
    /**
     * 分片检查 ------ 秒传 + 断点续传的唯一入口
     *
     * 三级决策链路(优先级从高到低):
     * 1. 秒传(Redis):查询到文件路径则直接返回,跳过全部上传流程
     * 2. 秒传(MySQL 回退):Redis未命中时查询数据库,查到记录则回写Redis(30天有效期)并返回路径,
     *    避免Redis过期导致秒传失效
     * 3. 断点续传:当前分片已上传且MD5匹配,返回空串,前端跳过该分片
     * 4. 需上传:返回null,前端正常上传当前分片
     *
     * @param dto 检查参数(文件MD5、分片序号、分片MD5)
     * @return 文件路径代表秒传命中,空串代表分片可跳过,null代表需要上传
     */
    @ApiOperation("分片检查(秒传/断点续传)")
    @PostMapping("/check")
    public Result<String> check(@RequestBody ChunkCheckDTO dto) {
        return Result.ok(fileService.check(dto.getFileMd5(), dto.getChunk(), dto.getChunkMd5()));
    }

    @Override
    public String check(String fileMd5, String chunk, String chunkMd5) {
        // 秒传第一级:Redis 中查找完整文件记录
        Object mergedPath = redisTemplate.opsForHash().get(fileMd5, REDIS_FILE_LOCATION);
        if (mergedPath != null) {
            log.info("秒传命中(Redis): fileMd5={}, path={}", fileMd5, mergedPath);
            return mergedPath.toString();
        }

        // 秒传第二级:Redis 过期后回退 MySQL,命中则回写 Redis 恢复秒传缓存
        UploadFile uploadFile = uploadFileMapper.selectOne(
                new QueryWrapper<UploadFile>().eq("file_md5", fileMd5));
        if (uploadFile != null) {
            redisTemplate.opsForHash().put(fileMd5, REDIS_FILE_LOCATION, uploadFile.getFilePath());
            redisTemplate.expire(fileMd5, TTL_MERGED_DAYS, java.util.concurrent.TimeUnit.DAYS);
            log.info("秒传命中(MySQL回退): fileMd5={}, path={}", fileMd5, uploadFile.getFilePath());
            return uploadFile.getFilePath();
        }

        // 断点续传:校验当前分片是否已上传且 MD5 匹配
        // 去掉引号和空格后再比较,兼容 Redis 存储的值可能带有额外格式
        Object storedMd5 = redisTemplate.opsForHash().get(fileMd5, REDIS_CHUNK_MD5_PREFIX + chunk);
        if (storedMd5 != null && chunkMd5.equals(storedMd5.toString().replace("\"", "").trim())) {
            return "";
        }

        // 需要上传
        return null;
    }

3、分片上传

java 复制代码
    /**
     * 分片上传 ------ 仅执行上传操作,不做业务判断
     *
     * 调用前必须通过 /check 接口确认当前分片需要上传,本方法不再重复校验秒传、断点续传逻辑,
     * 只负责文件上传与分片状态写入 Redis。
     *
     * Redis 存储内容:
     * chunk_location_{chunk}:分片在 MinIO 的存储路径
     * chunk_md5_{chunk}:分片MD5,用于断点续传校验
     * chunk_start_end_{chunk}:分片字节范围
     * 首分片(chunk=0)额外记录文件大小与总分片数
     *
     * 每次上传都会刷新对应Redis键24小时有效期,避免上传中断产生冗余数据。
     *
     * @param dto 分片元数据,通过@RequestPart的metadata字段以JSON形式传递
     * @param file 分片二进制文件
     * @return MinIO存储路径
     */
    @ApiOperation("分片上传")
    @PostMapping("/upload")
    public Result<String> upload(@RequestPart("metadata") ChunkUploadDTO dto,
                                 @RequestPart("file") MultipartFile file) {
        try {
            return Result.ok(fileService.upload(
                    dto.getMd5Value(), dto.getChunk(), dto.getStart(), dto.getEnd(),
                    dto.getChunks(), dto.getSize(), dto.getChunkMd5(), file));
        } catch (IOException e) {
            log.error("分片上传失败", e);
            return Result.fail(e.getMessage());
        }
    }

@Override
    public String upload(String md5Value, String chunk, String start, String end,
                         String chunks, String fileSize, String chunkMd5, MultipartFile file) throws IOException {
        // 上传分片到 MinIO,对象名格式:chunks/{chunkMd5}_{chunk序号}
        String objectName = MINIO_CHUNKS_PREFIX + chunkMd5 + "_" + chunk;
        String storedPath = minIoService.uploadChunkFile(file, objectName);

        // 在 Redis Hash 中记录分片状态(位置 + MD5 + 字节范围)
        redisTemplate.opsForHash().put(md5Value, REDIS_CHUNK_LOCATION_PREFIX + chunk, storedPath);
        redisTemplate.opsForHash().put(md5Value, REDIS_CHUNK_MD5_PREFIX + chunk, chunkMd5);
        redisTemplate.opsForHash().put(md5Value, REDIS_CHUNK_START_END_PREFIX + chunk, start + "_" + end);

        // 仅首个分片写入文件元信息,避免每次上传分片都重复覆盖
        if ("0".equals(chunk)) {
            redisTemplate.opsForHash().put(md5Value, REDIS_FILE_SIZE, fileSize);
            redisTemplate.opsForHash().put(md5Value, REDIS_FILE_CHUNKS, chunks);
        }

        // 每次上传刷新 24h TTL,防止中断上传后数据泄漏
        redisTemplate.expire(md5Value, TTL_UPLOAD_HOURS, java.util.concurrent.TimeUnit.HOURS);

        log.info("分片上传成功: md5Value={}, chunk={}, path={}", md5Value, chunk, storedPath);
        return storedPath;
    }

4、分片合并

java 复制代码
    /**
     * 合并分片 ------ 将已上传的全部分片合并为完整文件
     *
     * 合并流程:
     * 1. 秒传检查:Redis 查询 file_location,命中则直接返回,避免重复合并
     * 2. 完整性校验:通过 Redis 校验所有分片是否已上传完成,不调用 MinIO
     * 3. 流式合并:按分片序号从 MinIO 顺序读取,通过 transferTo 零拷贝写入本地临时文件
     * 4. 上传合并文件到 MinIO:路径格式为 merged/{md5前2位}/{md5第3-4位}/{原始文件名}
     * 5. Redis 持久化:写入 file_location 并设置 30 天有效期,作为秒传缓存
     * 6. MySQL 持久化:写入 upload_file 表,作为秒传永久记录,Redis 过期后的兜底查询
     * 7. 清理临时数据:删除 MinIO 分片文件和 Redis 临时字段
     *
     * 断点续传说明:必须等所有分片上传完成后才能调用,支持前端显式调用或最后一片自动触发
     *
     * @param dto 合并参数,包含文件MD5、原始文件名
     * @return 合并后文件在 MinIO 的存储路径
     */
    @ApiOperation("合并分片")
    @PostMapping("/merge")
    public Result<String> merge(@RequestBody MergeRequestDTO dto) {
        try {
            return Result.ok(fileService.merge(dto.getMd5Value(), dto.getOriginalFilename()));
        } catch (IOException e) {
            log.error("合并分片失败", e);
            return Result.fail(e.getMessage());
        }
    }

    @Override
    public String merge(String md5Value, String originalFilename) throws IOException {
        // 步骤1:秒传优先 ------ 完整文件已存在则直接返回
        Object existingPath = redisTemplate.opsForHash().get(md5Value, REDIS_FILE_LOCATION);
        if (existingPath != null) {
            log.info("秒传命中,文件已合并: md5Value={}, path={}", md5Value, existingPath);
            return existingPath.toString();
        }

        // 步骤2:校验分片完整性 ------ 纯 Redis 校验,不调 MinIO API
        if (!checkBeforeMerge(md5Value)) {
            throw new IOException("分片未完全上传,无法合并");
        }

        int totalChunks = Integer.parseInt(
                redisTemplate.opsForHash().get(md5Value, REDIS_FILE_CHUNKS).toString());

        // 步骤3:流式合并分片到本地临时文件
        File tempFile = File.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX);
        try {
            try (OutputStream os = new java.io.BufferedOutputStream(new FileOutputStream(tempFile))) {
                // 按分片序号顺序从 MinIO 下载并写入(保证文件字节正确顺序)
                for (int i = 0; i < totalChunks; i++) {
                    String chunkPath = redisTemplate.opsForHash()
                            .get(md5Value, REDIS_CHUNK_LOCATION_PREFIX + i).toString();
                    String objectName = extractObjectNameFromUrl(chunkPath);

                    try (InputStream is = minIoService.getObjectStream(objectName)) {
                        if (is == null) {
                            throw new IOException("分片 " + i + " 在MinIO中不存在: " + objectName);
                        }
                        // 零拷贝传输,避免大文件内存溢出
                        is.transferTo(os);
                    }
                }
            }

            // 步骤4:上传合并文件到 MinIO merged/ 目录
            // 路径散列: merged/{md5[0:2]}/{md5[2:4]}/{filename},防止单目录文件过多
            String fileDir = MINIO_MERGED_PREFIX + md5Value.substring(0, 2) + "/"
                    + md5Value.substring(2, 4) + "/";
            String filePath = fileDir + originalFilename;
            try (FileInputStream fis = new FileInputStream(tempFile)) {
                filePath = minIoService.uploadFileStream(fis, filePath, FILE_CONTENT_TYPE);
            }

            // 合并后从临时文件获取实际大小(最准确的尺寸来源)
            long fileSize = tempFile.length();

            // 步骤5:Redis 写入 file_location + 30天 TTL(秒传缓存层)
            redisTemplate.opsForHash().put(md5Value, REDIS_FILE_LOCATION, filePath);
            redisTemplate.expire(md5Value, TTL_MERGED_DAYS, java.util.concurrent.TimeUnit.DAYS);

            // 步骤6:MySQL 持久化(秒传永久兜底层)
            UploadFile uploadFile = new UploadFile();
            uploadFile.setFileMd5(md5Value);
            uploadFile.setFileName(originalFilename);
            uploadFile.setFilePath(filePath);
            uploadFile.setFileSize(fileSize);
            try {
                uploadFileMapper.insert(uploadFile);
            } catch (DuplicateKeyException e) {
                // 同一文件二次上传的场景,忽略唯一键冲突,不影响正常流程
                log.warn("文件已存在于MySQL: md5Value={}", md5Value);
            }

            // 步骤7:清理 MinIO 分片 + Redis 临时字段
            delTmpFile(md5Value);

            log.info("合并完成: md5Value={}, path={}, size={}", md5Value, filePath, fileSize);
            return filePath;
        } finally {
            // 无论成功失败,确保临时文件被删除
            tempFile.delete();
        }
    }

5、从 MinIO 返回的 URL 中提取纯对象路径

java 复制代码
    /**
     * 从 MinIO 返回的 URL 中提取纯对象路径
     *
     * MinIO 返回的路径可能是完整 HTTP URL,包含域名、桶名、签名参数等,
     * 后续下载操作需要纯对象路径,本方法统一提取 chunks/ 开头的部分。
     *
     * 处理的 URL 格式:
     * 1. 完整 URL:http://minio:9000/bucket/chunks/xxx?sign=yyy → chunks/xxx
     * 2. 带引号:"chunks/xxx" → chunks/xxx
     * 3. 纯路径:chunks/xxx → 直接返回
     * 4. 无 chunks/ 前缀:直接返回清理后的字符串
     *
     * @param url MinIO 返回的文件路径或完整 URL
     * @return 以 chunks/ 开头的纯对象路径,输入为空返回 null
     */
    private String extractObjectNameFromUrl(String url) {
        if (url == null || url.isEmpty()) {
            return null;
        }

        // 清理格式:去掉可能的双引号和首尾空格
        String cleanUrl = url.replace("\"", "").trim();

        try {
            // 完整 HTTP URL → 仅取路径部分(丢弃协议、域名、端口)
            if (cleanUrl.startsWith("http://") || cleanUrl.startsWith("https://")) {
                java.net.URL uri = new java.net.URL(cleanUrl);
                cleanUrl = uri.getPath();
            }

            // 以 chunks/ 为锚点截取,丢弃桶名等前缀
            int chunksIndex = cleanUrl.indexOf("chunks/");
            if (chunksIndex != -1) {
                return cleanUrl.substring(chunksIndex);
            }

        } catch (Exception e) {
            log.warn("URL解析异常,原始URL:{}", url, e);
        }

        // 兜底:未找到 chunks/ 则直接返回清理后的字符串
        return cleanUrl;
    }

6、清理合并后的临时数据

java 复制代码
 /**
     * 清理合并后的临时数据 ------ MinIO 分片 + Redis 临时字段
     *
     * 合并完成后调用,清理范围:
     * 1. MinIO:批量删除 chunks/ 目录下的所有分片文件
     * 2. Redis Hash:删除 chunk_location_*、chunk_md5_*、chunk_start_end_* 字段,
     *    同时删除 file_chunks 和 file_size
     *
     * 注意:不删除 file_location,该字段是秒传核心索引
     *
     * @param md5Value 文件 MD5,作为 Redis key
     * @throws IOException MinIO 批量删除失败时抛出
     */
    private void delTmpFile(String md5Value) throws IOException {
        Map map = redisTemplate.opsForHash().entries(md5Value);
        List<String> list = new ArrayList<>();
        List<String> chunkPaths = new ArrayList<>();

        // 遍历 Redis Hash 的所有字段,分类收集需要清理的键
        for (Object hashKey : map.keySet()) {
            if (hashKey.toString().startsWith(REDIS_CHUNK_LOCATION_PREFIX)) {
                // 分片路径 → 提取纯对象名后加入 MinIO 删除列表
                String chunkPath = map.get(hashKey).toString();
                String realObjectName = extractObjectNameFromUrl(chunkPath);
                chunkPaths.add(realObjectName);
                list.add(hashKey.toString());
            }
            if (hashKey.toString().startsWith(REDIS_CHUNK_START_END_PREFIX)) {
                list.add(hashKey.toString());
            }
            if (hashKey.toString().startsWith(REDIS_CHUNK_MD5_PREFIX)) {
                list.add(hashKey.toString());
            }
        }

        // 批量删除 MinIO 分片文件
        if (!chunkPaths.isEmpty()) {
            boolean flag = minIoService.batchDeleteFiles(chunkPaths);
            log.info("批量删除分片文件结果: " + flag);
        }

        // 追加元数据字段到 Redis 删除列表
        list.add(REDIS_FILE_CHUNKS);
        list.add(REDIS_FILE_SIZE);
        // 一次性删除 Redis Hash 中的多个字段
        redisTemplate.opsForHash().delete(md5Value, list.toArray());
    }

7、合并前校验

java 复制代码
    /**
     * 合并前校验 ------ 确认所有分片在 Redis 中均有有效记录
     *
     * 设计原则:仅通过 Redis 校验,不调用 MinIO。
     * Redis 是分片上传状态的可信数据源,只有上传成功才会记录路径,
     * 因此 Redis 存在记录即代表分片已存在于 MinIO,可避免多次 MinIO 调用的网络开销。
     *
     * 校验逻辑:
     * 1. 检查 file_chunks 是否存在
     * 2. 统计 chunk_location_* 数量是否等于总分片数
     *
     * @param md5Value 文件 MD5,作为 Redis key
     * @return true 所有分片已就绪可合并,false 分片不完整
     */
    private boolean checkBeforeMerge(String md5Value) {
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(md5Value);

        // 必须知道总分片数
        Object totalChunksObj = entries.get(REDIS_FILE_CHUNKS);
        if (totalChunksObj == null) {
            log.warn("缺少总分片数信息: md5Value={}", md5Value);
            return false;
        }

        int totalChunks = Integer.parseInt(totalChunksObj.toString());
        int validChunkCount = 0;

        // 统计 chunk_location_* 中有效记录的数量
        for (Object key : entries.keySet()) {
            if (key.toString().startsWith(REDIS_CHUNK_LOCATION_PREFIX)) {
                Object val = entries.get(key);
                if (val != null && !val.toString().isEmpty()) {
                    validChunkCount++;
                }
            }
        }

        boolean ready = validChunkCount == totalChunks;
        if (!ready) {
            log.warn("分片不完整: total={}, uploaded={}, md5Value={}",
                    totalChunks, validChunkCount, md5Value);
        }
        return ready;
    }

三:适用场景

  • MV / 短视频 / 高清视频上传
  • 音频、专辑、歌曲文件上传
  • 大文件安装包、压缩包上传
  • 企业网盘、素材库、媒体库
  • 所有需要稳定、高效、断点续传的大文件上传场景
相关推荐
Beginner x_u1 个月前
前端手动实现大文件分片上传调度层:分片计算、并发上传与断点续传
前端·状态模式·断点续传·大文件分片上传
鱼干~2 年前
【.net core使用minio大文件分片上传】.net core使用minio大文件分片上传以及断点续传、秒传思路
c#·.netcore·minio·大文件分片上传·minio分片上传