Spring Boot + MinIO 大文件上传完整实战:MD5 秒传、分片上传、断点续传与引用一致性
说明:本文基于实际项目流程整理,代码针对重复上报、合并校验、过期清理和对象缺失等问题补充了更稳妥的写法,不是线上代码的逐行复制。
前言
文件上传看起来很简单:前端提交一个 MultipartFile,后端调用 putObject() 保存到 MinIO,再向数据库写入一条记录。
但当文件体积达到几百 MB,并且多个业务可以引用同一个文件时,就需要继续解决这些问题:
- 同一个文件重复上传,能不能不再传输文件内容?
- 大文件如何切片并发上传?
- 上传到一半网络中断,如何只补传缺失分片?
- 预签名 URL 过期后,是否需要重新上传整个文件?
- 如何判断所有分片已经上传完成?
- 合并后的文件如何校验大小和 MD5?
- 多个业务引用同一文件时,什么时候才能物理删除?
- MinIO 对象已经不存在,数据库却还有记录,秒传应该如何处理?
本文基于 Java 17、Spring Boot、MySQL、MyBatis-Plus、MinIO,给出一套完整的大文件上传设计,并使用少量 Vue 3 代码说明客户端流程。
文章重点不是简单调用几个 SDK,而是把文件字节、数据库元数据、上传任务和业务引用四者之间的关系讲清楚。
一、整体方案设计
1. 小文件与大文件使用不同通道
本文采用以下策略:
- 文件小于 10 MiB:通过后端直接上传;
- 文件大于等于 10 MiB:前端分片,直传 MinIO;
- 默认分片大小:5 MiB;
- 分片并发数:3;
- 分片索引:从 0 开始;
- 分片上传任务默认保留 72 小时。
其中,10 MiB 只是业务分流阈值,不是 MinIO 的限制。后端不需要根据文件大小自动分流,由调用方选择对应接口即可。
2. 大文件为什么不让分片经过后端
如果每个分片都先提交到 Spring Boot,再由后端转发到 MinIO,会产生两次网络传输:
text
浏览器 → Spring Boot → MinIO
这会增加后端带宽、连接数和内存压力。
更合理的方式是:
text
浏览器 ──①申请预签名URL──> Spring Boot
浏览器 ──②PUT分片────────> MinIO
浏览器 ──③上报分片完成───> Spring Boot
浏览器 ──④请求合并───────> Spring Boot
Spring Boot ─⑤合并对象───> MinIO
Spring Boot 不接收分片字节,只负责:
- 创建和恢复上传任务;
- 校验分片索引;
- 签发短时效预签名 PUT URL;
- 保存分片完成状态;
- 合并分片;
- 保存文件元数据和业务引用。
3. 完整上传流程
text
选择本地文件
│
▼
增量计算文件 MD5
│
▼
按 MD5 + 文件大小检查是否已存在
│
├── 命中:绑定业务引用,直接返回已有 fileId
│
└── 未命中
│
├── 小文件:后端直传 MinIO
│
└── 大文件:
1. 初始化或恢复分片任务
2. 获取 uploadedChunks
3. 计算待上传分片
4. 批量获取预签名 PUT URL
5. 并发上传分片
6. 每个分片 PUT 成功后调用 report
7. 所有分片完成后调用 complete
8. 服务端合并并保存文件元数据
9. 清理临时分片
二、数据模型:把文件、上传任务和业务引用分开
整个方案主要使用三张表。
1. 文件表 sys_file
表示一个已经上传完成的完整文件。
核心字段如下:
| 字段 | 说明 |
|---|---|
id |
文件 ID,业务系统只保存这个 ID |
file_name |
原始文件名 |
file_type |
MIME 类型 |
file_size |
文件大小 |
bucket_name |
MinIO Bucket |
object_name |
MinIO 对象名 |
file_category |
文件分类 |
md5 |
文件 MD5,用于秒传和完整性校验 |
ref_count |
冗余引用计数,只作为缓存 |
upload_user |
上传人 |
upload_time |
上传时间 |
del |
删除标记 |
去重查询使用:
sql
SELECT *
FROM sys_file
WHERE md5 = #{md5}
AND file_size = #{fileSize}
AND del = 0
LIMIT 1;
索引:
sql
CREATE INDEX idx_md5_size
ON sys_file(md5, file_size);
这里使用 MD5 + 文件大小,而不是只使用 MD5。
普通索引只能加速查询,不能保证并发上传时只有一条文件记录。是否增加唯一约束,需要结合对象命名和历史数据处理,不能只改 Java 代码。
2. 分片表 sys_file_chunk
同时保存上传任务和分片完成记录:
chunk_index = -1:任务初始化记录;chunk_index >= 0:具体分片记录;status = 0:任务尚未完成;status = 1:该分片已经上报完成。
核心字段:
| 字段 | 说明 |
|---|---|
upload_id |
上传任务标识 |
file_name |
原始文件名 |
file_size |
完整文件大小 |
chunk_size |
分片大小 |
chunk_count |
分片总数 |
chunk_index |
分片索引,-1 表示初始化记录 |
chunk_md5 |
可选的分片 MD5 |
object_name |
最终对象名或分片对象名 |
bucket_name |
Bucket |
file_category |
文件分类 |
md5 |
完整文件 MD5 |
ref_type、ref_id |
初始化时保存的业务引用 |
upload_user |
任务所属用户 |
status |
状态 |
create_time、update_time |
用于过期清理 |
分片对象命名:
text
chunks/{uploadId}/part-0
chunks/{uploadId}/part-1
chunks/{uploadId}/part-2
建议为分片记录增加唯一约束:
sql
ALTER TABLE sys_file_chunk
ADD UNIQUE KEY uk_upload_chunk(upload_id, chunk_index);
这样,同一个分片重复上报时才能通过数据库约束保证幂等。
3. 文件引用表 sys_file_ref
文件记录表示"对象存了一份",引用记录表示"哪些业务正在使用它"。
核心字段:
| 字段 | 说明 |
|---|---|
file_id |
文件 ID |
ref_type |
业务类型,例如用户、学习资源 |
sub_type |
细分场景,例如头像、附件 |
ref_id |
业务记录 ID |
file_category |
文件分类 |
upload_user |
引用创建人 |
upload_time |
引用创建时间 |
del |
删除标记 |
例如:
text
sys_file
id = 100
md5 = abc...
object_name = document/2026-10-29/uuid.pdf
sys_file_ref
file_id = 100, ref_type = resource, ref_id = 2001
file_id = 100, ref_type = resource, ref_id = 2002
两个业务资源可以共享同一个物理文件。删除其中一个资源时,不能立即删除 MinIO 对象。
三、接口设计
| 接口 | 方法 | 作用 |
|---|---|---|
/files/check-duplicate |
GET | 上传前检查文件是否已存在 |
/files/upload |
POST | 小文件直传 |
/files/chunk/init |
POST | 初始化或恢复分片任务 |
/files/chunk/presign |
POST | 批量获取分片 PUT URL |
/files/chunk/report |
POST | 上报分片上传完成 |
/files/chunk/complete |
POST | 校验并合并所有分片 |
初始化请求:
java
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Positive;
public class ChunkInitRequest {
@NotBlank(message = "文件名不能为空")
private String fileName;
@NotNull(message = "文件大小不能为空")
@Positive(message = "文件大小必须大于0")
private Long fileSize;
@NotBlank(message = "文件MD5不能为空")
private String md5;
@Positive(message = "分片大小必须大于0")
private Integer chunkSize = 5 * 1024 * 1024;
private String fileCategory;
private String refType;
private String subType;
private String refId;
// getter、setter省略
}
初始化响应建议返回实际使用的 chunkSize:
java
import java.util.List;
public class ChunkInitResponse {
private String uploadId;
private Integer chunkSize;
private Integer chunkCount;
private List<Integer> uploadedChunks;
// getter、setter省略
}
客户端续传时必须使用服务端返回的 chunkSize,不能在中途改变切片规则。
四、前端第一步:增量计算文件 MD5
不能一次性把整个大文件读入内存。可以使用 spark-md5,按块读取并增量计算。
安装:
bash
npm install spark-md5
核心代码:
typescript
import SparkMD5 from 'spark-md5'
export const CHUNK_SIZE = 5 * 1024 * 1024
export function calculateMD5(
file: File,
chunkSize = CHUNK_SIZE
): Promise<string> {
return new Promise((resolve, reject) => {
const chunkCount = Math.ceil(file.size / chunkSize)
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
let currentChunk = 0
reader.onload = event => {
spark.append(event.target!.result as ArrayBuffer)
currentChunk++
if (currentChunk < chunkCount) {
readNextChunk()
} else {
resolve(spark.end())
}
}
reader.onerror = () => {
reject(new Error('文件MD5计算失败'))
}
function readNextChunk() {
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
reader.readAsArrayBuffer(file.slice(start, end))
}
readNextChunk()
})
}
这里计算 MD5 使用的读取块大小,可以与实际上传分片大小一致,但二者没有必然关系。
需要明确:
- MD5 适合在可信业务内部做文件指纹和重复内容识别;
- MD5 不具备抗恶意碰撞能力;
- 如果文件来源不可信,并且指纹涉及安全边界,应使用 SHA-256;
- 即使客户端计算了 MD5,服务端仍要校验文件大小,关键场景还要重新计算文件摘要。
五、前端完整上传编排
下面的代码把秒传、直传、分片和断点续传串起来。
typescript
import request from './request'
import { calculateMD5, CHUNK_SIZE } from '@/utils/file'
interface UploadOptions {
fileCategory?: string
refType?: string
subType?: string
refId?: string
businessId?: number
}
export async function uploadFile(
file: File,
options: UploadOptions = {}
) {
const fileCategory = options.fileCategory || 'default'
// 1. 计算文件指纹
const md5 = await calculateMD5(file, CHUNK_SIZE)
// 2. 秒传检查
const duplicated = await request({
url: '/files/check-duplicate',
method: 'get',
params: {
md5,
fileSize: file.size,
fileCategory,
refType: options.refType,
subType: options.subType,
refId: options.refId
}
})
if (duplicated) {
return duplicated
}
// 3. 小文件直传
if (file.size < CHUNK_SIZE * 2) {
const formData = new FormData()
formData.append('file', file)
formData.append('fileCategory', fileCategory)
appendOptional(formData, 'refType', options.refType)
appendOptional(formData, 'subType', options.subType)
appendOptional(formData, 'refId', options.refId)
if (options.businessId != null) {
formData.append('businessId', String(options.businessId))
}
return request.post('/files/upload', formData)
}
// 4. 大文件分片上传
return uploadByChunks(file, md5, options)
}
function appendOptional(
formData: FormData,
key: string,
value?: string
) {
if (value) {
formData.append(key, value)
}
}
分片上传部分:
typescript
async function uploadByChunks(
file: File,
md5: string,
options: UploadOptions
) {
// 1. 初始化或恢复任务
const initResult = await request({
url: '/files/chunk/init',
method: 'post',
data: {
fileName: file.name,
fileSize: file.size,
md5,
chunkSize: CHUNK_SIZE,
fileCategory: options.fileCategory || 'default',
refType: options.refType,
subType: options.subType,
refId: options.refId,
businessId: options.businessId
}
})
const uploadId = initResult.uploadId
const chunkSize = initResult.chunkSize
const chunkCount = initResult.chunkCount
const uploadedChunks: number[] =
initResult.uploadedChunks || []
// 2. 只保留缺失分片
const pendingChunks: number[] = []
for (let index = 0; index < chunkCount; index++) {
if (!uploadedChunks.includes(index)) {
pendingChunks.push(index)
}
}
// 3. 每批最多并发上传3个分片
const concurrency = 3
for (let i = 0; i < pendingChunks.length; i += concurrency) {
const batch = pendingChunks.slice(i, i + concurrency)
const urls: string[] = await request({
url: '/files/chunk/presign',
method: 'post',
data: {
uploadId,
chunkIndexes: batch
}
})
await Promise.all(
batch.map(async (chunkIndex, position) => {
const start = chunkIndex * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
// 4. 分片字节直接PUT到MinIO
const response = await fetch(urls[position], {
method: 'PUT',
body: chunk,
headers: {
'Content-Type': 'application/octet-stream'
}
})
if (!response.ok) {
throw new Error(
`分片${chunkIndex}上传失败,HTTP ${response.status}`
)
}
// 5. PUT成功后再上报,顺序不能反
await request({
url: '/files/chunk/report',
method: 'post',
data: {
uploadId,
chunkIndex
}
})
})
)
}
// 6. 请求服务端合并
return request({
url: '/files/chunk/complete',
method: 'post',
data: { uploadId }
})
}
这里有两个非常重要的细节。
第一,必须检查 response.ok:
typescript
const response = await fetch(url, options)
即使返回 403、404 或 500,fetch() 本身也不会自动进入 catch。
第二,必须先 PUT 成功,再调用 report。如果先上报再上传,网络中断后数据库会把未完成分片标记为已完成,续传时就会错误跳过它。
六、小文件直传与服务端 MD5 去重
小文件虽然经过了前端秒传检查,后端仍然需要自己计算 MD5。
原因是:
- 前端可能绕过预检直接调用上传接口;
- 客户端提交的 MD5 不可信;
- 秒传检查与实际上传之间存在并发窗口。
不要使用 file.getBytes() 计算大文件 MD5,因为它会将完整文件读入内存。可以使用输入流:
java
import org.springframework.util.DigestUtils;
import java.io.InputStream;
private String calculateMd5(MultipartFile file) throws Exception {
try (InputStream inputStream = file.getInputStream()) {
return DigestUtils.md5DigestAsHex(inputStream);
}
}
直传核心流程:
java
public FileResponse upload(
MultipartFile file,
String fileCategory,
String refType,
String subType,
String refId) throws Exception {
String originalFilename = sanitize(file.getOriginalFilename());
validateFile(file, originalFilename);
// 1. 服务端真实计算MD5
String md5 = calculateMd5(file);
// 2. 二次查重
SysFile existing = sysFileMapper.findByMd5AndSize(
md5, file.getSize());
if (existing != null) {
// 数据库命中不代表对象一定存在
assertObjectExists(existing);
// 必须幂等绑定,不能每次预检都无条件增加引用数
fileReferenceService.bindIfAbsent(
existing.getId(), refType, subType, refId);
return toFileResponse(existing);
}
// 3. 生成服务端对象名,不能由客户端直接指定
String bucket = minIOConfig.getBucketName();
String objectName = buildObjectName(
fileCategory, originalFilename);
// 4. 上传文件字节
try (InputStream inputStream = file.getInputStream()) {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.stream(
inputStream,
file.getSize(),
-1)
.contentType(file.getContentType())
.build());
}
// 5. 保存文件元数据与业务引用
SysFile sysFile = new SysFile();
sysFile.setFileName(originalFilename);
sysFile.setFileType(file.getContentType());
sysFile.setFileSize(file.getSize());
sysFile.setBucketName(bucket);
sysFile.setObjectName(objectName);
sysFile.setFileCategory(fileCategory);
sysFile.setMd5(md5);
sysFile.setUploadUser(getCurrentUsername());
sysFile.setUploadTime(new Date());
sysFile.setDel(0);
saveFileAndReference(
sysFile, refType, subType, refId);
return toFileResponse(sysFile);
}
对象名建议由服务端生成:
java
private String buildObjectName(
String fileCategory,
String originalFilename) {
String extension = getExtension(originalFilename);
String date = LocalDate.now().toString();
return fileCategory + "/"
+ date + "/"
+ UUID.randomUUID()
+ (extension.isEmpty() ? "" : "." + extension);
}
这样既能避免路径穿越,也不会因为原始文件名相同而互相覆盖。
七、分片初始化:新任务与断点恢复使用同一个接口
分片数量由服务端计算:
java
int chunkCount = (int) Math.ceil(
(double) fileSize / chunkSize);
例如:
text
文件大小:12 MiB
分片大小:5 MiB
part-0:5 MiB
part-1:5 MiB
part-2:2 MiB
chunkCount = 3
初始化核心代码:
java
private static final int DEFAULT_CHUNK_SIZE =
5 * 1024 * 1024;
public ChunkInitResponse initChunkUpload(
ChunkInitRequest request) {
String safeFilename = sanitize(request.getFileName());
validateExtension(safeFilename);
String uploadId = request.getMd5();
SysFileChunk task =
sysFileChunkMapper.findInitRecord(uploadId);
if (task == null) {
int chunkSize = request.getChunkSize() != null
? request.getChunkSize()
: DEFAULT_CHUNK_SIZE;
int chunkCount = (int) Math.ceil(
(double) request.getFileSize() / chunkSize);
task = new SysFileChunk();
task.setUploadId(uploadId);
task.setFileName(safeFilename);
task.setFileSize(request.getFileSize());
task.setChunkSize(chunkSize);
task.setChunkCount(chunkCount);
task.setChunkIndex(-1);
task.setBucketName(minIOConfig.getBucketName());
task.setObjectName(buildObjectName(
request.getFileCategory(), safeFilename));
task.setFileCategory(request.getFileCategory());
task.setMd5(request.getMd5());
task.setRefType(request.getRefType());
task.setSubType(request.getSubType());
task.setRefId(request.getRefId());
task.setUploadUser(getCurrentUsername());
task.setStatus(0);
task.setCreateTime(new Date());
task.setUpdateTime(new Date());
sysFileChunkMapper.insert(task);
} else {
// 恢复任务时必须核对归属和任务参数
assertTaskOwner(task);
if (!task.getFileSize().equals(request.getFileSize())
|| !task.getChunkSize().equals(
request.getChunkSize())) {
throw new IllegalArgumentException(
"续传参数与原上传任务不一致");
}
}
List<Integer> uploadedChunks =
sysFileChunkMapper.findUploadedChunkIndexes(
uploadId);
ChunkInitResponse response = new ChunkInitResponse();
response.setUploadId(uploadId);
response.setChunkSize(task.getChunkSize());
response.setChunkCount(task.getChunkCount());
response.setUploadedChunks(uploadedChunks);
return response;
}
查询已上传分片:
sql
SELECT DISTINCT chunk_index
FROM sys_file_chunk
WHERE upload_id = #{uploadId}
AND status = 1
AND chunk_index >= 0
ORDER BY chunk_index;
断点续传到底续的是什么
断点续传并不意味着服务器一直保存着浏览器状态。
刷新页面后,用户需要重新选择同一个本地文件。客户端重新计算 MD5,然后再次调用初始化接口:
text
同一文件
→ 同一文件指纹
→ 找回上传任务
→ 获取已上报分片列表
→ 只上传缺失分片
预签名 URL 过期不会影响续传,因为 URL 本来就不需要长期保存,重新申请即可。
但数据库中的"已上报"只是任务状态,不一定代表对象此刻仍然存在。因此,在合并前仍需要校验对象,或者让合并操作失败并允许重新补传。
多用户环境下不要只使用 MD5 作为全局任务 ID
如果直接令:
java
uploadId = md5;
不同用户上传相同文件时可能命中同一个任务。
生产环境可以选择:
- 使用用户维度、文件指纹和文件大小共同定位任务;
- 服务端生成随机
uploadId,另建"用户 + 文件指纹"任务索引; - 所有
presign、report、complete操作都校验任务归属。
不能只依赖"用户已经登录",登录不代表可以操作其他用户的上传任务。
八、签发分片预签名 URL
MinIO 生成分片 PUT URL:
java
import io.minio.GetPresignedObjectUrlArgs;
import io.minio.http.Method;
import java.util.concurrent.TimeUnit;
private String generateChunkUrl(
String bucket,
String uploadId,
Integer chunkIndex) throws Exception {
String objectName =
"chunks/" + uploadId + "/part-" + chunkIndex;
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket(bucket)
.object(objectName)
.expiry(10, TimeUnit.MINUTES)
.build());
}
批量签发前必须校验:
java
public List<String> getChunkPresignedUrls(
String uploadId,
List<Integer> chunkIndexes) throws Exception {
SysFileChunk task =
sysFileChunkMapper.findInitRecord(uploadId);
if (task == null) {
throw new IllegalArgumentException("上传任务不存在");
}
assertTaskOwner(task);
if (chunkIndexes == null
|| chunkIndexes.isEmpty()
|| chunkIndexes.size() > 10) {
throw new IllegalArgumentException("分片索引列表不合法");
}
List<String> urls = new ArrayList<>();
for (Integer index : chunkIndexes) {
if (index == null
|| index < 0
|| index >= task.getChunkCount()) {
throw new IllegalArgumentException(
"分片索引越界: " + index);
}
urls.add(generateChunkUrl(
task.getBucketName(), uploadId, index));
}
return urls;
}
返回的 URL 顺序必须与请求中的 chunkIndexes 顺序一致。相比单独返回字符串数组,更严谨的协议可以直接返回映射:
json
[
{
"chunkIndex": 3,
"url": "http://minio.example/..."
},
{
"chunkIndex": 4,
"url": "http://minio.example/..."
}
]
这样客户端不需要自行推断 URL 与索引之间的关系。
如果浏览器直接 PUT MinIO,还需要正确配置 MinIO 的跨域策略,允许前端来源使用 PUT。预签名 URL 只能解决授权问题,不能替代 CORS 配置。
九、分片上报:不要只 insert 一条"成功记录"
客户端 PUT 成功后,调用:
json
{
"uploadId": "file-md5",
"chunkIndex": 3
}
服务端至少需要完成:
- 校验任务存在及归属;
- 校验索引范围;
- 计算该分片的预期大小;
- 使用
statObject检查对象及实际大小; - 幂等保存分片状态。
计算预期大小:
java
private long expectedChunkSize(
SysFileChunk task,
int chunkIndex) {
if (chunkIndex < task.getChunkCount() - 1) {
return task.getChunkSize();
}
// 最后一个分片可能小于chunkSize
return task.getFileSize()
- (long) chunkIndex * task.getChunkSize();
}
上报代码:
java
public void reportChunkUploaded(
String uploadId,
Integer chunkIndex) throws Exception {
SysFileChunk task =
sysFileChunkMapper.findInitRecord(uploadId);
if (task == null) {
throw new IllegalArgumentException("上传任务不存在");
}
assertTaskOwner(task);
if (chunkIndex == null
|| chunkIndex < 0
|| chunkIndex >= task.getChunkCount()) {
throw new IllegalArgumentException("分片索引越界");
}
String objectName =
"chunks/" + uploadId + "/part-" + chunkIndex;
StatObjectResponse stat = minioClient.statObject(
StatObjectArgs.builder()
.bucket(task.getBucketName())
.object(objectName)
.build());
long expectedSize =
expectedChunkSize(task, chunkIndex);
if (stat.size() != expectedSize) {
throw new IllegalStateException(
"分片大小不一致,expected="
+ expectedSize
+ ", actual=" + stat.size());
}
SysFileChunk chunk = new SysFileChunk();
chunk.setUploadId(uploadId);
chunk.setChunkIndex(chunkIndex);
chunk.setObjectName(objectName);
chunk.setBucketName(task.getBucketName());
chunk.setStatus(1);
chunk.setCreateTime(new Date());
chunk.setUpdateTime(new Date());
// 推荐通过唯一键实现insert或update,避免重复上报产生重复记录
sysFileChunkMapper.insertOrUpdateByUploadIdAndIndex(
chunk);
}
chunkMd5 可以作为辅助元数据保存,但要注意:
把客户端提交的分片 MD5 存进数据库,不等于服务端已经校验了 MD5。
真正校验需要读取对象内容并重新计算,或者使用可信的上传校验机制。
十、合并分片:必须验证精确索引集合
不严谨的合并逻辑通常是这样:
java
if (uploadedChunks.size() >= chunkCount) {
// 开始合并
}
这存在问题:
- 同一个索引可能重复上报;
- 可能存在越界索引;
- 数量足够不代表
0到chunkCount - 1每一个都存在。
正确方式是验证精确集合:
java
private void validateCompletedChunks(
SysFileChunk task,
List<Integer> uploadedChunks) {
Set<Integer> actual = new HashSet<>(uploadedChunks);
Set<Integer> expected = IntStream.range(
0, task.getChunkCount())
.boxed()
.collect(Collectors.toSet());
if (!actual.equals(expected)) {
Set<Integer> missing = new HashSet<>(expected);
missing.removeAll(actual);
throw new IllegalStateException(
"分片尚未上传完成,缺失分片: " + missing);
}
}
然后严格按索引顺序合并:
java
public FileResponse completeChunkUpload(
String uploadId) throws Exception {
SysFileChunk task =
sysFileChunkMapper.findInitRecord(uploadId);
if (task == null) {
throw new IllegalArgumentException("上传任务不存在");
}
assertTaskOwner(task);
List<Integer> uploaded =
sysFileChunkMapper.findUploadedChunkIndexes(
uploadId);
validateCompletedChunks(task, uploaded);
String bucket = task.getBucketName();
List<ComposeSource> sources = new ArrayList<>();
for (int index = 0;
index < task.getChunkCount();
index++) {
sources.add(
ComposeSource.builder()
.bucket(bucket)
.object("chunks/" + uploadId
+ "/part-" + index)
.build());
}
// 1. 在MinIO服务端合并,无需把分片下载到Spring Boot
minioClient.composeObject(
ComposeObjectArgs.builder()
.bucket(bucket)
.object(task.getObjectName())
.sources(sources)
.build());
// 2. 至少校验最终对象大小
StatObjectResponse finalStat =
minioClient.statObject(
StatObjectArgs.builder()
.bucket(bucket)
.object(task.getObjectName())
.build());
if (finalStat.size() != task.getFileSize()) {
throw new IllegalStateException(
"合并后文件大小与原始文件不一致");
}
// 3. 严格完整性要求下,在这里重新计算最终文件MD5
// verifyFinalMd5IfNecessary(task);
// 4. 保存sys_file及业务引用,并记录任务完成状态
SysFile savedFile = saveCompletedFileAndReference(task);
// 5. 文件元数据保存成功后,再清理临时分片
cleanupUploadedChunks(task, uploaded);
return toFileResponse(savedFile);
}
为什么顺序必须是"保存文件后再清分片"
如果先删除分片,再执行数据库插入:
text
合并成功
→ 删除全部分片
→ 数据库插入失败
此时既没有正式文件记录,也失去了重新合并所需的临时分片,任务很难恢复。
更稳妥的顺序是:
text
合并成功
→ 校验最终对象
→ 保存正式文件元数据
→ 标记任务完成
→ 清理临时分片
临时分片清理失败可以重试,但正式文件元数据不能丢失。
合并后的 ETag 不一定等于完整文件 MD5
对于复合对象,不能简单认为:
java
finalStat.etag().equals(originalMd5)
如果必须验证最终 MD5,可以流式读取最终对象:
java
private String calculateObjectMd5(
String bucket,
String objectName) throws Exception {
try (InputStream inputStream =
minioClient.getObject(
GetObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.build())) {
return DigestUtils.md5DigestAsHex(inputStream);
}
}
但这种方式会让完整文件经过应用服务器一次。对于超大文件,可以根据业务要求选择:
- 合并后同步校验;
- 先标记为待校验,再异步校验;
- 使用更适合云存储的校验机制;
- 至少强制校验对象大小和分片完整性。
十一、过期分片清理:removeObject 不会递归删除前缀
假设分片对象名为:
text
chunks/task-a/part-0
chunks/task-a/part-1
chunks/task-a/part-2
下面的代码:
java
minioClient.removeObject(
RemoveObjectArgs.builder()
.bucket(bucket)
.object("chunks/task-a")
.build());
只表示删除名为 chunks/task-a 的单个对象,不会递归删除该前缀下的所有分片。
而且删除一个不存在的对象也可能不抛出明显异常,因此"删除方法执行成功"不等于分片已经清理。
清理时应根据数据库中保存的对象名逐个或批量删除:
java
public void cleanupExpiredTask(String uploadId)
throws Exception {
List<SysFileChunk> chunks =
sysFileChunkMapper.listByUploadId(uploadId);
List<DeleteObject> objects = chunks.stream()
.filter(chunk -> chunk.getChunkIndex() >= 0)
.map(chunk -> new DeleteObject(
chunk.getObjectName()))
.toList();
if (!objects.isEmpty()) {
Iterable<Result<DeleteError>> results =
minioClient.removeObjects(
RemoveObjectsArgs.builder()
.bucket(chunks.get(0).getBucketName())
.objects(objects)
.build());
// 必须遍历结果,删除错误发生在迭代时
for (Result<DeleteError> result : results) {
DeleteError error = result.get();
throw new IllegalStateException(
"分片删除失败: "
+ error.objectName());
}
}
// 对象清理成功后再删除任务记录
sysFileChunkMapper.deleteByUploadId(uploadId);
}
清理任务可以每小时执行一次:
java
@Scheduled(cron = "0 0 * * * ?")
public void cleanupExpiredFileChunks() {
Date expireTime = Date.from(
Instant.now().minus(72, ChronoUnit.HOURS));
List<String> uploadIds =
sysFileChunkMapper.findExpiredUploadIds(expireTime);
for (String uploadId : uploadIds) {
try {
cleanupExpiredTask(uploadId);
} catch (Exception e) {
// 保留任务记录,等待下一轮重试
log.error("清理过期分片任务失败: {}", uploadId, e);
}
}
}
多实例部署时,还需要分布式锁或任务调度中心,避免多个节点重复执行同一批清理任务。
过期时间最好根据任务的 update_time 判断,避免一个仍在持续上传、但初始化记录创建较早的任务被误清理。
十二、引用计数为什么不能作为唯一删除依据
常见做法是在 sys_file 中维护:
text
ref_count
新增引用时:
sql
UPDATE sys_file
SET ref_count = ref_count + 1
WHERE id = #{fileId};
解除引用时:
sql
UPDATE sys_file
SET ref_count = ref_count - 1
WHERE id = #{fileId};
这两条 SQL 本身是原子的,但原子加减不等于业务语义正确。
以下情况都可能导致计数漂移:
- 同一个引用重复绑定,每次仍执行
+1; - 没有实际创建引用记录,也执行
+1; - 删除时没有解除任何引用,仍执行
-1; - 业务异常重试导致重复加减;
- 历史业务直接保存 fileId,没有同步维护计数;
- 计数已经降为负数,仍继续递减。
因此建议:
ref_count只作为展示或性能缓存,文件是否可以物理删除,必须重新查询有效引用关系。
解除引用时,应先删除指定业务引用,再统计剩余引用:
java
@Transactional(rollbackFor = Exception.class)
public void removeReference(
Long fileId,
String refType,
String refId) {
// 锁定文件记录,降低解引用与秒传之间的竞争
SysFile file = sysFileMapper.selectByIdForUpdate(fileId);
if (file == null || file.getDel() == 1) {
return;
}
// 只解除当前业务引用,并按影响行数判断是否重复删除
int affected = sysFileRefMapper.softDeleteByBusiness(
fileId, refType, refId);
if (affected == 0) {
return;
}
int remaining =
sysFileRefMapper.countActiveReferences(fileId);
if (remaining == 0) {
// 简单实现可在这里进入物理删除流程
// 更稳妥的方式是生成待删除任务,由后台worker重试
createPhysicalDeleteTask(fileId);
}
}
不要重复累加两套引用证据
有些业务既存在:
text
sys_file_ref → fileId
又存在:
text
business_table.file_id → fileId
如果实际引用统计直接计算:
text
引用表记录数 + 业务表记录数
同一笔业务可能被计算两次。
更合理的方式是明确一种主引用模型:
- 以
sys_file_ref为主,业务表 fileId 只用于查询和核验; - 或者不维护通用引用表,统一从各业务表汇总;
- 如果两套数据都保留,应定义对账关系,而不是简单相加。
引用数偏大通常导致文件无法及时回收;引用数偏小则可能导致仍在使用的文件被误删。后者更危险。
十三、秒传"假成功":数据库有记录,MinIO 文件却没了
1. 故障复现
假设第一次上传后:
text
sys_file:
md5 = abc
file_size = 1024
object_name = document/a.pdf
del = 0
随后有人直接在 MinIO 控制台删除了 document/a.pdf,但没有清理数据库。
再次上传相同文件:
- 前端计算得到相同 MD5;
- 后端按 MD5 + 文件大小查到旧记录;
- 后端直接返回原 fileId 和下载地址;
- 前端显示"秒传成功";
- 用户真正下载时才发现文件不存在。
2. 为什么重新上传也不能自动恢复
因为直传去重分支通常是:
java
if (existing != null) {
return toFileResponse(existing);
}
只要数据库命中,就直接返回,不再执行 putObject()。
所以即使客户端这次真的上传了完整字节,后端也可能没有使用这些字节修复缺失对象。
3. 预签名 URL 不能证明对象存在
下面这段代码只是在本地生成签名:
java
String url = minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(file.getBucketName())
.object(file.getObjectName())
.expiry(1, TimeUnit.HOURS)
.build());
URL 生成成功只表示签名过程成功,并不表示 MinIO 中已经存在该对象。
4. 秒传命中前增加对象存在性校验
java
private boolean objectExists(SysFile file)
throws Exception {
try {
minioClient.statObject(
StatObjectArgs.builder()
.bucket(file.getBucketName())
.object(file.getObjectName())
.build());
return true;
} catch (ErrorResponseException e) {
if (e.response() != null
&& e.response().code() == 404) {
return false;
}
throw e;
}
}
秒传逻辑改为:
java
public FileResponse checkDuplicate(
String md5,
Long fileSize,
String refType,
String subType,
String refId) throws Exception {
SysFile existing =
sysFileMapper.findByMd5AndSize(md5, fileSize);
if (existing == null) {
return null;
}
if (!objectExists(existing)) {
// 不能继续返回秒传成功
// 也不建议简单返回null后新建记录,把旧异常数据继续留在库里
log.error(
"文件元数据存在但MinIO对象缺失,fileId={}, objectName={}",
existing.getId(),
existing.getObjectName());
throw new IllegalStateException(
"文件存储状态异常,请重新上传或联系管理员修复");
}
fileReferenceService.bindIfAbsent(
existing.getId(), refType, subType, refId);
return toFileResponse(existing);
}
这会在秒传时多一次 statObject 请求,但能避免把已知异常文件继续分发给业务。
如果希望自动修复,应设计明确的恢复流程:
- 找出所有受影响的业务引用;
- 使用本次真实上传的文件内容恢复原对象;
- 校验恢复后的大小和 MD5;
- 恢复原文件记录状态;
- 记录修复日志。
不能只是查不到对象就随意删除数据库记录,因为该记录可能仍被多个业务引用。
5. 孤儿扫描需要双向进行
常见扫描只检查:
text
MinIO 有对象,数据库没有记录
这能发现"孤儿对象",却不能发现本文的"缺失对象"。
完整对账至少要覆盖:
| 方向 | 异常 | 处理方式 |
|---|---|---|
| MinIO → DB | 对象存在,数据库无记录 | 延迟确认后清理 |
| DB → MinIO | 有效文件记录存在,对象不存在 | 告警、修复或重新上传 |
| 引用表 → 文件表 | 引用指向不存在的 fileId | 清理悬空引用 |
| 业务表 → 引用表 | 两套引用数据不一致 | 按既定主模型校准 |
十四、数据库事务管不住 MinIO
下面这段代码即使加了 @Transactional,也不代表上传具备跨系统原子性:
java
@Transactional
public void upload() {
minioClient.putObject(...); // MinIO不参与数据库事务
sysFileMapper.insert(...); // 这里失败只回滚数据库
}
可能出现:
text
MinIO上传成功
数据库插入失败
数据库回滚
MinIO对象残留
删除时则可能出现相反情况:
text
MinIO删除成功
数据库更新失败
数据库回滚
有效文件记录指向已删除对象
因此,不能把 @Transactional 当作文件一致性的完整答案。
生产环境可以使用以下思路:
- 上传先生成对象,再落库,失败时记录补偿删除任务;
- 删除先生成待删除任务,再由后台 Worker 执行;
- 所有远程操作记录明确状态和重试次数;
- 定期执行数据库与 MinIO 双向对账;
- 清理任务失败时保留对象名和任务记录,避免失去重试依据;
- 对重要文件采用延迟物理删除,而不是立即删除。
这类方案本质上是在用状态机、补偿和幂等重试替代不存在的跨系统本地事务。
十五、生产环境还需要补齐的细节
1. 秒传预检尽量幂等
重复点击上传、网络重试和页面刷新都可能重复调用预检接口。
不要每次命中都无条件:
java
refCount++;
应该先判断当前业务引用是否已经存在。更干净的设计是:
- 预检接口只读;
- 业务确认使用文件时,再单独绑定引用。
2. 完成接口需要幂等
网络超时时,客户端可能重复调用 complete。
建议任务状态至少包含:
text
UPLOADING
MERGING
COMPLETED
FAILED
同时保存 result_file_id:
- 第一次调用执行合并;
- 后续调用发现任务已完成,直接返回原文件;
- 合并中重复请求应等待或返回处理中;
- 失败任务根据状态决定重试合并还是重新上传。
3. 并发去重需要数据库或锁兜底
两个请求可能同时出现:
text
查询不到相同文件
→ 都上传对象
→ 都插入文件记录
仅依靠"先查询再插入"不能保证全局只保存一份。
需要根据业务选择:
- 文件指纹唯一约束;
- 文件级分布式锁;
- 固定内容寻址对象名;
- 允许短时重复,后台对账合并。
每种方案都要先处理历史重复数据。
4. 所有分片接口都要校验任务归属
不仅仅是初始化:
presign校验;report校验;complete校验;- 过期清理按任务处理。
5. 直传与分传使用一致的文件安全策略
不能出现:
- 小文件直传校验图片魔数;
- 大文件分片只校验扩展名;
- 合并后不再检查。
文件类型、大小、权限和安全扫描策略,应在最终文件进入业务前统一执行。
十六、测试清单
这套功能不能只测试"选一个文件上传成功"。
秒传与直传
- 第一次上传生成新对象和文件记录;
- 相同 MD5、相同大小命中秒传;
- 相同 MD5、不同大小不能简单复用;
- 同一业务重复绑定不会重复增加引用;
- 不同业务引用同一文件不会重复存储对象;
- 数据库有记录、MinIO 无对象时不能返回秒传成功。
分片与续传
- 文件大小恰好是分片大小的整数倍;
- 最后一个分片小于默认分片大小;
- 上传到 30% 中断,重新初始化后跳过已上报分片;
- 预签名 URL 过期后可以重新申请;
- PUT 成功但 report 失败时,可以安全重传;
- 重复 report 不产生重复分片;
- 缺少任意一个分片时禁止合并;
- 出现重复索引或越界索引时禁止合并;
- 合并后文件大小与源文件一致;
- 严格模式下,合并后 MD5 与源文件一致。
引用与删除
- 删除一个引用不会删除仍被其他业务使用的对象;
- 最后一个引用解除后才进入物理删除流程;
- 重复解除引用不会让计数持续减少;
- MinIO 删除失败后任务可重试;
- 过期分片能按真实对象名全部删除;
- 正在持续上传的旧任务不会被误清理。
总结
一套完整的大文件上传方案,不只是"前端切片、后端合并"。
它至少需要同时维护四条链路:
- 字节链路:直传、分片 PUT、合并与完整性校验;
- 任务链路:初始化、断点恢复、完成状态和过期清理;
- 元数据链路:文件记录、对象名、MD5 和文件大小;
- 生命周期链路:业务引用、解除引用、物理删除和异常对账。
其中最容易被忽视的是:
- 预签名 URL 生成成功,不代表对象存在;
- 数据库存了 MD5,不代表服务端校验过 MD5;
- 分片数量足够,不代表索引完整;
refCount能原子加减,不代表计数一定正确;- 方法加了
@Transactional,不代表 MinIO 会一起回滚; - 删除一个前缀名称,不代表前缀下的对象都被删除。
把这些边界处理好,文件上传才从"能传"变成真正可维护、可恢复、可清理的系统能力。