欢迎光临
我们一直在努力

Spring Boot + MinIO 大文件上传完整实战:MD5 秒传、分片上传、断点续传与引用一致性

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,会产生两次网络传输:

浏览器 → Spring Boot → MinIO

这会增加后端带宽、连接数和内存压力。

更合理的方式是:

浏览器 ──①申请预签名URL──> Spring Boot
浏览器 ──②PUT分片────────> MinIO
浏览器 ──③上报分片完成───> Spring Boot
浏览器 ──④请求合并───────> Spring Boot
Spring Boot ─⑤合并对象───> MinIO

Spring Boot 不接收分片字节,只负责:

  • 创建和恢复上传任务;
  • 校验分片索引;
  • 签发短时效预签名 PUT URL;
  • 保存分片完成状态;
  • 合并分片;
  • 保存文件元数据和业务引用。
  • 3. 完整上传流程

    选择本地文件
    │
    ▼
    增量计算文件 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 删除标记

    去重查询使用:

    SELECT *
    FROM sys_file
    WHERE md5 = #{md5}
    AND file_size = #{fileSize}
    AND del = 0
    LIMIT 1;

    索引:

    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 用于过期清理

    分片对象命名:

    chunks/{uploadId}/part-0
    chunks/{uploadId}/part-1
    chunks/{uploadId}/part-2

    建议为分片记录增加唯一约束:

    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 删除标记

    例如:

    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 校验并合并所有分片

    初始化请求:

    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:

    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,按块读取并增量计算。

    安装:

    npm install spark-md5

    核心代码:

    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,服务端仍要校验文件大小,关键场景还要重新计算文件摘要。

    五、前端完整上传编排

    下面的代码把秒传、直传、分片和断点续传串起来。

    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)
    }
    }

    分片上传部分:

    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:

    const response = await fetch(url, options)

    即使返回 403、404 或 500,fetch() 本身也不会自动进入 catch。

    第二,必须先 PUT 成功,再调用 report。如果先上报再上传,网络中断后数据库会把未完成分片标记为已完成,续传时就会错误跳过它。

    六、小文件直传与服务端 MD5 去重

    小文件虽然经过了前端秒传检查,后端仍然需要自己计算 MD5。

    原因是:

  • 前端可能绕过预检直接调用上传接口;
  • 客户端提交的 MD5 不可信;
  • 秒传检查与实际上传之间存在并发窗口。
  • 不要使用 file.getBytes() 计算大文件 MD5,因为它会将完整文件读入内存。可以使用输入流:

    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);
    }
    }

    直传核心流程:

    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);
    }

    对象名建议由服务端生成:

    private String buildObjectName(
    String fileCategory,
    String originalFilename) {

    String extension = getExtension(originalFilename);
    String date = LocalDate.now().toString();

    return fileCategory + "/"
    + date + "/"
    + UUID.randomUUID()
    + (extension.isEmpty() ? "" : "." + extension);
    }

    这样既能避免路径穿越,也不会因为原始文件名相同而互相覆盖。

    七、分片初始化:新任务与断点恢复使用同一个接口

    分片数量由服务端计算:

    int chunkCount = (int) Math.ceil(
    (double) fileSize / chunkSize);

    例如:

    文件大小:12 MiB
    分片大小:5 MiB

    part-0:5 MiB
    part-1:5 MiB
    part-2:2 MiB

    chunkCount = 3

    初始化核心代码:

    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;
    }

    查询已上传分片:

    SELECT DISTINCT chunk_index
    FROM sys_file_chunk
    WHERE upload_id = #{uploadId}
    AND status = 1
    AND chunk_index >= 0
    ORDER BY chunk_index;

    断点续传到底续的是什么

    断点续传并不意味着服务器一直保存着浏览器状态。

    刷新页面后,用户需要重新选择同一个本地文件。客户端重新计算 MD5,然后再次调用初始化接口:

    同一文件
    → 同一文件指纹
    → 找回上传任务
    → 获取已上报分片列表
    → 只上传缺失分片

    预签名 URL 过期不会影响续传,因为 URL 本来就不需要长期保存,重新申请即可。

    但数据库中的“已上报”只是任务状态,不一定代表对象此刻仍然存在。因此,在合并前仍需要校验对象,或者让合并操作失败并允许重新补传。

    多用户环境下不要只使用 MD5 作为全局任务 ID

    如果直接令:

    uploadId = md5;

    不同用户上传相同文件时可能命中同一个任务。

    生产环境可以选择:

  • 使用用户维度、文件指纹和文件大小共同定位任务;
  • 服务端生成随机 uploadId,另建“用户 + 文件指纹”任务索引;
  • 所有 presign、report、complete 操作都校验任务归属。
  • 不能只依赖“用户已经登录”,登录不代表可以操作其他用户的上传任务。

    八、签发分片预签名 URL

    MinIO 生成分片 PUT URL:

    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());
    }

    批量签发前必须校验:

    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 顺序一致。相比单独返回字符串数组,更严谨的协议可以直接返回映射:

    [
    {
    "chunkIndex": 3,
    "url": "http://minio.example/…"
    },
    {
    "chunkIndex": 4,
    "url": "http://minio.example/…"
    }
    ]

    这样客户端不需要自行推断 URL 与索引之间的关系。

    如果浏览器直接 PUT MinIO,还需要正确配置 MinIO 的跨域策略,允许前端来源使用 PUT。预签名 URL 只能解决授权问题,不能替代 CORS 配置。

    九、分片上报:不要只 insert 一条“成功记录”

    客户端 PUT 成功后,调用:

    {
    "uploadId": "file-md5",
    "chunkIndex": 3
    }

    服务端至少需要完成:

  • 校验任务存在及归属;
  • 校验索引范围;
  • 计算该分片的预期大小;
  • 使用 statObject 检查对象及实际大小;
  • 幂等保存分片状态。
  • 计算预期大小:

    private long expectedChunkSize(
    SysFileChunk task,
    int chunkIndex) {

    if (chunkIndex < task.getChunkCount() – 1) {
    return task.getChunkSize();
    }

    // 最后一个分片可能小于chunkSize
    return task.getFileSize()
    – (long) chunkIndex * task.getChunkSize();
    }

    上报代码:

    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。

    真正校验需要读取对象内容并重新计算,或者使用可信的上传校验机制。

    十、合并分片:必须验证精确索引集合

    不严谨的合并逻辑通常是这样:

    if (uploadedChunks.size() >= chunkCount) {
    // 开始合并
    }

    这存在问题:

    • 同一个索引可能重复上报;
    • 可能存在越界索引;
    • 数量足够不代表 0 到 chunkCount – 1 每一个都存在。

    正确方式是验证精确集合:

    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);
    }
    }

    然后严格按索引顺序合并:

    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);
    }

    为什么顺序必须是“保存文件后再清分片”

    如果先删除分片,再执行数据库插入:

    合并成功
    → 删除全部分片
    → 数据库插入失败

    此时既没有正式文件记录,也失去了重新合并所需的临时分片,任务很难恢复。

    更稳妥的顺序是:

    合并成功
    → 校验最终对象
    → 保存正式文件元数据
    → 标记任务完成
    → 清理临时分片

    临时分片清理失败可以重试,但正式文件元数据不能丢失。

    合并后的 ETag 不一定等于完整文件 MD5

    对于复合对象,不能简单认为:

    finalStat.etag().equals(originalMd5)

    如果必须验证最终 MD5,可以流式读取最终对象:

    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 不会递归删除前缀

    假设分片对象名为:

    chunks/task-a/part-0
    chunks/task-a/part-1
    chunks/task-a/part-2

    下面的代码:

    minioClient.removeObject(
    RemoveObjectArgs.builder()
    .bucket(bucket)
    .object("chunks/task-a")
    .build());

    只表示删除名为 chunks/task-a 的单个对象,不会递归删除该前缀下的所有分片。

    而且删除一个不存在的对象也可能不抛出明显异常,因此“删除方法执行成功”不等于分片已经清理。

    清理时应根据数据库中保存的对象名逐个或批量删除:

    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);
    }

    清理任务可以每小时执行一次:

    @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 中维护:

    ref_count

    新增引用时:

    UPDATE sys_file
    SET ref_count = ref_count + 1
    WHERE id = #{fileId};

    解除引用时:

    UPDATE sys_file
    SET ref_count = ref_count – 1
    WHERE id = #{fileId};

    这两条 SQL 本身是原子的,但原子加减不等于业务语义正确。

    以下情况都可能导致计数漂移:

    • 同一个引用重复绑定,每次仍执行 +1;
    • 没有实际创建引用记录,也执行 +1;
    • 删除时没有解除任何引用,仍执行 -1;
    • 业务异常重试导致重复加减;
    • 历史业务直接保存 fileId,没有同步维护计数;
    • 计数已经降为负数,仍继续递减。

    因此建议:

    ref_count 只作为展示或性能缓存,文件是否可以物理删除,必须重新查询有效引用关系。

    解除引用时,应先删除指定业务引用,再统计剩余引用:

    @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);
    }
    }

    不要重复累加两套引用证据

    有些业务既存在:

    sys_file_ref → fileId

    又存在:

    business_table.file_id → fileId

    如果实际引用统计直接计算:

    引用表记录数 + 业务表记录数

    同一笔业务可能被计算两次。

    更合理的方式是明确一种主引用模型:

    • 以 sys_file_ref 为主,业务表 fileId 只用于查询和核验;
    • 或者不维护通用引用表,统一从各业务表汇总;
    • 如果两套数据都保留,应定义对账关系,而不是简单相加。

    引用数偏大通常导致文件无法及时回收;引用数偏小则可能导致仍在使用的文件被误删。后者更危险。

    十三、秒传“假成功”:数据库有记录,MinIO 文件却没了

    1. 故障复现

    假设第一次上传后:

    sys_file:
    md5 = abc
    file_size = 1024
    object_name = document/a.pdf
    del = 0

    随后有人直接在 MinIO 控制台删除了 document/a.pdf,但没有清理数据库。

    再次上传相同文件:

  • 前端计算得到相同 MD5;
  • 后端按 MD5 + 文件大小查到旧记录;
  • 后端直接返回原 fileId 和下载地址;
  • 前端显示“秒传成功”;
  • 用户真正下载时才发现文件不存在。
  • 2. 为什么重新上传也不能自动恢复

    因为直传去重分支通常是:

    if (existing != null) {
    return toFileResponse(existing);
    }

    只要数据库命中,就直接返回,不再执行 putObject()。

    所以即使客户端这次真的上传了完整字节,后端也可能没有使用这些字节修复缺失对象。

    3. 预签名 URL 不能证明对象存在

    下面这段代码只是在本地生成签名:

    String url = minioClient.getPresignedObjectUrl(
    GetPresignedObjectUrlArgs.builder()
    .method(Method.GET)
    .bucket(file.getBucketName())
    .object(file.getObjectName())
    .expiry(1, TimeUnit.HOURS)
    .build());

    URL 生成成功只表示签名过程成功,并不表示 MinIO 中已经存在该对象。

    4. 秒传命中前增加对象存在性校验

    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;
    }
    }

    秒传逻辑改为:

    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. 孤儿扫描需要双向进行

    常见扫描只检查:

    MinIO 有对象,数据库没有记录

    这能发现“孤儿对象”,却不能发现本文的“缺失对象”。

    完整对账至少要覆盖:

    方向异常处理方式
    MinIO → DB 对象存在,数据库无记录 延迟确认后清理
    DB → MinIO 有效文件记录存在,对象不存在 告警、修复或重新上传
    引用表 → 文件表 引用指向不存在的 fileId 清理悬空引用
    业务表 → 引用表 两套引用数据不一致 按既定主模型校准

    十四、数据库事务管不住 MinIO

    下面这段代码即使加了 @Transactional,也不代表上传具备跨系统原子性:

    @Transactional
    public void upload() {
    minioClient.putObject(...); // MinIO不参与数据库事务
    sysFileMapper.insert(...); // 这里失败只回滚数据库
    }

    可能出现:

    MinIO上传成功
    数据库插入失败
    数据库回滚
    MinIO对象残留

    删除时则可能出现相反情况:

    MinIO删除成功
    数据库更新失败
    数据库回滚
    有效文件记录指向已删除对象

    因此,不能把 @Transactional 当作文件一致性的完整答案。

    生产环境可以使用以下思路:

    • 上传先生成对象,再落库,失败时记录补偿删除任务;
    • 删除先生成待删除任务,再由后台 Worker 执行;
    • 所有远程操作记录明确状态和重试次数;
    • 定期执行数据库与 MinIO 双向对账;
    • 清理任务失败时保留对象名和任务记录,避免失去重试依据;
    • 对重要文件采用延迟物理删除,而不是立即删除。

    这类方案本质上是在用状态机、补偿和幂等重试替代不存在的跨系统本地事务。

    十五、生产环境还需要补齐的细节

    1. 秒传预检尽量幂等

    重复点击上传、网络重试和页面刷新都可能重复调用预检接口。

    不要每次命中都无条件:

    refCount++;

    应该先判断当前业务引用是否已经存在。更干净的设计是:

    • 预检接口只读;
    • 业务确认使用文件时,再单独绑定引用。

    2. 完成接口需要幂等

    网络超时时,客户端可能重复调用 complete。

    建议任务状态至少包含:

    UPLOADING
    MERGING
    COMPLETED
    FAILED

    同时保存 result_file_id:

    • 第一次调用执行合并;
    • 后续调用发现任务已完成,直接返回原文件;
    • 合并中重复请求应等待或返回处理中;
    • 失败任务根据状态决定重试合并还是重新上传。

    3. 并发去重需要数据库或锁兜底

    两个请求可能同时出现:

    查询不到相同文件
    → 都上传对象
    → 都插入文件记录

    仅依靠“先查询再插入”不能保证全局只保存一份。

    需要根据业务选择:

    • 文件指纹唯一约束;
    • 文件级分布式锁;
    • 固定内容寻址对象名;
    • 允许短时重复,后台对账合并。

    每种方案都要先处理历史重复数据。

    4. 所有分片接口都要校验任务归属

    不仅仅是初始化:

    • presign 校验;
    • report 校验;
    • complete 校验;
    • 过期清理按任务处理。

    5. 直传与分传使用一致的文件安全策略

    不能出现:

    • 小文件直传校验图片魔数;
    • 大文件分片只校验扩展名;
    • 合并后不再检查。

    文件类型、大小、权限和安全扫描策略,应在最终文件进入业务前统一执行。

    十六、测试清单

    这套功能不能只测试“选一个文件上传成功”。

    秒传与直传

    • 第一次上传生成新对象和文件记录;
    • 相同 MD5、相同大小命中秒传;
    • 相同 MD5、不同大小不能简单复用;
    • 同一业务重复绑定不会重复增加引用;
    • 不同业务引用同一文件不会重复存储对象;
    • 数据库有记录、MinIO 无对象时不能返回秒传成功。

    分片与续传

    • 文件大小恰好是分片大小的整数倍;
    • 最后一个分片小于默认分片大小;
    • 上传到 30% 中断,重新初始化后跳过已上报分片;
    • 预签名 URL 过期后可以重新申请;
    • PUT 成功但 report 失败时,可以安全重传;
    • 重复 report 不产生重复分片;
    • 缺少任意一个分片时禁止合并;
    • 出现重复索引或越界索引时禁止合并;
    • 合并后文件大小与源文件一致;
    • 严格模式下,合并后 MD5 与源文件一致。

    引用与删除

    • 删除一个引用不会删除仍被其他业务使用的对象;
    • 最后一个引用解除后才进入物理删除流程;
    • 重复解除引用不会让计数持续减少;
    • MinIO 删除失败后任务可重试;
    • 过期分片能按真实对象名全部删除;
    • 正在持续上传的旧任务不会被误清理。

    总结

    一套完整的大文件上传方案,不只是“前端切片、后端合并”。

    它至少需要同时维护四条链路:

  • 字节链路:直传、分片 PUT、合并与完整性校验;
  • 任务链路:初始化、断点恢复、完成状态和过期清理;
  • 元数据链路:文件记录、对象名、MD5 和文件大小;
  • 生命周期链路:业务引用、解除引用、物理删除和异常对账。
  • 其中最容易被忽视的是:

    • 预签名 URL 生成成功,不代表对象存在;
    • 数据库存了 MD5,不代表服务端校验过 MD5;
    • 分片数量足够,不代表索引完整;
    • refCount 能原子加减,不代表计数一定正确;
    • 方法加了 @Transactional,不代表 MinIO 会一起回滚;
    • 删除一个前缀名称,不代表前缀下的对象都被删除。

    把这些边界处理好,文件上传才从“能传”变成真正可维护、可恢复、可清理的系统能力。

    赞(0)
    未经允许不得转载:171主机测评 » Spring Boot + MinIO 大文件上传完整实战:MD5 秒传、分片上传、断点续传与引用一致性
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址