Spring Boot + MinIO 文件分片上传/断点续传/秒传
通过这篇文章你将了解到 Spring Boot + Minio 如何实现文件分片上传、断点续传、秒传功能、失败重试、并发控制、进度显示等功能,包括设计原理、流程说明、代码示例、注意事项、扩展点、优化点等。

目录
前言:
在文件上传场景中,你是否会遇到这些问题:文件太大,几GB甚至几十GB,上传需要花费好几个小时;眼看着已经上传百分之九十九了,结果由于网络波动/页面关闭/系统退出等问题导致上传失败了;可能已经上传了某个文件,但你不知道,又重新上传了一遍,小文件还好,大文件那又得花好长时间。这些问题严重影响着用户体验,所以就有了 分片上传、断点续传、秒传 等设计。
1 流程设计
1.1 分片上传

如上图所示,文件分片上传的详细流程如下:
- 1、用户选择文件。
- 2、前端计算文件的 md5 值。md5 值可用于验证文件的完整性,前后端用相同的 md5 对称加密算法对同一文件进行加密,然后比较各自产生的 md5 值是否相等,若想等则说明文件未被篡改,反之亦然。注意,只有改变文件内容才能改变 md5 值,改变文件名不会改变 md5 值。
- 3、前端调用 初始化上传接口。该接口的主要作用是根据 md5 值判断文件是否已上传,或已上传了部分。
- 4、在 初始化上传接口 中,根据 md5 尝试从 redis 中获取文件 URL,若不为空则直接返回给前端;若为空,则分两种情况:一种是该文件从未被上传过,则创建上传任务(如生成任务唯一标识
uploadId、根据文件大小与分片大小计算总分片数等)并返回给前端;二是该文件被上传过,但只上传了部分属于断点续传逻辑(这一小节只说明正常的分片上传逻辑,断点续传见下小节)。 - 5、前端判断 URL 是否为空。若不为空,则说明文件已上传,直接显示上传成功即可(实际上这就是秒传的逻辑);若为空则走正常分片上传逻辑或断点续传逻辑。
- 6、前端根据后端返回的分片大小和总分片数将文件分片,并调用 上传分片接口 逐个上传分片。
- 7、在 上传分片接口 中,后端将分片上传至文件服务的临时目录下,然后将 redis 中这个分片的状态更新为已上传并记录分片索引
chunkIndex。 - 8、每个分片上传成功后,前端需要更新上传进度条。
- 9、所有分片都上传完成后,调用 完成上传/合并请求接口 。
- 10、在 完成上传/合并请求接口 接口中,将所有分片合并为原始文件并保存至最终目录下(目标目录),删除临时目录下的所有分片,保存该文件 md5 到文件访问地址 URL 的映射,然后返回 URL。
- 11、上传成功。
1.2 断点续传

