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

text 复制代码
浏览器 → Spring Boot → MinIO

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

更合理的方式是:

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

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

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

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。

原因是:

  1. 前端可能绕过预检直接调用上传接口;
  2. 客户端提交的 MD5 不可信;
  3. 秒传检查与实际上传之间存在并发窗口。

不要使用 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;

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

生产环境可以选择:

  1. 使用用户维度、文件指纹和文件大小共同定位任务;
  2. 服务端生成随机 uploadId,另建"用户 + 文件指纹"任务索引;
  3. 所有 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
}

服务端至少需要完成:

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

计算预期大小:

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,但没有清理数据库。

再次上传相同文件:

  1. 前端计算得到相同 MD5;
  2. 后端按 MD5 + 文件大小查到旧记录;
  3. 后端直接返回原 fileId 和下载地址;
  4. 前端显示"秒传成功";
  5. 用户真正下载时才发现文件不存在。

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 请求,但能避免把已知异常文件继续分发给业务。

如果希望自动修复,应设计明确的恢复流程:

  1. 找出所有受影响的业务引用;
  2. 使用本次真实上传的文件内容恢复原对象;
  3. 校验恢复后的大小和 MD5;
  4. 恢复原文件记录状态;
  5. 记录修复日志。

不能只是查不到对象就随意删除数据库记录,因为该记录可能仍被多个业务引用。

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 删除失败后任务可重试;
  • 过期分片能按真实对象名全部删除;
  • 正在持续上传的旧任务不会被误清理。

总结

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

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

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

其中最容易被忽视的是:

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

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

相关推荐
行者-全栈开发1 小时前
【码动四季·秋】把 Spring Boot 的 Tomcat 换成 Undertow:一次容器替换的三处暗坑
spring boot·tomcat·容器化·依赖管理·undertow
长弓三石1 小时前
Spring Boot 4.1 + AgentScope:企业级 Agent 平台的 MCP 双向集成实战
人工智能·spring boot·后端·harness·开源智能体
云和数据.ChenGuang9 小时前
Python 重写(Override)深度解析:从 MRO 到 super() 的完整机制
java·开发语言·人工智能·python·langchain·1024程序员节
摇滚侠9 小时前
《On Java 中文版 基础卷》阅读笔记 实现隐藏 12
java·数据库·笔记
silvamux_12 小时前
10款模型写种草笔记:可发布性、返工来源与成本数据缺口复盘
java·数据库·笔记
步行cgn13 小时前
StringHttpMessageConverter 详解:Spring MVC 中字符串读写的默认转换器
java·spring·mvc
摇滚侠13 小时前
《On Java 中文版 基础卷》阅读笔记 实现隐藏 11
java·笔记·python
小蒜学长13 小时前
基于Spring Boot+Vue3的在线医疗咨询系统的设计与实现(代码+数据库+LW)
java·spring boot·后端·在线问诊·在线医疗咨询系统
Deng桑13 小时前
SpringAI写第一个Agent-把工单循环跑起来
java·agent·工具调用·spring ai