ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java集成MinIO对象存储:上传下载、预签名与分片实践

Java集成MinIO对象存储:上传下载、预签名与分片实践

1. 为什么文件管理这件事值得单独拎出来做

做过业务系统的朋友大概都有类似体会:项目刚起步时,图片、合同附件、导入导出的表格全都往服务器本地磁盘一丢,/data/upload/一个目录走天下,代码里写个File.save就完事。等到机器扩容、容器化部署、多实例负载均衡一上来,你会发现用户头像在A节点能看到、在B节点就是404;再往后要备份、要迁移、要做容量统计,才意识到文件这一层其实早就该抽出来了。

我这几年接手的系统里,凡是需要长期跑、需要横向扩容的,几乎都会把文件存储独立出去,走对象存储的路子。而在自建对象存储的选型里,MinIO是落地成本最低、和Java生态契合度又特别高的一个:单二进制部署、兼容 S3 协议、有成熟的官方 SDK,本地开发直接一个 docker 命令拉起来就能用。这套组合解决的核心问题,就是把"文件存哪里、怎么取、怎么管"从业务代码里剥离出来,变成一个稳定、可水平扩展的基础设施。

这篇文章适合谁看?如果你正在写一个带附件上传的 Java 服务,发现本地磁盘方案开始撑不住了;或者你听说过对象存储但一直没实操过,不知道怎么在 Spring Boot 里把 MinIO 的初始化、上传、下载、分片、临时链接这些东西串起来;又或者你已经能跑通 demo,但线上被桶策略、大文件超时、流没关导致连接池耗尽这些坑坑过——那下面这些内容应该能省你不少调试时间。我会从整体设计思路讲起,再拆核心细节、给出可直接复制的实操代码,最后把我在实际项目里踩到的坑整理成排查表。

需要提前说明,文中所有涉及具体业务、公司、人员的场景,都是基于常见实践做的抽象描述,代码也是从通用做法里提炼的模板,你按自己的项目替换参数即可。

2. 整体设计:Java 与 MinIO 的职责边界怎么划

2.1 先想清楚对象存储和数据库各自该管什么

很多人一开始就把文件对象直接当数据库记录用,结果查个附件列表要遍历桶,或者把二进制塞进数据库的 BLOB 字段,这两种极端都不可取。我比较推荐的分工是这样的:文件的元信息落数据库,文件实体落 MinIO。

元信息包括什么?业务归属(属于哪个订单、哪个用户)、原始文件名、存储对象名(objectName)、大小、MIME 类型、内容摘要、上传时间、状态(临时/正式/已删除)。有了这层,列表分页、按业务查询、软删除、审计都有据可依,而 MinIO 那边只负责按 objectName 存取字节流。这样做的直接好处是:迁移存储时只换 objectName 背后的桶,数据库记录一行都不用动;反过来清理数据也只查库,不用去遍历桶里的对象。

为什么不用 MinIO 的对象标签(tag)来承担业务属性?因为标签查询能力有限、不适合复杂条件组合,而且把业务逻辑耦合到存储层,将来换实现就会很痛。存储层保持"哑"一点,是长期可维护的关键。

2.2 客户端选型:官方 SDK 与 Spring 封装怎么选

Java 侧操作 MinIO 有两条常见路线:一是直接用官方minio-javaSDK,自己封装一个工具类;二是用社区或自研的 starter 做一层 Spring 风格的包装。我的建议是先用官方 SDK 把核心能力跑通,再考虑要不要包一层。理由是官方 SDK 的 API 语义清晰,MinioClient上的putObject、getObject、getPresignedObjectUrl都是一眼能懂的,先理解原语,后面封装才不会被层层抽象挡住视线。

版本上要注意,minio-java8.x 与 7.x 有不兼容变更,8.x 之后引入了io.minio包下的 builder 风格 API。引入方式很简单:

<dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.7</version> </dependency>

选择这个版本的考量是:它对 S3 兼容接口的支持比较完整,分片上传、预签名、桶策略这些我常用的能力都有,而且依赖的 OkHttp 版本较新,长连接复用表现稳定。7.x 不是不能用,但新项目没有理由停在旧版本上纠结。

