详解MinIO的大文件分片上传、断点续传和秒传

1,388 阅读4分钟

文件分片策略:大文件分片,每个分片5MB,小文件不分片,大小文件判断标准是10MB。分片处理流程是,前端上传前判断文件大小,根据文件大小决定是否分片,分片上传时使用线程池提高效率。

断点续传、秒传:创建MySQL文件记录表(id,MD5指纹,文件路径,文件大小,状态(0未完成,1已完成))。分片索引列表用Redis的Hash存储,key是前缀加MD5指纹,field是分片序号,value是状态(0未上传,1已上传)。前端上传前请求 /upload/check 接口,携带MD5文件指纹,小文件是整个文件计算MD5,大文件是取文件头+中段+尾部的2MB数据拼接后计算MD5,后端检索MD5指纹查看分片索引列表的上传情况,如果没有,则前端需上传完整文件,如果有但是上传不完整,前端需上传缺失的分片,如果有且已完整上传,则触发秒传,直接上传成功。

以下是完整的Java后端实现方案:

一、数据库设计

文件记录表 (file_record)

CREATE TABLE file_record (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    file_md5 VARCHAR(32) NOT NULL COMMENT '文件指纹',
    file_path VARCHAR(255) NOT NULL COMMENT '存储路径',
    file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
    status TINYINT NOT NULL DEFAULT 0 COMMENT '0-未完成 1-已完成',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE INDEX idx_md5 (file_md5)
);

二、Redis存储设计

  • Key格式: file:chunks:{file_md5}
  • Value类型: Hash
    • Field: 分片序号 (从0开始)
    • Value: 0(未上传)/1(已上传)

三、核心实现逻辑

1. 文件检查接口 (/upload/check)

@PostMapping("/upload/check")
public Response checkFile(@RequestParam String fileMd5, 
                         @RequestParam long fileSize) {
    
    // 1. 检查数据库是否已存在完整文件
    FileRecord record = fileRecordRepo.findByMd5(fileMd5);
    if (record != null && record.getStatus() == 1) {
        return Response.success("秒传成功", Map.of("exist", true));
    }

    // 2. 检查Redis分片信息
    String redisKey = "file:chunks:" + fileMd5;
    Map<Object, Object> chunkMap = redisTemplate.opsForHash().entries(redisKey);
    
    boolean isLargeFile = fileSize >= 10 * 1024 * 1024; // 10MB判断
    
    // 3. 首次上传:初始化分片记录
    if (chunkMap.isEmpty()) {
        if (isLargeFile) {
            int totalChunks = (int) Math.ceil((double) fileSize / (5 * 1024 * 1024));
            Map<String, Integer> initMap = new HashMap<>();
            for (int i = 0; i < totalChunks; i++) {
                initMap.put(String.valueOf(i), 0);
            }
            redisTemplate.opsForHash().putAll(redisKey, initMap);
        }
        
        // 创建文件记录
        if (record == null) {
            record = new FileRecord();
            record.setFileMd5(fileMd5);
            record.setFileSize(fileSize);
            record.setStatus(0);
            fileRecordRepo.save(record);
        }
        
        return Response.success("需要上传", Map.of(
            "exist", false,
            "needUpload", true,
            "chunkMap", isLargeFile ? initMap : Collections.emptyMap()
        ));
    }

    // 4. 断点续传:返回缺失分片
    List<Integer> missingChunks = new ArrayList<>();
    for (Map.Entry<Object, Object> entry : chunkMap.entrySet()) {
        if (Integer.parseInt(entry.getValue().toString()) == 0) {
            missingChunks.add(Integer.parseInt(entry.getKey().toString()));
        }
    }
    
    return Response.success("继续上传", Map.of(
        "exist", false,
        "needUpload", true,
        "missingChunks", missingChunks
    ));
}

2. 分片上传接口 (/upload/chunk)

@PostMapping("/upload/chunk")
public Response uploadChunk(@RequestParam String fileMd5,
                           @RequestParam int chunkIndex,
                           @RequestParam MultipartFile chunk) {
    
    // 1. 验证分片有效性
    if (chunk.isEmpty()) {
        throw new BusinessException("分片不能为空");
    }

    // 2. 获取Redis记录
    String redisKey = "file:chunks:" + fileMd5;
    Integer status = (Integer) redisTemplate.opsForHash().get(redisKey, String.valueOf(chunkIndex));
    if (status != null && status == 1) {
        return Response.success("分片已存在");
    }

    // 3. 存储分片到临时目录
    String tempDir = "/tmp/uploads/" + fileMd5 + "/";
    Path chunkPath = Paths.get(tempDir, chunkIndex + ".tmp");
    try {
        Files.createDirectories(chunkPath.getParent());
        chunk.transferTo(chunkPath);
    } catch (IOException e) {
        throw new BusinessException("分片保存失败");
    }

    // 4. 更新Redis状态
    redisTemplate.opsForHash().put(redisKey, String.valueOf(chunkIndex), 1);

    // 5. 检查是否全部完成
    if (checkAllChunksUploaded(redisKey)) {
        asyncMergeFile(fileMd5); // 异步合并文件
    }

    return Response.success("分片上传成功");
}

