一个 2GB 文件先传到 SpringBoot,再由 SpringBoot 写进 MinIO。
这套代码在本地看不出问题,一到线上,Tomcat 线程挂着,临时目录不断涨,网关还可能先报超时。文件明明只需要存一次,数据却在网络里跑了两遍,这个链路我第一眼就不太信。
比较稳的做法是:SpringBoot 只签发上传凭证、记录分片状态、触发合并,真正的文件数据由客户端直接写入 MinIO。
MinIO 提供 S3 兼容接口,Java SDK 可以生成指定对象的临时 PUT 地址,客户端拿到地址后直接上传,不需要知道 accessKey 和 secretKey。
整个流程拆成四步:
客户端计算文件 SHA-256,请求初始化上传。
服务端判断能否秒传,并返回缺失分片的上传地址。
客户端并发 PUT 分片。
所有分片完成后,由服务端通知 MinIO 合并。
上传任务不能只放 Redis
我一般会保留两张表。
file_asset保存已经完成的文件,关键字段是sha256、file_size、object_key、status。
upload_job保存上传任务,记录task_id、sha256、chunk_size、chunk_count、status。分片完成情况可以放 Redis,但任务本身最好落库。Redis 一重启,用户上传到 90% 的文件不能继续,这种设计经不起一次故障。
初始化上传时,先查成品文件:
public UploadTicket prepare(UploadCommand command) {
FileAsset ready = assetRepository
.findReady(command.sha256(), command.fileSize())
.orElse(null);
if (ready != null) {
return UploadTicket.instant(ready.getObjectKey());
}
UploadJob job = jobRepository
.findRunning(command.sha256(), command.fileSize())
.orElseGet(() -> createJob(command));
Set<Integer> finished = partRepository.findFinishedIndexes(job.getId());
List<PartTicket> missing = IntStream.range(0, job.getChunkCount())
.filter(index -> !finished.contains(index))
.mapToObj(index -> {
String partKey = "uploading/%s/%05d.part"
.formatted(job.getTaskId(), index);
return new PartTicket(index, signPutUrl(partKey));
})
.toList();
return UploadTicket.uploading(job.getTaskId(), missing);
}
秒传不要拿文件名判断。
两个用户都上传合同.pdf,内容可能完全不同;同一个文件改了名字,内容仍然一样。实际判断至少要带上 SHA-256 和文件大小,库里还必须是已完成状态。
预签名地址的代码不复杂:
private String signPutUrl(String objectKey) {
try {
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket("business-file")
.object(objectKey)
.expiry(10, TimeUnit.MINUTES)
.build()
);
} catch (Exception e) {
throw new IllegalStateException("生成分片上传地址失败:" + objectKey, e);
}
}
签名时间别给得太长,十分钟左右够用。对象路径也必须由服务端生成,不能让客户端传一个任意路径进来,不然迟早有人把别人的文件覆盖掉。
分片完成,不能只相信客户端回调
客户端说“第 7 片上传成功”,我一般不会直接把数据库改成成功。
先去 MinIO 查对象,至少核对这一片是否存在、大小是否正确:
public void confirmPart(long jobId, int partIndex) {
UploadJob job = jobRepository.getRequired(jobId);
long expectedSize = job.expectedPartSize(partIndex);
String partKey = job.partKey(partIndex);
try {
StatObjectResponse stat = minioClient.statObject(
StatObjectArgs.builder()
.bucket("business-file")
.object(partKey)
.build()
);
if (stat.size() != expectedSize) {
throw new IllegalStateException(
"分片大小不对,index=%d, expected=%d, actual=%d"
.formatted(partIndex, expectedSize, stat.size())
);
}
partRepository.markFinished(jobId, partIndex, stat.etag());
} catch (Exception e) {
throw new IllegalStateException("确认分片失败,index=" + partIndex, e);
}
}
断点续传也就落在这里。
用户刷新页面或者断网重连后,再拿同一个 SHA-256 和文件大小请求初始化接口。服务端查出原任务,读取已经完成的分片,只给缺失部分重新签名。
已经传完的分片不动,只补洞。
合并时别把文件重新读回 Java
分片齐了以后,还有人会把所有分片下载到 SpringBoot,再拼成一个流重新上传。前面好不容易绕开了后端中转,最后又绕回来了。
MinIO Java SDK 的composeObject可以在存储端组合已有对象,不需要让文件内容再次经过应用服务器。
public String complete(long jobId) {
UploadJob job = jobRepository.getRequired(jobId);
if (!partRepository.allFinished(jobId, job.getChunkCount())) {
throw new IllegalStateException("分片还没传齐,拒绝合并");
}
List<ComposeSource> sources = IntStream
.range(0, job.getChunkCount())
.mapToObj(index -> ComposeSource.builder()
.bucket("business-file")
.object(job.partKey(index))
.build())
.toList();
String finalKey = "files/%s/%s"
.formatted(job.getSha256().substring(0, 2), job.getSha256());
try {
minioClient.composeObject(
ComposeObjectArgs.builder()
.bucket("business-file")
.object(finalKey)
.sources(sources)
.build()
);
} catch (Exception e) {
throw new IllegalStateException("MinIO 合并文件失败,task=" + jobId, e);
}
assetRepository.saveReady(
job.getSha256(),
job.getFileSize(),
finalKey,
job.getOriginalName()
);
jobRepository.markCompleted(jobId, finalKey);
return finalKey;
}
合并接口还要做幂等。
客户端超时后很可能再次调用完成接口。如果任务已经是COMPLETED,直接返回原来的objectKey,不要重复合并,更不要因为临时分片已经清理就报错。
分片大小我通常放在 8MB 到 16MB。切得太小,请求数量、状态记录和签名接口先扛不住;切得太大,断线后重传成本又会上去。
最后再补一个容易漏的地方:uploading/下面的临时分片必须清理。可以给临时前缀配置生命周期规则,也可以定时扫描长时间未完成的任务。否则上传失败不会报事故,只会悄悄吃磁盘,等磁盘告警响了,现场会更难看。
这套链路里,SpringBoot 从头到尾都不碰文件正文。它只管权限、状态和最终确认,这才是大文件上传该有的分工。