另一个容易被忽略的点是客户端必须单例复用。MinioClient内部持有一个 OkHttp 的连接池,如果你每次请求都new MinioClient(...),连接池就形同虚设,压测时空闲连接和 TIME_WAIT 会一起爆掉。我见过一个项目在 Service 方法里每次 new,QPS 上到两百就开始偶发超时,改成注入单例后直接恢复。

2.3 桶的规划:按环境分还是按业务分

桶(bucket)是 MinIO 里的顶层隔离单元,命名规则和 DNS 类似,只能小写字母、数字、连字符,长度 3 到 63。规划时我一般遵循两条原则:

  • 按环境隔离:app-dev、app-test、app-prod分开,避免测试数据污染生产,也方便不同环境的权限收口。
  • 按访问策略分,而不是按业务表分。业务模块再细分,其实用一个桶加 objectName 的前缀(如contract/2024/06/xxx.pdf)就够了。桶的数量增多会带来策略维护成本,而对查询性能没什么帮助。

注意:不要把"是否需要公开访问"和"业务模块"混在一起。公开的静态资源(如商品图)和私密的合同附件,应该分成两个桶,因为前者可以配匿名只读策略,后者必须走预签名链接。混在一个桶里,策略很难写对。

2.4 上传路径的设计:直传还是中转

这是架构上最需要提前决策的一点。所谓中转上传,是浏览器把文件先传给 Java 服务,服务再转手 PUT 到 MinIO;直传则是服务端签发一个临时上传凭证(预签名 URL),浏览器直接和 MinIO 通信。

两者取舍很清楚:中转实现简单、服务端能做校验和转码,但文件字节要过一遍应用服务器,大文件会占带宽和内存;直传省掉了这层中转,但需要额外处理凭证有效期、上传后的回调确认。我的经验是,小于 20MB 的常规附件走中转,代码简单、可控性强;超过 50MB 的音视频、大压缩包走直传,否则一个 500MB 的视频能把服务的堆内存顶穿。中间的区间按实际业务量决定。

这条决策会直接影响后面的代码形态,所以一定要在动手前定下来,别写完中转再回头改成直传,改动面会很大。

3. 核心细节拆解:从初始化到对象操作的每一步

3.1 客户端初始化与配置项的含义

基础配置我一般放在application.yml里,至少包含端点、凭证、默认桶、区域四项:

app: minio: endpoint: http://127.0.0.1:9000 access-key: your-access-key secret-key: your-secret-key bucket: app-prod region: us-east-1

endpoint这里有个高频坑:如果你的 MinIO 前面挂了反向代理,endpoint 必须写成对外可访问的域名,而不是127.0.0.1。因为预签名 URL 是用这个 endpoint 参与签名的,如果签名时填的是内网地址,返回给浏览器的链接就是内网地址,用户根本打不开。我遇到过一次排查了半天的问题,最后发现就是这一项配错了。

region在自建 MinIO 里通常固定填us-east-1,它不是真正的地理区域,但 SDK 签名时会带上,值不一致会报签名错误。凭证部分千万不要硬编码,用环境变量或配置中心注入,本地开发可以放在不提交的application-local.yml里。

初始化 Bean 的写法和要点:

@Configuration public class MinioConfig { @Bean public MinioClient minioClient(MinioProperties props) { return MinioClient.builder() .endpoint(props.getEndpoint()) .credentials(props.getAccessKey(), props.getSecretKey()) .region(props.getRegion()) .build(); } }

注意:MinioClient是线程安全的,交给 Spring 管理成单例即可。不要在方法内部重复构建,也不要在使用后调用任何"关闭"方法,它没有需要手动释放的生命周期。

3.2 桶的创建与存在性检查

首次部署时桶可能还不存在,我习惯在应用启动时做一次幂等检查。这里的关键是理解bucketExists只回答"有没有",makeBucket在已存在时会抛异常,所以要组合使用:

public void ensureBucket(String bucket) { try { boolean exists = client.bucketExists( BucketExistsArgs.builder().bucket(bucket).build()); if (!exists) { client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); } } catch (Exception e) { throw new IllegalStateException("桶初始化失败: " + bucket, e); } }

这段看着简单,但有几个细节值得说。第一,异常不能吞,桶建不出来后面所有操作都会失败,启动时就该快速失败,而不是等到用户点上传才报错。第二,多实例同时启动时这段会并发执行,makeBucket可能有一方抛"已存在",所以更稳妥的写法是捕获该异常并忽略,或者直接用ifNotExists语义的封装。第三,桶策略(比如给公开桶配匿名只读)建议也放在这一步统一设置,避免人工在控制台点点点,环境一换就忘了。

