首页
学习
活动
专区
圈层
工具
发布

SpringBoot 接 MinIO,大文件上传别再经过后端中转了

一个 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 从头到尾都不碰文件正文。它只管权限、状态和最终确认,这才是大文件上传该有的分工。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/OTVSNzoxSqv4fX_78T9HrlUg0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券