如上图所示,文件断点续传的详细流程如下:
- 1、用户重新上传文件。
- 2、前端计算文件 md5 值。
- 3、前端调用 初始化上传接口 。
- 4、若 初始化上传接口 返回内容不为空但文件访问 URL 为空时,说明该文件已经上传了部分,此时只需要上传剩余部分即可。且该接口会返回哪些分片(分片索引
chunkIndex) 上传了。 - 5、前端计算剩余分片,并调用 上传分片接口 逐个上传剩余分片。
- 6、后续流程与上述正常分片上传流程一致。
1.3 秒传
秒传是指当某个文件已经被上传至文件服务器,再被上传时系统直接返回该文件访问地址即可,不用在耗费时间重新上传了。而判断是否为同一文件的依据则是文件的 md5 值,只要文件内容没被修改过,那么文件的 md5 值就不会变。如果同一文件前后端生成了不同的 md5 值,那说明文件被篡改了。
2 后端编码设计
后端主要负责上传任务信息的维护、通过 MinioClient 与 MinIO 服务端的交互。故我们需要设计一个上传任务管理器类 UploadTaskManager ,上传任务信息存储在 redis 中,所以需要对 redis 进行配置 RedisConfig ,以及封装的操作 redis 的 RedisUtils 类;我们需要在后端通过 MinioClient 与 MinIO 服务端进行交互,所以需要设计一个 MinioService 类来封装关于 MinIO 的操作,同时需要对 MinioClient 进行配置 MinioConfig ;除了这些核心类之外,还需要设计处理业务操作的 FileToMinioService 类与和前端进行交互的 FileToMinioController 控制器。
2.1 核心依赖
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- minio SDK -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
2.2 相关配置
################### spring 配置 ###################
spring:
application:
name: file-server
profiles:
active: dev
servlet:
multipart:
max-file-size: 100GB
max-request-size: 100GB
enabled: true
################### minio 配置 ###################
minio:
endpoint: http://localhost:9000 # minio 服务端地址
access-key: ******
secret-key: ******
bucket-name: momo # 文件目标桶名
temp-bucket: momo-temp # 分片临时桶名
chunk-size: 5242880 # 分片大小 默认 5MB 可由前端通过参数指定
upload-timeout: 7200 # 上传超时时间 秒 2h
2.3 实体类设计
2.3.1 上传任务信息实体
@Data
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UploadTask implements Serializable {
@Serial
private static final long serialVersionUID = -7351326905892114331L;
// 上传任务 id
private String uploadId;
// 文件名
private String filename;
// 文件 md5
private String fileMd5;
// 文件大小
private Long fileSize;
// 总分片数
private Integer totalChunks;
// 上传状态:0:上传中;1:已完成;2:失败
private Integer status;
// 文件 url
private String fileUrl;
// 已上传分片的索引集合
private Set<Integer> uploadedChunks = new HashSet<>();
// 分片索引与 Etag 映射 分片索引 -> Etag
private Map<Integer, String> chunkEtags = new HashMap<>();
}
2.3.2 初始化任务返回值
@Data
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InitUploadResponse implements Serializable {
@Serial
private static final long serialVersionUID = -5309973156739630192L;
// 上传任务 id
private String uploadId;
// 文件 url(秒传时返回)
private String fileUrl;
// 总分片数
private Integer totalChunks;
// 是否秒传
private Boolean quickUpload;
// 已上传分片的索引列表
private Set<Integer> uploadedChunks;
}
2.4 控制器业务处理
2.4.1 控制器
@Tag(name = "文件上传至 Minio")
@RestController
@RequestMapping("/fileToMinio")
public class FileToMinioController {
private final FileToMinioService fileToMinioService;
public FileToMinioController(FileToMinioService fileToMinioService) {
this.fileToMinioService = fileToMinioService;
}
@Operation(summary = "初始化上传")
@Parameters({
@Parameter(name = "filename", description = "文件名(带后缀)", required = true),
@Parameter(name = "fileMd5", description = "文件 MD5 值", required = true),
@Parameter(name = "fileSize", description = "文件大小", required = true),
@Parameter(name = "chunkSize", description = "分片大小")
})
@PostMapping(value = "/initUpload")
public APIResponse<InitUploadResponse> initUpload(@RequestParam("filename") String filename,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileSize") Long fileSize,
@RequestParam(name = "chunkSize", required = false) Integer chunkSize) {
if (!StringUtils.hasText(filename) || !StringUtils.hasText(fileMd5) || fileSize == null) {
throw GlobalException.error("参数不能为空!");
}
return APIResponse.success(this.fileToMinioService.initUpload(filename, fileMd5, fileSize, chunkSize));
}
@Operation(summary = "上传分片")
@Parameters({
@Parameter(name = "uploadId", description = "上传任务 id", required = true),
@Parameter(name = "chunkIndex", description = "分片索引", required = true),
@Parameter(name = "chunk", description = "分片文件", required = true, schema = @Schema(type = "string", format = "binary")),
})
@PostMapping(value = "/uploadChunk")
public APIResponse<Map<String, Object>> uploadChunk(@RequestParam("uploadId") String uploadId,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("chunk") MultipartFile chunk) {
if (!StringUtils.hasText(uploadId) || chunk == null) {
throw GlobalException.error("参数不能为空!");
}
return APIResponse.success(this.fileToMinioService.uploadChunk(uploadId, chunkIndex, chunk));
}
@Operation(summary = "完成上传")
@Parameter(name = "uploadId", description = "上传任务 id", required = true)
@PostMapping("/completeUpload")
public APIResponse<Map<String, Object>> completeUpload(@RequestParam("uploadId") String uploadId) {
this.checkParams(uploadId);
return APIResponse.success(this.fileToMinioService.completeUpload(uploadId));
}
@Operation(summary = "获取上传任务(包括进度)")
@Parameter(name = "uploadId", description = "上传任务 id", required = true)
@PostMapping("/getUploadTask")
public APIResponse<UploadTask> getUploadTask(@RequestParam("uploadId") String uploadId) {
this.checkParams(uploadId);
return APIResponse.success(this.fileToMinioService.getUploadTask(uploadId));
}
@Operation(summary = "取消上传")
@Parameter(name = "uploadId", description = "上传任务 id", required = true)
@PostMapping("/cancelUpload")
public APIResponse<Object> cancelUpload(@RequestParam("uploadId") String uploadId) {
this.checkParams(uploadId);
this.fileToMinioService.cancelUpload(uploadId);
return APIResponse.success();
}
// 参数校验
private void checkParams(String uploadId) {
if (!StringUtils.hasText(uploadId)) {
throw GlobalException.error("参数不能为空!");
}
}
}
2.4.2 业务实现
@Service
public class FileToMinioServiceImpl implements FileToMinioService {
private final UploadTaskManager uploadTaskManager;
private final MinioService minioService;
@Value("${minio.chunk-size}")
private Integer chunkSize;
public FileToMinioServiceImpl(UploadTaskManager uploadTaskManager, MinioService minioService) {
this.uploadTaskManager = uploadTaskManager;
this.minioService = minioService;
}
// 初始化上传任务
@Override
public InitUploadResponse initUpload(String filename, String fileMd5, Long fileSize, Integer chunkSize) {
InitUploadResponse response = InitUploadResponse.builder().build();
// 检查是否上传过
String fileUrl = this.uploadTaskManager.getFileUrlByMd5(fileMd5);
if (StringUtils.hasText(fileMd5)) {
response.setFileUrl(fileUrl);
response.setQuickUpload(true);
return response;
}
// 计算分片总数
int totalChunks = (int) Math.ceil((double) fileSize / this.determineChunkSize(chunkSize));
// 初始化上传任务
UploadTask uploadTask = this.uploadTaskManager.createUploadTask(filename, fileMd5, fileSize, totalChunks);
response.setUploadId(uploadTask.getUploadId());
response.setTotalChunks(totalChunks);
response.setQuickUpload(false);
response.setUploadedChunks(uploadTask.getUploadedChunks());
return response;
}
// 上传分片
@Override
public Map<String, Object> uploadChunk(String uploadId, int chunkIndex, MultipartFile chunk) {
this.checkUploadTask(uploadId);
// 这里可加入重试机制 提高成功率 如使用 spring-retry 组件
String etag = this.minioService.uploadChunk(uploadId, chunkIndex, chunk);
// 标记当前分片已上传
this.uploadTaskManager.markChunkUploaded(uploadId, chunkIndex, etag);
Map<String, Object> map = new HashMap<>();
map.put("chunkIndex", chunkIndex);
map.put("etag", etag);
return map;
}
// 完成上传(合并分片)
@Override
public Map<String, Object> completeUpload(String uploadId) {
UploadTask uploadTask = this.checkUploadTask(uploadId);
// 校验所有分片是否均已上传
if (!this.uploadTaskManager.isAllChunkUploaded(uploadId)) {
throw GlobalException.error("存在未上传完成分片!");
}
// 合并分片
String fileUrl = this.minioService.mergeChunks(uploadTask.getFilename(), uploadId, uploadTask.getTotalChunks());
// 更新上传任务为已完成
this.uploadTaskManager.completeUploadTask(uploadId, fileUrl);
Map<String, Object> map = new HashMap<>();
map.put("filename", uploadTask.getFilename());
map.put("fileUrl", fileUrl);
return map;
}
// 获取上传任务信息
@Override
public UploadTask getUploadTask(String uploadId) {
return this.checkUploadTask(uploadId);
}
// 取消上传
@Override
public void cancelUpload(String uploadId) {
this.uploadTaskManager.removeUploadTask(uploadId);
}
// 校验上传任务
private UploadTask checkUploadTask(String uploadId) {
UploadTask uploadTask = this.uploadTaskManager.getUploadTask(uploadId);
if (uploadTask == null) {
throw GlobalException.error("上传任务不存在!");
}
return uploadTask;
}
// 推断分片大小 若未传则使用默认配置(5MB)
private int determineChunkSize(Integer chunkSize) {
return chunkSize != null ? chunkSize : this.chunkSize;
}
}
2.5 组件配置
2.5.1 Redis 配置
@Configuration
public class RedisConfig {
private final ObjectMapper objectMapper;
public RedisConfig(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Bean("redisTemplate")
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
// 使用 String 来序列化 redis 的 key 值
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// 使用 Jackson 来序列化和反序列化 redis 的 value 值(默认使用 JDK 的序列化方法)
RedisSerializer<Object> jsonRedisSerializer = new GenericJackson2JsonRedisSerializer(objectMapper);
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setValueSerializer(jsonRedisSerializer);
redisTemplate.setHashKeySerializer(jsonRedisSerializer);
redisTemplate.setHashValueSerializer(jsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
2.5.2 MinIO 配置
@Configuration
public class MinioConfig {
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.access-key}")
private String accessKey;
@Value("${minio.secret-key}")
private String secretKey;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(this.endpoint)
.credentials(this.accessKey, this.secretKey)
.build();
}
}
2.6 核心类
2.6.1 上传任务管理器
@Component
public class UploadTaskManager {
// md5 与 url 映射关系 key 前缀
private static final String FILE_MD5_PREFIX = "file:md5:";
// 上传任务信息 key 前缀 值为 UploadTask 对象
private static final String FILE_TASK_PREFIX = "file:task:";
private final ObjectMapper objectMapper;
@Value("${minio.upload-timeout}")
private Long uploadTimeout;
public UploadTaskManager(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
// 创建上传任务
public UploadTask createUploadTask(String filename, String fileMd5, Long fileSize, int totalChunks) {
UploadTask uploadTask = UploadTask.builder()
.uploadId(UUID.randomUUID().toString())
.filename(filename)
.fileMd5(fileMd5)
.fileSize(fileSize)
.totalChunks(totalChunks)
.status(0)
.build();
this.saveUploadTask(uploadTask);
return uploadTask;
}
// 保存上传任务
public void saveUploadTask(UploadTask uploadTask) {
String key = FILE_TASK_PREFIX + uploadTask.getUploadId();
RedisUtils.addValue(key, uploadTask, this.uploadTimeout, TimeUnit.SECONDS);
}
// 标记分片已上传
public void markChunkUploaded(String uploadId, int chunkIndex, String eTag) {
UploadTask uploadTask = this.getUploadTask(uploadId);
if (uploadTask == null) {
throw GlobalException.error("上传任务不存在!");
}
uploadTask.getUploadedChunks().add(chunkIndex);
uploadTask.getChunkEtags().put(chunkIndex, eTag);
this.saveUploadTask(uploadTask);
}
// 完成上传
public void completeUploadTask(String uploadId, String fileUrl) {
UploadTask uploadTask = this.getUploadTask(uploadId);
if (uploadTask == null) {
throw GlobalException.error("上传任务不存在!");
}
uploadTask.setStatus(1);
uploadTask.setFileUrl(fileUrl);
this.saveUploadTask(uploadTask);
this.saveMd5UrlMapping(uploadTask.getFileMd5(), fileUrl);
}
// 获取上传任务
public UploadTask getUploadTask(String uploadId) {
String key = FILE_TASK_PREFIX + uploadId;
Object value = RedisUtils.getValue(key);
if (value == null) {
return null;
}
if (value instanceof UploadTask) {
return ((UploadTask) value);
}
return this.objectMapper.convertValue(value, UploadTask.class);
}
// 检查所有分片是否都已上传
public boolean isAllChunkUploaded(String uploadId) {
UploadTask uploadTask = this.getUploadTask(uploadId);
if (uploadTask == null) {
return false;
}
return uploadTask.getTotalChunks().equals(uploadTask.getUploadedChunks().size());
}
// 根据文件 md5 获取文件 url
public String getFileUrlByMd5(String md5) {
Object value = RedisUtils.getValue(FILE_MD5_PREFIX + md5);
return value != null ? value.toString() : null;
}
// 保存映射:md5 -> 文件 url
public void saveMd5UrlMapping(String fileMd5, String fileUrl) {
RedisUtils.addValue(FILE_MD5_PREFIX + fileMd5, fileUrl, 30, TimeUnit.DAYS);
}
// 移除上传任务
public void removeUploadTask(String uploadId) {
RedisUtils.deleteKey(FILE_MD5_PREFIX + uploadId);
RedisUtils.deleteKey(FILE_TASK_PREFIX + uploadId);
}
}
2.6.2 MinIO 服务
@Slf4j
@Service
public class MinioService {
private final MinioClient minioClient;
@Value("${minio.bucket-name}")
private String bucketName;
@Value("${minio.temp-bucket}")
private String tempBucket;
public MinioService(MinioClient minioClient) {
this.minioClient = minioClient;
}
// 初始化桶
@PostConstruct
public void initBucket() {
try {
this.createBucketIfNotExists(this.bucketName);
this.createBucketIfNotExists(this.tempBucket);
} catch (Exception e) {
throw GlobalException.error("初始化桶失败!", e);
}
}
// 上传分片
public String uploadChunk(String uploadId, int chunkIndex, MultipartFile file) {
String objectName = String.format("%s/chunk_%d", uploadId, chunkIndex);
try {
ObjectWriteResponse response = this.minioClient.putObject(PutObjectArgs.builder()
.bucket(this.tempBucket)
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build());
return response.etag();
} catch (Exception e) {
log.error("上传分片失败:bucketName = {}, uploadId = {}, chunkIndex = {},error = {}",
this.tempBucket, uploadId, chunkIndex, e);
throw GlobalException.error("上传分片失败!");
}
}
// 合并分片
public String mergeChunks(String filename, String uploadId, int totalChunks) {
String objectName = this.generateObjectName(filename, uploadId);
List<ComposeSource> sources = new ArrayList<>();
for (int i = 0; i < totalChunks; i++) {
sources.add(ComposeSource.builder()
.bucket(this.tempBucket)
.object(String.format("%s/chunk_%d", uploadId, i))
.build());
}
try {
this.minioClient.composeObject(ComposeObjectArgs.builder()
.bucket(this.bucketName)
.object(objectName)
.sources(sources)
.build());
} catch (Exception e) {
log.error("合并分片失败:bucketName = {}, uploadId = {}, totalChunks = {},error = {}",
this.tempBucket, uploadId, totalChunks, e);
throw GlobalException.error("合并分片失败!");
}
this.deleteChunks(uploadId, totalChunks);
return this.getFileUrl(objectName);
}
// 获取文件访问 URL
public String getFileUrl(String objectName) {
try {
return this.minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.bucket(this.bucketName)
.object(objectName)
.expiry(7, TimeUnit.DAYS)
.method(Method.GET)
.build());
} catch (Exception e) {
log.error("获取文件 URL 失败:bucketName = {}, objectName = {},,error = {}",
this.tempBucket, objectName, e);
throw GlobalException.error("获取文件 URL 失败!");
}
}
// 桶不存在时创建桶
private void createBucketIfNotExists(String bucketName) throws Exception {
boolean exists = this.minioClient.bucketExists(BucketExistsArgs.builder()
.bucket(bucketName)
.build());
if (exists) {
return;
}
this.minioClient.makeBucket(MakeBucketArgs.builder()
.bucket(bucketName)
.build());
}
// 删除临时分片
private void deleteChunks(String uploadId, int totalChunks) {
try {
for (int i = 0; i < totalChunks; i++) {
this.minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(this.tempBucket)
.object(String.format("%s/chunk_%d", uploadId, i))
.build());
}
} catch (Exception e) {
log.error("删除临时分片失败:bucketName = {}, uploadId = {}, totalChunks = {},error = {}",
this.tempBucket, uploadId, totalChunks, e);
throw GlobalException.error("删除临时分片失败!");
}
}
// 生成对象名
private String generateObjectName(String fileName, String uploadId) {
String extension = "";
int dotIndex = fileName.lastIndexOf(".");
if (dotIndex > 0) {
extension = fileName.substring(dotIndex);
}
return uploadId + extension;
}
}
3 前端编码设计
前端需要对文件进行分片;计算文件 md5 值;为避免过多占用 web 服务器线程以影响其它业务需要进行一定的并发控制,如只限制三个线程处理上传任务;为避免网络波动影响,还可以配置重试机制,建议采用指数退避算法。因篇幅原因,此处只给出相关核心代码。
3.1 文件分片算法
const chunkSize = 5 * 1024 * 1024; // 5MB
const totalChunks = Math.ceil(file.size / chunkSize);
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
// 上传 chunk
}
3.2 文件 MD5 值计算
// 使用 SparkMD5 增量计算,避免大文件内存溢出
const spark = new SparkMD5.ArrayBuffer();
// 分块读取文件
spark.append(arrayBuffer); // 每读一块就 append 到 spark
const md5 = spark.end(); // 最后获取 MD5
3.3 并发控制
// 使用 Promise.all + 工作队列
const concurrency = 3;
const queue = [...chunks];
const workers = [];
for (let i = 0; i < concurrency; i++) {
workers.push(uploadWorker(queue));
}
await Promise.all(workers);
3.4 重试机制
// 指数退避重试
let retryCount = 0;
const maxRetry = 3;
while (retryCount < maxRetry) {
try {
await uploadChunk();
break;
} catch (error) {
retryCount++;
await sleep(1000 * retryCount); // 1s, 2s, 3s
}
}
4 优化与扩展
优化与扩展方面可针对前端、后端和存储进行性能、安全、可用性等方面的优化。
- Web Worker:MD5 计算在 Worker 中进行,不阻塞 UI。
- 并发上传与控制:并发上传是指后端可通过线程池(如
Executors.newFixedThreadPool(10))并行上传分片,以提高效率;并发控制是指前端控制分片上传的并发数,以避免占用过多的 web 服务器线程,从而影响到其他业务。 - Blob 分片:文件分片时可采用
File.slice()避免内存占用。 - 预签名 URL:为减轻服务器压力同时又确保安全性,可使用
MinIO的预签名 URL。预签名 URL 是一种包含间弥签名和过期时间的临时访问链接。 - 合理设置分片大小:分片大小需要合理设置,太小会导致协调开销大,太大则降低并行粒度以及重复代价,
5~8MB是最优解。 - 临时桶和最终桶:为防止业务影响,可将分片放在临时桶,合并后再放入最终桶(合并后记得删除临时桶里的分片哦)。


358

被折叠的 条评论
为什么被折叠?