3.3 对象命名:别拿原始文件名当 key

用原始文件名做 objectName 是最常见的偷懒做法,后果是:同名文件互相覆盖、中文和特殊字符导致 URL 编码问题、路径穿越风险(文件名里带../)。我的做法是用带日期的雪花 ID 或 UUID 作为对象名,原始文件名只存数据库:

public String buildObjectName(String originalName, String bizType) { String ext = ""; int dot = originalName.lastIndexOf('.'); if (dot > -1 && dot < originalName.length() - 1) { ext = originalName.substring(dot).toLowerCase(); } String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); return bizType + "/" + date + "/" + UUID.randomUUID() + ext; }

这样生成的对象名类似contract/2024/06/12/8f3c....pdf。前缀给业务类型,中间给日期分层,好处是:

  • 对象在桶里按目录感组织,运维排查和按前缀批量清理都方便;
  • 日期的层级让冷数据归档、按周期删除变得可操作,用listObjects加前缀就能筛;
  • 扩展名保留,浏览器下载时 MIME 推断更准。

扩展名的提取逻辑要小心,lastIndexOf('.')之后还要判断 dot 是否为最后一个字符,否则file.这种会得到空串。另外可以加一个白名单校验,不允许.exe、.jsp之流,尤其是公开桶里的资源。

3.4 上传:流式与内存的取舍

putObject是核心 API,但参数选错会导致内存问题。看这段:

public String upload(InputStream in, long size, String contentType, String objectName) throws Exception { client.putObject( PutObjectArgs.builder() .bucket(bucket) .object(objectName) .stream(in, size, -1) .contentType(contentType) .build()); return objectName; }

stream(stream, objectSize, partSize)三个参数很关键:

  • objectSize是必须给的,SDK 需要它来决定分片策略和签名;传-1表示未知大小,这时会强制走分片上传,且partSize必须显式指定(最小 5MB);
  • partSize传-1表示由 SDK 按大小自动决定分片,但只在objectSize已知时才允许。

所以如果是从MultipartFile拿到的流,file.getSize()直接填进去就行,partSize给-1。如果大小确实未知(比如从网络流边下边传),那就必须给固定分片大小,比如10 * 1024 * 1024。这一条我踩过:给未知大小传了partSize = -1,SDK 直接抛参数校验异常,报错信息还不够直白,查文档才明白。

另一个重点是contentType一定要传。不传的话,MinIO 存的是application/octet-stream,浏览器点击图片链接会变成下载而不是预览,用户体验很割裂。取值从MultipartFile.getContentType()拿即可,但要注意这个方法返回的可能是null(某些客户端不传),需要兜底成application/octet-stream。

3.5 下载与流的管理:最容易漏的一环

下载分两种场景:服务端需要读取内容做处理(比如解析 Excel、生成缩略图),或者直接把文件透传给前端。前者用getObject拿到InputStream,后者更推荐用预签名 URL 让浏览器自己取。

先说服务端读取。GetObjectResponse本身是一个InputStream,必须显式关闭,否则底层连接不会归还连接池。正确写法:

try (InputStream in = client.getObject( GetObjectArgs.builder().bucket(bucket).object(objectName).build())) { // 处理流 } catch (Exception e) { // 记录并抛业务异常 }

我见过最典型的泄漏写法是在finally里只关了业务流的包装层,而没关GetObjectResponse,或者干脆什么也没关。表现是跑一段时间后上传下载开始变慢、偶发超时,用连接监控一看全是 CLOSE_WAIT。记住:任何从 SDK 拿到的流,都要放进 try-with-resources。

如果只是要透传给前端下载,用GetObjectResponse配合StreamingResponseBody也可以,但要注意大文件会长时间占用一个 Tomcat 工作线程,并发上来后线程池会打满。所以除非必须做权限校验后的转发,否则优先走预签名 URL。

3.6 预签名 URL:有效期与权限的平衡

预签名(presigned)URL 的本质是"用服务端凭证把一次操作的授权签进链接里",拿到链接的人在这个有效期内可以凭链接做对应操作,不需要再带凭证。生成方式:

String url = client.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucket) .object(objectName) .expiry(15, TimeUnit.MINUTES) .build());