private boolean checkAllChunksUploaded(String redisKey) {
    Map<Object, Object> chunkMap = redisTemplate.opsForHash().entries(redisKey);
    return chunkMap.values().stream().allMatch(status -> status.equals(1));
}

3. 文件合并逻辑

@Async
public void asyncMergeFile(String fileMd5) {
    // 1. 获取文件记录
    FileRecord record = fileRecordRepo.findByMd5(fileMd5);
    
    // 2. 获取所有分片
    String tempDir = "/tmp/uploads/" + fileMd5 + "/";
    File[] chunks = new File(tempDir).listFiles();
    Arrays.sort(chunks, Comparator.comparingInt(f -> Integer.parseInt(f.getName().split("\\.")[0])));
    
    // 3. 合并文件
    String finalPath = "/storage/" + LocalDate.now() + "/" + UUID.randomUUID() + ".mp4";
    try (FileOutputStream fos = new FileOutputStream(finalPath)) {
        for (File chunk : chunks) {
            Files.copy(chunk.toPath(), fos);
        }
    } catch (IOException e) {
        throw new BusinessException("文件合并失败");
    }
    
    // 4. 更新数据库
    record.setFilePath(finalPath);
    record.setStatus(1);
    fileRecordRepo.save(record);
    
    // 5. 清理临时文件
    FileUtils.deleteDirectory(new File(tempDir));
    redisTemplate.delete("file:chunks:" + fileMd5);
}

四、秒传指纹计算(前端示例)

async function calcFileFingerprint(file) {
  const chunkSize = 2 * 1024 * 1024; // 2MB
  const chunks = [];
  
  if (file.size <= 10 * 1024 * 1024) {
    // 小文件:全量计算
    return await calcMD5(file);
  }
  
  // 大文件:取头中尾三部分
  const offsets = [
    0, // 头部
    Math.floor(file.size / 2) - chunkSize / 2, // 中部
    file.size - chunkSize // 尾部
  ];
  
  for (const offset of offsets) {
    const chunk = file.slice(offset, offset + chunkSize);
    chunks.push(await readChunk(chunk));
  }
  
  return await calcMD5(new Blob(chunks));
}

五、线程池配置

@Configuration
public class UploadConfig {
    
    @Bean("uploadThreadPool")
    public Executor uploadThreadPool() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("upload-");
        executor.initialize();
        return executor;
    }
}

六、关键优化点

  1. 分片并发上传

    // 在Controller中使用异步处理
    @PostMapping("/upload/chunk")
    @Async("uploadThreadPool")
    public Future<Response> uploadChunkAsync(...) { ... }
    
  2. 断点续传优化

    • Redis记录分片状态时设置过期时间(如24小时)
    • 添加分片校验机制(MD5验证分片完整性)
  3. 错误处理

    • 分片上传失败时重置Redis状态
    • 合并失败时回滚操作
  4. 安全控制

    • 限制单个文件最大分片数量
    • 验证分片序号有效性

七、系统架构图

+--------+       +---------+       +------------+       +----------+
| 客户端  | ----> | Nginx   | ----> | 上传服务    | ----> | 文件存储  |
+--------+       +---------+       +-----+------+       +----------+
                              |          |
                              |          v
                              |     +----------+
                              +---> | Redis    |
                              |     +----------+
                              |
                              v
                         +------------+
                         | MySQL     |
                         +------------+

该方案实现了:

  1. 智能分片:根据10MB阈值自动选择分片策略
  2. 秒传功能:通过文件指纹快速匹配
  3. 断点续传:Redis记录分片状态
  4. 高效并发:线程池处理分片上传
  5. 自动合并:后台异步合并文件分片

实际部署时需注意:

  • 设置合理的临时文件清理策略
  • 增加文件校验机制防止数据损坏
  • 对大文件合并操作添加熔断保护
  • 分布式环境下需使用分布式锁控制合并操作