一:业务流程
1、上传准备与秒传判断
- 前端选择文件,计算整个文件 MD5(唯一标识)
- 按固定大小(默认 5MB)切割文件,计算每个分片 MD5
- 调用检查接口,传入:文件总 MD5、分片索引、分片 MD5
- 后端查询 Redis:
- 查询到了 → 前端跳过上传
- 未查询到(可能过期了)→ 从数据库查询然后重新写入Redis
- 数据库也未查到→ 执行上传
2、分片上传流程
- 前端上传分片 + 元数据(总 MD5、索引、起止位置、总分片数等)
- 后端接收分片,直接上传至 MinIO(
chunks/目录) - 后端将分片信息存入 Redis Hash 结构:
- 分片 MD5、存储路径、起止位置
- 总分片数、总文件大小(仅 0 号分片记录)
3、合并前校验
- 前端所有分片上传完成,请求合并接口
- 后端执行强校验:
- 校验总分片数是否完整
- 校验所有分片 MD5 记录是否存在
校验不通过 → 拒绝合并;校验通过 → 进入合并流程
4、分片合并
- 按分片索引顺序(0、1、2...) 从 MinIO 读取分片
- 写入服务器本地临时文件,完成文件合并
- 将合并后的完整文件上传至 MinIO 正式目录(
merged/) - 记录完整文件路径到 Redis
- 自动删除 MinIO 分片
- 自动清理 Redis 临时分片数据
- 返回文件可访问路径
二:核心功能实现
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 / 短视频 / 高清视频上传
- 音频、专辑、歌曲文件上传
- 大文件安装包、压缩包上传
- 企业网盘、素材库、媒体库
- 所有需要稳定、高效、断点续传的大文件上传场景