有效期怎么定?原则是够用就好,宁短勿长。下载链接给 15 分钟到 1 小时,基本覆盖一次点击到下载完成的场景;上传链接给 10 到 30 分钟,因为用户可能选完文件还要填表单。MinIO 对预签名有效期有上限约束(默认最长 7 天),别想着签一年省事,那等于把文件变成公开的。

还有一个容易忽略的细节:getPresignedObjectUrl生成的链接里包含 endpoint 的 host,所以前面说的 endpoint 必须是外部可访问地址,这里再强调一次。另外,如果链接会出现在邮件、消息里被转发,考虑加上时间戳和一次性标识,在业务层做二次校验,因为预签名 URL 本身无法主动失效。

4. 实操落地:把完整的上传下载链路跑起来

4.1 表结构与元信息字段设计

动手写代码前先把表定下来,后面所有逻辑都围绕它转。我用的最小字段集如下:

字段类型说明
idbigint主键
object_namevarchar(255)存储对象名,唯一
original_namevarchar(255)原始文件名,展示用
bucketvarchar(63)所属桶,便于多环境迁移
sizebigint字节数
content_typevarchar(127)MIME 类型
biz_typevarchar(63)业务类型,便于归类
biz_idvarchar(63)关联业务主键
statustinyint0 临时 1 正式 2 已删除
created_byvarchar(63)上传人
created_atdatetime上传时间

object_name上加唯一索引,(biz_type, biz_id)加联合索引用于业务查询,created_at加索引支持按时间清理。status这个字段很有用:上传成功但业务提交失败的文件,可以标记成临时,由定时任务在若干天后清理掉,避免桶里堆垃圾。

4.2 上传接口的完整实现

下面这段是从 Controller 到 Service 的完整链路,含参数校验和异常兜底:

@PostMapping("/upload") public Result<FileVO> upload(@RequestParam("file") MultipartFile file, @RequestParam("bizType") String bizType) { if (file.isEmpty()) { return Result.fail("文件为空"); } if (file.getSize() > 200L * 1024 * 1024) { return Result.fail("超过单文件大小上限"); } String original = StringUtils.cleanPath(file.getOriginalFilename()); if (original.contains("..")) { return Result.fail("非法文件名"); } return Result.ok(fileService.upload(file, bizType)); }

Service 层:

public FileVO upload(MultipartFile file, String bizType) { String objectName = buildObjectName(file.getOriginalFilename(), bizType); String contentType = Optional.ofNullable(file.getContentType()) .orElse("application/octet-stream"); try (InputStream in = file.getInputStream()) { client.putObject(PutObjectArgs.builder() .bucket(bucket) .object(objectName) .stream(in, file.getSize(), -1) .contentType(contentType) .build()); } catch (Exception e) { throw new BizException("文件上传失败", e); } // 落库 FileRecord record = new FileRecord(); record.setObjectName(objectName); record.setOriginalName(file.getOriginalFilename()); record.setBucket(bucket); record.setSize(file.getSize()); record.setContentType(contentType); record.setBizType(bizType); record.setStatus(1); fileRecordMapper.insert(record); return toVO(record); }

几个实操要点:

  • 先传存储再落库。反过来的话,如果上传失败,数据库里就会留下指向不存在对象的脏记录。万一落库失败,桶里会留一个孤儿对象,这个可以用定时任务扫"库中无记录且创建时间超过一天"的对象来清理。
  • StringUtils.cleanPath是 Spring 提供的,能处理../之类的路径穿越,配合手动判断更稳。
  • 大小上限建议在网关、Spring 的max-file-size、业务代码三层都配,任何一层漏了都可能被绕过。
  • file.getInputStream()也要关,虽然MultipartFile的实现通常会在请求结束后清理临时文件,但显式关闭是更好的习惯。

4.3 下载与预览:预签名优先

下载接口我一般是这样的逻辑:先查库确认记录存在且状态正常,再判断是走重定向还是流式返回。

@GetMapping("/{id}/url") public Result<String> downloadUrl(@PathVariable Long id, @RequestParam(defaultValue = "false") boolean inline) { FileRecord record = fileRecordMapper.selectById(id); if (record == null || record.getStatus() == 2) { return Result.fail("文件不存在"); } GetPresignedObjectUrlArgs.Builder builder = GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(record.getBucket()) .object(record.getObjectName()) .expiry(30, TimeUnit.MINUTES); if (inline) { // 追加响应头参数,让浏览器内联预览 Map<String, String> queryParams = new HashMap<>(); queryParams.put("response-content-disposition", "inline; filename=\"" + record.getOriginalName() + "\""); builder.extraQueryParams(queryParams); } try { return Result.ok(client.getPresignedObjectUrl(builder.build())); } catch (Exception e) { throw new BizException("生成下载链接失败", e); } }

这里有个很实用的技巧:extraQueryParams可以往预签名里塞response-content-disposition和response-content-type,让 MinIO 在返回时覆盖响应头。这样一来,即使对象存的时候是application/octet-stream,也能让浏览器按图片或 PDF 内联打开,中文文件名也能通过filename*=UTF-8''的方式正确编码。这个能力在"预览"场景里几乎必用。

注意:response-content-disposition里的文件名如果含中文,直接拼会乱码。正确做法是用URLEncoder.encode(name, StandardCharsets.UTF_8)编码,并采用filename*=UTF-8''编码后名字的格式。

4.4 大文件分片上传的落地思路

超过一两百兆的文件,走单次putObject虽然 SDK 内部也会分片,但整个请求会占住一个连接很久,中途网络抖动就全废了。更稳的做法是前端分片 + 服务端组装,或者用 MinIO 的分段上传 API 手动控制。手动分段的核心是三步:

  1. createMultipartUpload拿到 uploadId;
  2. 每个分片uploadPart,记录返回的 etag;
  3. 全部完成后completeMultipartUpload,传入分片编号和 etag 列表。

关键参数是分片大小,除最后一片外每片最小 5MB,最大 5GB,最多 10000 片。所以规划时要反推:假如要支持 1TB 的文件,分片至少得 100MB 一片才不超上限(10000 × 100MB = 约 976GB)。绝大多数业务用 8MB 或 16MB 一片足够,太小会导致请求数暴增、元数据膨胀,太大会让失败重传代价变高。我一般取 16MB,在 200MB 到 5GB 的区间里重传成本可以接受。

分片上传的难点不在 API,而在中断恢复:要把 uploadId 和已完成分片列表存下来,用户续传时先查已完成分片跳过。这块如果做,表结构至少要有upload_id、object_name、part_number、etag、size。不做续传的话,至少要保证失败后能调用abortMultipartUpload清理,否则 MinIO 里会留下一堆不可见的碎片,占着空间却查不到。

4.5 删除、批量清理与容量控制

删除分两种:软删除只改数据库状态,对象保留;物理删除调用removeObject。我通常的业务删除都是软删,保留可恢复性,然后用定时任务定期做物理清理。

public void physicalDelete(FileRecord record) { try { client.removeObject(RemoveObjectArgs.builder() .bucket(record.getBucket()) .object(record.getObjectName()) .build()); } catch (Exception e) { log.warn("对象删除失败,稍后重试: {}", record.getObjectName(), e); } fileRecordMapper.deleteById(record.getId()); }

批量清理时用removeObjects传一个可迭代的对象列表,比循环单删效率高得多,它内部走了批量接口。定时任务扫临时文件时,按status = 0 且 created_at < now - 7 天查,分批处理,每批 500 条以内,避免一次拉太多。

容量控制这块,MinIO 本身支持桶配额(quota),可以在管理端设置总量上限。另外建议在应用层做一个简单的用量统计,定期把桶的总大小写到一个监控指标里,超过阈值告警,否则磁盘写满是很难第一时间发现的。

5. 常见问题与排查实录

5.1 高频报错速查表

下面这张表是我这几年处理过的问题里出现频率最高的,按现象、原因、处置三列整理,遇到问题可以先对照查:

现象常见原因处置
启动报S3 API Requests must be made to API portendpoint 用了控制台端口(9001)改成 API 端口(9000)
预签名链接 403 SignatureDoesNotMatchendpoint 与实际访问地址不一致,或 region 配错统一 endpoint 为对外域名,region 固定us-east-1
上传后无法删除桶策略限制了删除权限检查凭证对应的策略,补s3:DeleteObject
报Part number must be an integer between 1 and 10000分片数超上限增大 partSize,反推保证不超 10000 片
隔一段时间后连接超时增多GetObjectResponse未关闭,连接泄漏所有流放进 try-with-resources
中文文件名下载乱码响应头没做编码用filename*=UTF-8''格式编码
图片点开变下载上传时没设 contentType上传时显式传 contentType
多实例部署部分文件 404早期用了本地磁盘或 endpoint 指向单节点统一走 MinIO,endpoint 指向可访问地址

5.2 几个值得单独讲的排查过程

第一个案例是"偶发超时"。某服务压测时,QPS 一过 150 就开始出现零星超时,日志里是SocketTimeoutException。第一反应是 MinIO 性能问题,但集群 CPU 和网络都很闲。后来用连接监控看主机上的 TCP 状态,发现大量 CLOSE_WAIT,说明应用侧拿了连接没关。翻代码找到一处下载逻辑,getObject拿到的流只在一个分支里关闭,异常分支直接抛出去了。改成 try-with-resources 后,超时彻底消失。这个案例的教训是:网络问题先排除资源泄漏,再怀疑对端。

第二个案例是"预签名链接时而能打开时而 403"。服务部署了两个实例,hosts 里给 endpoint 写了两个内网 IP 做负载,结果签出来的链接有时指向 A、有时指向 B,而用户只能访问其中一个。这类问题的根因是"签名用的地址"和"用户访问的地址"不一致。解决方式是把 endpoint 固定成一个对外统一入口,而不是在内网做轮询。

第三个案例是"上传大文件到 99% 失败"。排查发现是网关的请求体大小限制和超时时间都用的默认值,200MB 的文件传到后半段直接被断开。这类问题需要对整条链路排查:网关的限制、反向代理的client_max_body_size和读写超时、应用的 multipart 上限、MinIO 自身的超时。任何一环没放开,大文件都过不去。我的经验是,大文件方案上线前,拿一个接近上限的文件做一次完整回归,比事后救火省事得多。

5.3 独家避坑心得

几条写文档里通常不会提、但实测很管用的经验:

  • 本地开发和线上用同一套 SDK 参数。有些人在本地把partSize调得很小图快,线上忘了改,结果分片数暴涨,元数据膨胀、性能反而下降。参数应该走配置,环境之间只改 endpoint 和凭证。
  • 给putObject加上超时配置。SDK 底层的 OkHttp 默认超时可能不符合你的期望,通过自定义OkHttpClient设置 connect/read/write 超时,再传给MinioClient.builder().httpClient(...),能让故障暴露得更快,而不是傻等。
  • 对象名不要包含中文和空格。虽然 MinIO 支持,但在一些中间件、日志系统、URL 拼接里会不断给你制造麻烦,用 ID 加扩展名是最省心的。
  • 测试环境定期清桶。测试环境最容易堆积垃圾文件,磁盘告警往往第一个出现在测试环境。写个脚本按前缀删,或者干脆定期重建桶。
  • 不要把凭证打日志。排查问题时打印配置很容易顺手把 secretKey 也打出来,这个习惯一定要改,日志脱敏要做在配置加载那一层。

6. 收尾前再聊两句实践感受

这套 Java 加 MinIO 的组合,我从早期的单体项目一路用到现在的多服务架构,最大的感受是:它本身不难,难的是边界和细节。API 就那么几个,一天就能跑通 demo,但真正让它稳定跑上一年半载的,是那些不起眼的决定——对象名怎么生成、流有没有关、endpoint 配的是哪个地址、分片大小怎么定、临时文件谁来清。这些点每一个单独看都是小事,凑在一起就是线上事故和正常运行的差别。

我个人的习惯是,每次接入一个新的存储实现,先写一个小的验证程序,把上传、下载、预签名、删除、分片这五条路径各跑一遍,再拿一个 300MB 左右的文件压一次,确认没有连接泄漏和超时,然后才开始往业务代码里接。这个过程大概半天,但能挡掉后面大部分的返工。

另外提一句扩展方向:如果将来文件量级继续涨,可以在 MinIO 前面加一层 CDN 或者反向代理做缓存,公开资源走缓存、私密资源仍走预签名;如果要做内容安全扫描,可以在上传成功后发一个异步事件,由独立服务拉取对象做检测,检测完再改状态。这些都是在现有结构上做加法,不需要动核心链路,这也是当初把存储层做"哑"的好处。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进