夯爆了的大文件分片上传、断点续传、秒传技术方案:Redis 分片状态管理与 MinIO 存储实践
·
企业级大文件分片上传完整后端技术方案设计(基于 Redis + MinIO)
方案简介
本方案专为企业级大文件(视频、音频、安装包等)上传场景设计,基于 Spring Boot + Redis + MinIO 实现分片上传、断点续传、秒传、自动合并、自动清理一体化能力。核心解决:网络不稳定导致上传失败、大文件占用服务器资源、重复上传浪费带宽、上传中断必须重传等问题。
整体流程:前端切片 → Redis 记录分片状态 → MinIO 存储分片 → 后端校验合并 → 清理临时资源
前后端业务流程梳理
1. 上传准备与秒传判断
- 前端选择文件,计算整个文件 MD5(唯一标识)
- 按固定大小(默认 5MB)切割文件,计算每个分片 MD5
- 调用检查接口,传入:文件总 MD5、分片索引、分片 MD5
- 后端查询 Redis:
- 分片已存在且 MD5 一致 → 前端跳过上传
- 分片不存在 → 执行上传
2. 分片上传流程
- 前端上传分片 + 元数据(总 MD5、索引、起止位置、总分片数等)
- 后端接收分片,直接上传至 MinIO(
chunks/目录) - 后端将分片信息存入 Redis Hash 结构:
- 分片 MD5、存储路径、起止位置
- 总分片数、总文件大小(仅 0 号分片记录)
上传成功返回结果,支持并发分片上传
3. 合并前校验流程
- 前端所有分片上传完成,请求合并接口
- 后端执行强校验:
- 校验总分片数是否完整
- 校验所有分片 MD5 记录是否存在
- 校验所有分片在 MinIO 中真实存在
校验不通过 → 拒绝合并;校验通过 → 进入合并流程
4. 分片合并流程
- 按分片索引顺序(0、1、2…) 从 MinIO 读取分片
- 写入服务器本地临时文件,完成文件合并
- 将合并后的完整文件上传至 MinIO 正式目录(
merged/) - 记录完整文件路径到 Redis
- 自动删除 MinIO 分片
- 自动清理 Redis 临时分片数据
- 返回文件可访问路径
5. 异常与重试流程
- 上传中断:重传时仅上传缺失分片
- 合并失败:不清理数据,支持重新合并
- 网络波动:分片下载自带 3 次重试机制
- 重复文件:相同 MD5 直接返回已上传路径
技术架构
架构组成
- 前端:文件切片、MD5 计算、并发上传、断点续传控制
- 应用服务:Spring Boot(接口实现、校验、合并、业务逻辑)
- 状态存储:Redis(记录分片信息、文件元数据、上传状态)
- 文件存储:MinIO(存储分片文件 + 完整文件)
- 业务集成:MV / 音乐视频上传、大文件统一管理
核心技术栈
- 后端框架:Spring Boot
- 缓存中间件:Redis
- 对象存储:MinIO
- 校验算法:MD5(文件 + 分片双重校验)
- 业务适配:MV 视频、音频、大文件上传
Redis 存储设计
以文件总 MD5 作为 Redis Key,使用 Hash 结构存储所有信息:
|
Redis 字段 |
含义 |
|
|
分片 MD5 值 |
|
|
分片 MinIO 存储路径 |
|
|
分片在文件中的起止位置 |
|
|
文件总分片数 |
|
|
文件总大小 |
|
|
合并后完整文件路径 |
MinIO 存储结构
- 分片文件:
chunks/{分片MD5}_{分片索引} - 完整文件:
merged/{MD5前2位}/{MD5第3-4位}/{文件名}
核心功能实现
1. 分片检查(断点续传 / 秒传核心)
/**
* 检查当前分片是否已经成功上传(断点续传 + 秒传核心方法)
*
* @param fileMd5 整个文件的唯一MD5值(Redis的主Key)
* @param chunk 当前分片的索引序号(0、1、2...)
* @param chunkMd5 当前分片的MD5值(用于校验一致性)
* @return boolean true=分片已存在且MD5一致(可跳过上传);false=分片不存在或MD5不匹配(需要上传)
*/
@Override
public boolean check(String fileMd5, String chunk, String chunkMd5) {
// 1. 获取该文件在Redis中存储的所有分片信息(仅用于调试日志,不影响逻辑)
Map<Object, Object> allEntries = redisTemplate.opsForHash().entries(fileMd5);
// 2. 从Redis中获取当前分片对应的MD5值
// Redis Key = 文件MD5
// Redis Field = chunk_md5_0、chunk_md5_1...
Object o = redisTemplate.opsForHash().get(fileMd5, REDIS_CHUNK_MD5_PREFIX + chunk);
// 3. 如果Redis中没有这条记录 → 表示分片未上传 → 返回false
if (o == null) {
return false;
}
// 4. 清理Redis中存储的MD5字符串(去除可能存在的双引号、空格)
String storedChunkMd5 = o.toString().replace("\"", "").trim();
// 5. 比对前端传入的分片MD5与Redis存储的MD5是否一致
// 一致 → 返回true(分片已上传)
// 不一致 → 返回false(需要重新上传)
return chunkMd5.equals(storedChunkMd5);
}
2. 分片上传
/**
* 单独分片上传核心方法
* 将单个分片文件上传到 MinIO,并将分片元信息存入 Redis
*
* @param md5Value 整个大文件的唯一MD5值(作为Redis的Key)
* @param chunkIndex 当前分片的索引(从 0 开始,0、1、2...)
* @param start 分片在原文件中的起始字节位置
* @param end 分片在原文件中的结束字节位置
* @param chunks 大文件总分片数量
* @param fileSize 大文件总大小(字节)
* @param chunkMd5 当前分片文件的MD5值
* @param multipartFile 前端上传的分片文件对象
* @return 上传结果提示字符串
* @throws IOException 上传或IO异常时抛出
*/
@Override
public String upload(String md5Value, String chunkIndex, String start, String end,
String chunks, String fileSize, String chunkMd5, MultipartFile multipartFile) throws IOException {
// 1. 构建分片在 MinIO 中的存储路径:chunks/分片MD5_分片索引
String chunkPath = MINIO_CHUNKS_PREFIX + chunkMd5 + "_" + chunkIndex;
// 2. 调用 MinIO 服务,上传分片文件,并获取文件的访问路径
String storedPath = minIoService.uploadChunkFile(multipartFile, chunkPath);
// 3. 将分片的关键信息存入 Redis(Hash结构)
// 存储分片在 MinIO 的访问路径
redisTemplate.opsForHash().put(md5Value, REDIS_CHUNK_LOCATION_PREFIX + chunkIndex, storedPath);
// 存储分片在原文件中的起止位置
redisTemplate.opsForHash().put(md5Value, REDIS_CHUNK_START_END_PREFIX + chunkIndex, start + "_" + end);
// 存储当前分片的 MD5 值,用于校验和断点续传
redisTemplate.opsForHash().put(md5Value, REDIS_CHUNK_MD5_PREFIX + chunkIndex, chunkMd5);
// 4. 仅当上传【第一个分片(索引0)】时,记录整个文件的基础信息
// 避免重复写入,保证文件总信息只存一份
if ("0".equals(chunkIndex)) {
// 存储文件总大小
redisTemplate.opsForHash().put(md5Value, REDIS_FILE_SIZE, fileSize);
// 存储文件总分片数
redisTemplate.opsForHash().put(md5Value, REDIS_FILE_CHUNKS, chunks);
}
// 5. 返回上传成功标识
return "分片上传成功";
}
3. 合并前预检
/**
* 合并分片之前的【完整性预检】
* 作用:确保所有分片都已上传、Redis信息完整、MinIO文件真实存在,只有全部满足才能合并
*
* @param key 文件的MD5值(Redis的key)
* @return true=所有分片完整可合并;false=缺少分片或文件异常
*/
private boolean checkBeforeMerge(String key) {
// 1. 获取当前文件在Redis中存储的【所有分片元数据】(Hash结构全部键值对)
Map map = redisTemplate.opsForHash().entries(key);
// 2. 获取【文件总分片数】,如果没有记录说明上传未初始化,直接返回false
Object file_chunks = map.get(REDIS_FILE_CHUNKS);
if (file_chunks == null) {
return false;
}
// 3. 将总分片数转为int,作为最终校验标准
int totalChunks = Integer.parseInt(file_chunks.toString());
// 4. 定义三个校验计数器
int md5Count = 0; // 已记录MD5的分片数量
int locationCount = 0; // 已记录存储路径的分片数量
int fileExistsCount = 0; // MinIO中【真实存在】的分片文件数量
// 5. 遍历Redis中所有的分片信息,逐项校验
for (Object hashKey : map.keySet()) {
String keyStr = hashKey.toString();
// 统计:有多少个分片保存了MD5
if (keyStr.startsWith(REDIS_CHUNK_MD5_PREFIX)) {
md5Count++;
}
// 统计:有多少个分片保存了路径,并校验MinIO文件是否真实存在
if (keyStr.startsWith(REDIS_CHUNK_LOCATION_PREFIX)) {
locationCount++;
// 获取分片存储路径
String chunkPath = map.get(hashKey).toString();
// 从URL中提取MinIO真实对象名
String objectName = extractObjectNameFromUrl(chunkPath);
// 校验:分片在MinIO真实存在才计数
if (minIoService.isObjectExists(objectName)) {
fileExistsCount++;
}
}
}
// 6. 最终严格校验:三项必须全部等于【总分片数】才允许合并
// 1) 所有分片都有MD5
// 2) 所有分片都有存储路径
// 3) 所有分片在MinIO上真实存在
return totalChunks == md5Count
&& totalChunks == locationCount
&& totalChunks == fileExistsCount;
}
4. 分片合并(完整逻辑)
/**
* 合并所有分片,生成完整文件(核心方法)
* 1. 先校验所有分片是否齐全
* 2. 按顺序从 MinIO 读取分片
* 3. 写入本地临时文件完成合并
* 4. 上传合并后的完整文件到 MinIO 正式目录
* 5. 记录文件路径并清理临时分片数据
*
* @param md5Value 大文件整体 MD5(唯一标识)
* @param originalFilename 原始文件名
* @return 合并后文件在 MinIO 的存储路径
* @throws IOException 文件读取、写入、上传异常
*/
@Override
public String merge(String md5Value, String originalFilename) throws IOException {
// ===================== 1. 合并前校验 =====================
// 校验所有分片是否都已上传、数据是否完整
boolean flag = this.checkBeforeMerge(md5Value);
if (!flag) {
// 校验不通过,抛出异常,禁止合并
throw new IOException("切片未完全上传");
}
// ===================== 2. 秒传复用判断 =====================
// 检查该文件是否已经合并过(相同 MD5 直接返回已存在路径)
Object file_location = redisTemplate.opsForHash().get(md5Value, REDIS_FILE_LOCATION);
if (file_location != null) {
return file_location.toString();
}
// ===================== 3. 初始化合并环境 =====================
// 获取总分片数
int file_chunks = Integer.parseInt(redisTemplate.opsForHash().get(md5Value, REDIS_FILE_CHUNKS).toString());
// 创建服务器本地临时文件,用于接收所有分片数据
File tempFile = File.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX);
OutputStream outputStream = new FileOutputStream(tempFile);
// ===================== 4. 按顺序合并所有分片 =====================
// 必须按 0、1、2… 顺序读取,否则文件损坏
for (int i = 0; i < file_chunks; i++) {
// 从 Redis 获取当前分片的存储路径
String redisKey = REDIS_CHUNK_LOCATION_PREFIX + i;
Object chunkPathObj = redisTemplate.opsForHash().get(md5Value, redisKey);
String chunkPath = chunkPathObj.toString();
// 从路径中解析出 MinIO 真实对象名称
String objectName = extractObjectNameFromUrl(chunkPath);
// 从 MinIO 获取分片文件流
InputStream inputStream = minIoService.getObjectStream(objectName);
// 缓冲区 1MB,提高读写效率
byte[] bytes = new byte[1024 * 1024];
int len;
// 读取分片数据并写入本地临时文件
while ((len = inputStream.read(bytes)) != -1) {
outputStream.write(bytes, 0, len);
}
// 关闭当前分片流
inputStream.close();
}
// 所有分片写入完成,关闭文件输出流
outputStream.close();
// ===================== 5. 上传合并后的完整文件 =====================
// 构建正式文件存储目录:按 MD5 前 4 位分散目录,避免单目录文件过多
String fileDir = MINIO_MERGED_PREFIX + md5Value.substring(0, 2) + "/" + md5Value.substring(2, 4) + "/";
String filePath = fileDir + originalFilename;
// 将本地临时文件上传到 MinIO
try (FileInputStream tempInputStream = new FileInputStream(tempFile)) {
filePath = minIoService.uploadFileStream(tempInputStream, filePath, FILE_CONTENT_TYPE);
}
// ===================== 6. 记录信息并清理 =====================
// 把合并后的完整文件路径存入 Redis,用于秒传与下载
redisTemplate.opsForHash().put(md5Value, REDIS_FILE_LOCATION, filePath);
// 删除本地临时文件
tempFile.delete();
// 删除 MinIO 中的分片文件 + 清理 Redis 临时数据
delTmpFile(md5Value);
// 返回最终文件路径
return filePath;
}
5. 自动清理临时资源
/**
* 清理临时文件与Redis临时数据(合并成功后调用)
* 功能:删除MinIO里的分片文件 + 删除Redis里的分片元数据,释放存储空间
*
* @param md5Value 文件整体MD5值(Redis的key)
* @throws IOException IO异常
*/
private void delTmpFile(String md5Value) throws IOException {
// 1. 获取当前文件在Redis中存储的所有Hash数据
Map map = redisTemplate.opsForHash().entries(md5Value);
// 2. 定义两个集合
List<String> list = new ArrayList<>(); // 存放需要从Redis删除的key
List<String> chunkPaths = new ArrayList<>(); // 存放需要从MinIO删除的分片路径
// 3. 遍历Redis所有字段,分类收集需要删除的数据
for (Object hashKey : map.keySet()) {
String key = hashKey.toString();
// 处理:分片存储路径 → 收集起来用于删除MinIO文件
if (key.startsWith(REDIS_CHUNK_LOCATION_PREFIX)) {
String chunkPath = map.get(hashKey).toString();
// 从URL中提取MinIO真实对象名
String realObjectName = extractObjectNameFromUrl(chunkPath);
chunkPaths.add(realObjectName);
// 同时把这个key加入待删除列表
list.add(key);
}
// 处理:分片起止位置信息 → 加入Redis删除列表
if (key.startsWith(REDIS_CHUNK_START_END_PREFIX)) {
list.add(key);
}
// 处理:分片MD5信息 → 加入Redis删除列表
if (key.startsWith(REDIS_CHUNK_MD5_PREFIX)) {
list.add(key);
}
}
// 4. 批量删除MinIO中的分片文件(如果有分片需要删除)
if (!chunkPaths.isEmpty()) {
minIoService.batchDeleteFiles(chunkPaths);
}
// 5. 把文件基本信息(总分片数、总大小)也加入删除列表
list.add(REDIS_FILE_CHUNKS);
list.add(REDIS_FILE_SIZE);
// 6. 批量删除Redis中所有临时数据
redisTemplate.opsForHash().delete(md5Value, list.toArray());
}
6. 统一大文件上传接口(支持上传 / 合并自动判断)
/**
* 大文件统一上传入口(支持分片上传 + 自动合并)
* 前端通过 operation 字段控制操作类型:
* upload = 上传分片
* merge = 合并分片
*
* @param fileUploadDTO 前端传递的所有文件上传参数(MD5、分片索引、文件、操作类型等)
* @return 上传/合并结果信息
*/
@Override
public String uploadLargeFile(FileUploadDTO fileUploadDTO) {
// 获取文件唯一标识(整个大文件的MD5)
String md5Value = fileUploadDTO.getMd5Value();
// 获取当前分片索引(0、1、2...)
String chunk = fileUploadDTO.getChunk();
// 获取操作类型:upload-上传分片 / merge-合并文件
String operation = fileUploadDTO.getOperation();
// ======================================
// 操作一:执行【分片上传】
// ======================================
if (OPERATION_UPLOAD.equals(operation)) {
// 先检查分片是否已经上传过(断点续传)
boolean exists = check(md5Value, chunk, fileUploadDTO.getChunkMd5());
// 已上传 → 直接返回,无需重复上传
if (exists) {
return "分片已存在";
}
// 未上传 → 调用真正的分片上传方法保存到 MinIO 和 Redis
return upload(
md5Value,
chunk,
fileUploadDTO.getStart(),
fileUploadDTO.getEnd(),
fileUploadDTO.getChunks(),
fileUploadDTO.getSize(),
fileUploadDTO.getChunkMd5(),
fileUploadDTO.getFile()
);
}
// ======================================
// 操作二:执行【分片合并】
// ======================================
if (OPERATION_MERGE.equals(operation)) {
// 调用合并方法,将所有分片合成完整文件
return merge(md5Value, fileUploadDTO.getOriginalFilename());
}
// 未知操作,默认返回成功
return "操作成功";
}
优化特性
- 秒传机制:相同 MD5 文件直接返回路径,无需上传
- 断点续传:网络中断后仅上传缺失分片
- 幂等上传:重复上传同一分片自动跳过
- 合并重试:合并失败不清理数据,支持重试
- 智能路径解析:兼容任意域名 / 路径,无硬编码
- 高并发支持:前端控制并发数,服务端无压力
- 自动垃圾回收:合并完成自动删除分片、清理 Redis
适用场景
- MV / 短视频 / 高清视频上传
- 音频、专辑、歌曲文件上传
- 大文件安装包、压缩包上传
- 企业网盘、素材库、媒体库
- 所有需要稳定、高效、断点续传的大文件上传场景
更多推荐




所有评论(0)