SpringBoot 3整合MinIO:对象存储文件上传与预签名分片实践
1. 从本地磁盘到对象存储:一次被迫的存储改造
项目做到第三个月,文件上传这块先出事了。最初版本很朴素,MultipartFile拿过来直接transferTo到服务器本地的一个upload目录,数据库里存个相对路径,前端拼接域名访问。单机跑得好好的,直到运维把服务扩到三个实例,用户反馈"刚传的头像刷新一下就 404 了"——负载均衡把下载请求打到了另一台机器上,那台机器上根本没有这个文件。这件事之后我才认真去做了 SpringBoot 3 整合 MinIO 这件事,而不是继续在本地磁盘上打补丁。
MinIO 是什么,一句话讲清楚:它是一个可以自己部署的对象存储服务,对外提供的接口和 S3 协议高度兼容,你把文件扔进去,它给你一个 key,取的时候拿 key 换内容。它解决的核心问题是文件不再和某一台应用服务器绑定,同时把文件读写这件事从"操作系统的文件系统"抽象成了"一套带鉴权的 HTTP 接口"。适合谁来参考这篇内容?我的判断是三类人:一类是项目里正在用本地磁盘存文件、已经被多实例部署坑过的后端;一类是要做私有化交付、不能用公有云对象存储的团队;还有一类是刚开始接触 SpringBoot 3、想找一个真实可跑的中间件集成案例练手的开发者。
MinIO 本身不复杂,装起来一个二进制文件加两条命令就能跑。真正花时间的部分在于把它和 SpringBoot 3 缝在一起的过程——依赖版本的互相牵扯、Bean 的装配时机、对象名的设计、预签名 URL 的地址问题、大文件的分片策略、删库和删文件之间的一致性。这些问题在官方文档里大多只有一行说明,踩上去才知道疼。下面我把这条链路从头到尾拆一遍,代码可以直接抄,坑我也标出来了。
2. 依赖版本这一步就把人拦下了:okhttp 冲突与 Bean 装配
2.1 SpringBoot 3 带来的不只是 Jakarta 命名空间
很多人以为 SpringBoot 3 的迁移成本就是把javax.*全局替换成jakarta.*,改完编译通过就完事了。文件上传这块确实受这个影响——javax.servlet.http.HttpServletRequest变成了jakarta.servlet.http.HttpServletRequest,如果你项目里有自定义的 Filter 或者用了某些老版本的 Web 工具库,编译期就会报错。但这只是明面上的。
真正隐蔽的是第三方 SDK 的传递依赖。MinIO 的 Java SDK 底层用的是 OkHttp 发 HTTP 请求,而 OkHttp 4.x 相对 3.x 改了一批静态工厂方法的签名。如果你的项目里还有别的组件(比如某些推送 SDK、某些 HTTP 客户端封装)把 OkHttp 锁在了 3.x,Maven 的最近优先原则可能给你解析出一个混合版本,编译期一切正常,运行到第一次调用 MinIO 就抛NoSuchMethodError,堆栈里指向的是okhttp3.RequestBody.create之类的方法。这个报错第一次见会非常懵,因为你的代码里压根没出现过 OkHttp。
我当时的做法是先看清依赖树的真实面貌:
mvn dependency:tree -Dincludes=com.squareup.okhttp3输出里如果出现两个不同版本的 okhttp,基本就确诊了。解决方式有两种,我倾向前一种:
<dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.7</version> <exclusions> <exclusion> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency>显式排除再加显式声明,等于告诉 Maven"这块版本我接管了"。另一种是在dependencyManagement里统一锁定 OkHttp 版本,效果类似,但如果项目是多模块的,容易在某个子模块里漏掉,反而不如排除来得干脆。实测下来,OkHttp 4.12.0 配 minio-java 8.5.x 是稳的组合,我用了大半年没再出现过类似的NoSuchMethodError。
注意:不要图省事把 minio 的版本降到 8.4.x 去"迁就"旧 OkHttp,8.4 系列在部分分片上传场景下有已知的流关闭问题,而且新版本对 S3 的兼容性更好,往回退是给自己找麻烦。
2.2 配置项与 MinioClient 的装配时机
MinIO 的连接信息就四个:endpoint、accessKey、secretKey、默认桶名。我习惯用@ConfigurationProperties接,而不是每个字段写一个@Value,原因是后面如果要加区域、加超时、加多个桶,字段会越来越多,散落各处的@Value维护起来很痛苦。
minio: endpoint: http://127.0.0.1:9000 access-key: minioadmin secret-key: minioadmin bucket: app-file presign-endpoint: https://file.example.internal注意endpoint和presign-endpoint是两个值,这个后面第 4 节会详细说,先记住有这么个东西。
@Data @ConfigurationProperties(prefix = "minio") public class MinioProperties { private String endpoint; private String accessKey; private String secretKey; private String bucket; private String presignEndpoint; }@Configuration @EnableConfigurationProperties(MinioProperties.class) public class MinioConfig { @Bean public MinioClient minioClient(MinioProperties props) { return MinioClient.builder() .endpoint(props.getEndpoint()) .credentials(props.getAccessKey(), props.getSecretKey()) .build(); } }这里有个容易被忽略的点:MinioClient本身是线程安全的,官方明确说过一个实例可以全局复用,它的 Builder 内部维护了连接池。这意味着你不要在每次上传的时候new一个 client,也不要用@Scope("prototype")。我曾经在一个项目里见过同事在 Service 方法里现场构建 client,压测的时候连接数飙到几千,服务端直接开始拒连。
另一个点是装配顺序。如果你在@PostConstruct里做建桶检查,要注意此时网络可能还没就绪(尤其是容器化部署,MinIO 和应用同时启动的场景)。我一般把建桶逻辑放到一个ApplicationRunner里,并且加上重试:
@Component @RequiredArgsConstructor public class MinioBucketInitializer implements ApplicationRunner { private final MinioClient minioClient; private final MinioProperties props; @Override public void run(ApplicationArguments args) { String bucket = props.getBucket(); for (int i = 0; i < 5; i++) { try { boolean exists = minioClient.bucketExists( BucketExistsArgs.builder().bucket(bucket).build()); if (!exists) { minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); } return; } catch (Exception e) { if (i == 4) { throw new IllegalStateException("MinIO 桶初始化失败: " + bucket, e); } try { Thread.sleep(2000L); } catch (InterruptedException ignored) { } } } } }用ApplicationRunner而不是@PostConstruct,是因为前者在容器完全启动之后才执行,此时配置、线程池都就绪了,失败重试也不会阻塞 Bean 的创建过程。这算是我踩过的一次坑换来的习惯——之前把建桶写在@PostConstruct里,MinIO 启动慢了两秒,整个应用启动直接失败,排查了半天才发现是初始化时序的问题。
3. 桶、对象名与上传链路的三处设计取舍
3.1 对象名怎么设计,决定了后面运维有多痛
对象名(objectName)是 MinIO 里唯一的标识,相当于文件系统里的完整路径。我见过三种写法,优劣差别很大。
第一种是直接把用户上传的原始文件名当对象名,报表.xlsx就叫报表.xlsx。这种写法的问题三天内就会暴露:两个用户传同名文件,后者覆盖前者;文件名里的空格、中文、#、?在拼接 URL 的时候需要转义,一不小心就 400;所有文件平铺在同一层,桶里几万个对象之后,控制台翻页都卡。
第二种是 UUID 加扩展名,a3f9c1e2-....xlsx。解决了重名和特殊字符,但完全丧失了可读性,运维想找某个文件只能靠数据库反查。
我现在统一用的是第三种,日期分层加业务前缀加 UUID 加扩展名:
private String buildObjectName(String bizType, String originalFilename) { String ext = ""; int dot = originalFilename.lastIndexOf('.'); if (dot > -1) { ext = originalFilename.substring(dot).toLowerCase(); } String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); String uuid = UUID.randomUUID().toString().replace("-", ""); return bizType + "/" + date + "/" + uuid + ext; }这样生成出来的对象名长这样:avatar/2024/06/18/9f2c8a1b....jpg。好处有三层:日期分层让单个"目录"下的对象数量可控,批量清理历史文件时按前缀删就行;业务前缀让你在控制台一眼能看出这个桶里都装了什么类型的文件;UUID 保证唯一性,不用处理并发重名。原始文件名不丢,存在数据库的一个字段里,下载的时候再还原成用户看得懂的名字。
这里有个细节值得单独说:不要用File.separator或者反斜杠。对象名必须是正斜杠,而且不能以斜杠开头。有次在 Windows 上开发,代码里用了Paths.get(...).toString()拼路径,本地跑没问题(MinIO 会做兼容处理),部署到 Linux 之后所有的对象名都正常,但下载时的路径匹配逻辑炸了,因为数据库里存的对象名带了反斜杠。
3.2 三种上传形态的选择逻辑
SpringBoot 里往 MinIO 传文件,落到代码上无非是三个入口,选哪个取决于文件大小和调用方。
小文件走MultipartFile,最直观。但要注意MultipartFile.getInputStream()的流只能读一次,且getSize()返回的是字节数,可以直接传给 SDK:
public String upload(MultipartFile file, String bizType) { String objectName = buildObjectName(bizType, file.getOriginalFilename()); try (InputStream in = file.getInputStream()) { minioClient.putObject(PutObjectArgs.builder() .bucket(props.getBucket()) .object(objectName) .stream(in, file.getSize(), -1) .contentType(file.getContentType()) .build()); return objectName; } catch (Exception e) { throw new RuntimeException("上传失败", e); } }stream(in, size, partSize)这三个参数值得解释一下。第二个参数是对象总大小,第三个参数是分片大小。当size已知且小于 5MiB 时,partSize传-1,SDK 走单次 PUT;当size大于 5MiB 时,SDK 会自动切成 5MiB 一片做分片上传,partSize传-1让它自己算;当size未知(比如从网络流直接转发)时,第二个参数传-1,此时必须显式指定partSize且不能小于 5MiB(5 * 1024 * 1024),否则 SDK 会直接抛参数异常。
中等文件走字节数组,适合文件已经在内存里的场景,比如服务端生成的 Excel、PDF。直接new ByteArrayInputStream(bytes)配上bytes.length就行,简单直接。但要注意内存占用,一次性把 50MB 读进byte[],并发几十个就是几个 G 的堆压力,所以这条路我只在文件确定小于 10MB 的时候用。
大文件走前端直传,这是最省服务器资源的方案。后端只负责签发一个预签名 URL(第 4 节讲),前端拿到 URL 之后自己 PUT 到 MinIO,文件流量完全不经过应用服务器。代价是前端要处理分片和断点,复杂度上去了。
三种形态的取舍我整理成了这张表,实际选型的时候对着看就行:
| 形态 | 适用文件大小 | 服务器压力 | 前端复杂度 | 典型场景 |
|---|---|---|---|---|
| MultipartFile 转发 | < 50MB | 中,流量过应用 | 低 | 头像、附件、导入模板 |
| 字节数组 | < 10MB | 高,占堆内存 | 低 | 动态生成的报表、导出文件 |
| 预签名直传 | 无上限 | 低,流量不过应用 | 高 | 视频、大压缩包、批量素材 |
3.3 Content-Type 与编码这两件小事
contentType不传会怎样?MinIO 会给你默认成application/octet-stream。后果是浏览器访问这个文件 URL 的时候不会内联预览,一律弹下载框。图片、PDF 这类需要在线看的文件,必须在putObject的时候显式带上contentType。
MultipartFile.getContentType()拿到的值来自浏览器,大多数时候是对的,但不能全信。用户可以用工具改掉请求头,传一个.jpg却声明成text/html。如果你的桶开了公共读,这玩意儿就变成了一个可以在你的域名下执行脚本的 HTML 页面,风险不小。我现在的做法是服务端根据扩展名做一次收敛,白名单之外的统一按application/octet-stream处理:
private static final Map<String, String> TYPE_MAP = Map.of( "jpg", "image/jpeg", "jpeg", "image/jpeg", "png", "image/png", "gif", "image/gif", "pdf", "application/pdf", "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ); private String resolveContentType(String filename) { int dot = filename.lastIndexOf('.'); if (dot == -1) return "application/octet-stream"; String ext = filename.substring(dot + 1).toLowerCase(); return TYPE_MAP.getOrDefault(ext, "application/octet-stream"); }多这一步看着啰嗦,但它同时解决了两个问题:一是健康的内联预览,二是把不可信的类型声明挡在门外。上线之后再回来看,这十几行代码是整个上传链路里性价比最高的部分。
4. 预签名 URL:方便背后的地址与签权陷阱
4.1 预签名到底在签什么
预签名 URL(presigned URL)的本质是:服务端用 accessKey 和 secretKey 对一个即将发生的请求做 HMAC 签名,把签名、过期时间、访问凭证一起拼进 URL 的 query 参数里,然后把这个带签名的 URL 交给前端。前端拿着 URL 直接访问 MinIO,MinIO 自己校验签名,校验通过就返回文件,全程不需要应用服务器参与。
这个机制最直接的好处是下载流量不过应用服务器,而且天然支持时效控制——链接发出去 7 天就失效,不需要额外的权限系统。生成代码很短:
public String presignedGetUrl(String objectName, Duration expiry) { try { return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(props.getBucket()) .object(objectName) .expiry((int) expiry.toSeconds()) .build()); } catch (Exception e) { throw new RuntimeException("生成预签名地址失败", e); } }expiry有硬上限,最长 7 天,传更大的值 SDK 会直接抛异常。这个限制来自 SigV4 签名规范,不是 MinIO 自己加的。所以如果你的业务是"生成一个永久下载链接",只能靠业务侧发一个短链重定向,每次访问时重新签一个。
4.2 最容易翻车的地方:URL 里的 host 是谁
预签名 URL 生成出来之后,形如http://127.0.0.1:9000/app-file/avatar/2024/...?X-Amz-Algorithm=...&X-Amz-Signature=...。注意那个 host,它是你在MinioClient.builder().endpoint()里配置的值。
如果应用服务器和 MinIO 在同一内网,你配置的是http://10.0.1.5:9000,那签出来的 URL 里就是内网 IP。这个 URL 直接丢给浏览器,用户的电脑根本访问不到10.0.1.5,结果就是一个超时。这是我在第一个项目里遇到的第一个线上问题,测试环境全内网所以一直没发现,上线后前端同学说"图片全是裂的"。
解决办法有两个,我推荐第二个。
办法一:配置两套 endpoint。一套给服务端自己调用 SDK 用(内网,快),一套只用来生成对外 URL(公网域名)。但getPresignedObjectUrl是用 client 的 endpoint 签的,你没法在调用时临时换 endpoint。变通做法是再建一个专门用于签名的 client:
@Bean("presignMinioClient") public MinioClient presignMinioClient(MinioProperties props) { return MinioClient.builder() .endpoint(props.getPresignEndpoint()) .credentials(props.getAccessKey(), props.getSecretKey()) .build(); }需要注意,签名是按 host 算进去的,所以你用公网地址签、前端用公网地址访问,签名才对得上。用内网 client 签出来再把 URL 里的 host 字符串替换成公网域名,一定会得到SignatureDoesNotMatch,因为 Host 是 SigV4 计算签名时参与运算的 header 之一。
办法二:用反向代理统一域名。Nginx 上挂一个file.example.com,同时反代到 MinIO 和应用,MinIO 那边通过环境变量MINIO_SERVER_URL声明自己的外部访问地址。这样内网外网看到的是同一个域名,签出来的 URL 天然可用。
location /app-file/ { proxy_pass http://10.0.1.5:9000/app-file/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; client_max_body_size 0; proxy_buffering off; }proxy_set_header Host $host这行是命门。如果不加,Nginx 会把 Host 改成上游地址10.0.1.5:9000,而预签名里签的是file.example.com,两边一比对,签名校验失败。proxy_buffering off是为大文件下载准备的,避免 Nginx 先把整个文件缓存到本地临时目录再吐给客户端,白白多占一份磁盘 IO。
还有一个我踩过的坑:URL 里的对象名必须 URL 编码。如果你的对象名里有中文或者空格(虽然我在 3.1 里建议不要,但历史数据总归有),拼 URL 的时候没编码,MinIO 那边解析出来的对象名和你签的对象名对不上,一样是签名失败。SDK 内部对 objectName 做了编码,但如果你是自己手动拼字符串(比如在 URL 后面追加?response-content-disposition=...之类的参数),就要自己处理。
4.3 下载时的中文文件名
预签名 URL 默认打开是浏览器的行为:能识别的类型内联显示,不能识别的弹下载。文件名会是对象名里那串 UUID,用户看到一个9f2c8a1b.jpg会一脸问号。要指定下载文件名,可以在签名的时候加response-content-disposition参数:
Map<String, String> queryParams = new HashMap<>(); queryParams.put("response-content-disposition", "attachment; filename*=UTF-8''" + URLEncoder.encode(originalName, StandardCharsets.UTF_8));注意必须用filename*=UTF-8''这个带星号的写法,这是 RFC 5987 规定的中文文件名编码方式。老的filename=xxx直接放 UTF-8 字节,在某些浏览器上会变成乱码。我一开始用的是老写法,测试同学发来截图说"下载下来叫 鎶ュ憡.xlsx",改成filename*之后正常。
如果走的是后端流式下载(流量过应用),思路一样,只是换成设置 Response Header:
@GetMapping("/file/{objectName}") public void download(@PathVariable String objectName, HttpServletResponse response) throws Exception { StatObjectResponse stat = minioClient.statObject( StatObjectArgs.builder().bucket(props.getBucket()).object(objectName).build()); response.setContentType(stat.contentType()); response.setContentLengthLong(stat.size()); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + URLEncoder.encode(displayName, StandardCharsets.UTF_8)); try (InputStream in = minioClient.getObject(GetObjectArgs.builder() .bucket(props.getBucket()).object(objectName).build())) { StreamUtils.copy(in, response.getOutputStream()); } }StreamUtils.copy内部是 4KB 的缓冲区,对大文件来说偏小,我一般自己写一个 64KB 的循环拷贝,实测在千兆内网下吞吐能高一截。另外别忘了response.flushBuffer(),不然在某些容器下最后一点数据会卡在缓冲区里。
注意:流式下载会占用应用服务器的连接和内存,如果同一个接口被批量调用(比如一次性下载一个相册的 30 张图),连接池很快会被打满。这种场景应该返回预签名 URL 列表,让浏览器自己并发去拿。
5. 大文件、分片与孤儿对象的善后处理
5.1 分片大小不是随便定的
MinIO 的 putObject 在对象超过阈值时会自动分片,规则很简单:除最后一片外,每片不得小于 5MiB,最多 10000 片。这两条约束反过来限定了你的单对象最大体积——如果每片固定 5MiB,理论上限是 48.8GiB;想传更大的文件,就得调大 partSize。
我之前有个项目要传 200MB 左右的视频,直接用stream(in, size, -1)让 SDK 自己算,它默认用 5MiB 分片,也就是切成 40 片。这个粒度其实偏细,每片都是一次 HTTP 往返,40 次往返加上签名计算,总耗时比预期的长不少。后来我把 partSize 显式设成 16MiB:
minioClient.putObject(PutObjectArgs.builder() .bucket(props.getBucket()) .object(objectName) .stream(in, size, 16 * 1024 * 1024) .contentType(contentType) .build());同样的 200MB 文件切成 13 片,上传耗时降了差不多三分之一。经验值是:单对象 100MB 以内用 5MiB,100MB 到 1GB 用 16MiB,再往上用 32MiB 或 64MiB。分片太少也不好,一旦某一片失败要整体重传;分片太多则是往返开销压过收益。这个平衡点跟你的网络质量强相关,内网可以往大调,跨地域就得保守一点。
还有个细节:partSize设成非-1的值之后,SDK 会忽略你传的objectSize,按分片往下读直到流结束。这意味着如果你传了错误的partSize(比如小于 5MiB),会在写入第二片的时候报错,而此时第一片已经落到 MinIO 上了,留下一个不完整的对象。所以这个值一定要通过常量定义,别在调用处随手写魔法数字。
5.2 合并与前端直传分片
前端直传大文件的典型流程是:先调后端拿一个"初始化分片上传"的响应,拿到 uploadId,然后前端按片 PUT 到预签名 URL,全部完成后调后端"完成上传",后端调 MinIO 的composeObject或者completeMultipartUpload把片合并。
Java SDK 里对应的是createMultipartUpload、uploadPart、completeMultipartUpload这一组 API,但这些 API 都需要 secretKey 才能调,不能直接暴露给前端。前端直传的正确姿势是:后端为每一片单独签发一个预签名 PUT URL,前端只拿到 URL,不碰任何凭证。
这里有个坑我踩得很实在:预签名 URL 的expiry是每一片各自计算的,如果文件很大、上传时间长,前面签的 URL 可能已经过期了。我的做法是按"预计总时长 + 30 分钟余量"来设,而不是每片都签 7 天——长期有效的预签名 URL 相当于一个没有回收机制的通行证,泄露风险太大。
合并的时候还有一个约束容易被忽略:除最后一片外,参与合并的每一片都必须不小于 5MiB,而且必须按顺序。前端如果并发上传导致 partNumber 乱了,合并出来的文件内容是错乱的——不会报错,就是文件打不开。这个 bug 极其难查,因为服务端一切正常,只有用户下载后才发现图片是花的。所以我在完成上传的接口里加了一道校验:检查提交上来的 part 列表的 number 是否连续、size 是否符合要求,不满足直接拒绝。
5.3 删库和删文件之间的一致性
业务上的删除操作,一定是"删数据库记录"和"删 MinIO 对象"两件事。这两件事没有事务能包住,必然存在中间态。
先说顺序。先删库,后删对象,还是反过来?我选先删库。原因很直接:如果先删对象再删库,中间失败的话数据库里留着一条指向不存在文件的记录,用户点下载会 500;反过来,先删库再删对象失败,最多是留下一个没人引用的孤儿对象,占点空间,业务上无感知。让不一致的后果落在"多占空间"而不是"功能报错"上,这是这类跨系统操作的基本原则。
但孤儿对象积累起来也是问题。我的处理是加一个兜底的对账任务,每天凌晨跑一次,扫出超过 N 天且数据库里没有引用的对象删掉。实现上不用扫全量,按对象名的日期前缀限定范围就行:
@Scheduled(cron = "0 30 3 * * ?") public void cleanOrphanObjects() { LocalDate target = LocalDate.now().minusDays(7); String prefix = "avatar/" + target.format(DateTimeFormatter.ofPattern("yyyy/MM/dd")) + "/"; Iterable<Result<Item>> results = minioClient.listObjects( ListObjectsArgs.builder().bucket(props.getBucket()).prefix(prefix).build()); for (Result<Item> result : results) { Item item = result.get(); if (!fileRecordMapper.existsByObjectName(item.objectName())) { minioClient.removeObject(RemoveObjectArgs.builder() .bucket(props.getBucket()).object(item.objectName()).build()); } } }几点说明。第一,只扫 7 天前的那一天,范围可控,不会因为桶里对象太多跑不完。第二,listObjects返回的是懒加载的Iterable,遍历过程中每一条都会发一次请求,对象多的时候很慢,数据量大就换成listObjects的递归版本加批量删除。第三,删除之前一定先查库,别图快直接删——曾经有一次我把条件写反了,删掉了当天正在使用的头像,用户反馈"头像全没了",靠备份才恢复回来。
另外,MinIO 支持桶级别的生命周期规则,可以让它自动清理某个前缀下超过 N 天的对象。这个能力适合"临时文件"这种场景(比如上传中断留下的碎片),不适合业务文件,因为生命周期规则不认识你的数据库。
6. 上线前值得再过一遍的检查项
下面这些是我在不同项目里反复确认的东西,整理成清单,每次上线前对着过一遍,能省掉大部分低级事故。
| 检查项 | 具体确认内容 | 出问题的表现 |
|---|---|---|
| 依赖版本 | okhttp 是否被锁到 4.12.x,有无多个版本共存 | 运行期 NoSuchMethodError |
| 连接复用 | MinioClient 是否为单例 Bean | 压测时连接数暴涨、拒绝连接 |
| endpoint 配置 | 服务端调用与对外的 presign 地址是否分离 | 前端拿到的链接超时不可达 |
| 反向代理 | Host 头是否透传、buffering 是否关闭 | SignatureDoesNotMatch、大文件下载卡顿 |
| 分片参数 | partSize 是否 ≥ 5MiB 且按文件大小分级 | 大文件上传中途失败 |
| Content-Type | 是否按扩展名白名单收敛 | 图片变下载、HTML 注入风险 |
| 对象名 | 是否含中文、空格、反斜杠、以斜杠开头 | 签名失败、下载 404 |
| 预签名有效期 | 是否 ≤ 7 天,是否按场景缩短 | 凭证泄露面扩大 |
| 删除顺序 | 是否先删库后删对象,是否有孤儿对账任务 | 下载 500,或存储无限膨胀 |
| 桶权限 | 是否误开了公共读 | 文件可被任意人枚举下载 |
关于桶权限这一项,展开说两句。MinIO 的桶默认是私有的,只有带正确签名的请求才能访问。有些人为了图方便,把桶设成公共读(mc anonymous set download),觉得这样前端拿到的 URL 就是干净的、不带一长串签名参数。这个做法在小项目里确实省事,但代价是任何人拿到对象名就能下载,而对象名又是可以枚举的(尤其是按日期分层之后,前缀规律非常明显)。我的底线是:除了纯公开的静态资源(比如站点 logo),业务文件一律私有加预签名。
还有一个不那么技术但同样重要的点:上传接口的限流。文件上传是天然的放大攻击面——一个 50MB 的请求,服务端要落盘、要传 MinIO、要写库,成本远高于一个普通查询。我在网关层给上传接口单独配了阈值,比普通接口严格得多,同时在业务层限制了单用户的并发上传数。这件事在业务量小的时候完全感知不到,等到有人拿脚本批量传文件的时候才想起来加,往往已经晚了。
最后分享一个小技巧,跟排查效率有关。MinIO 的控制台有一个"对象浏览器",能直接看到桶里的对象、大小、上传时间,还能生成临时的下载链接。调试阶段我几乎不离手——后端说"我传上去了",前端说"我拿不到",打开控制台看一眼有没有这个对象,五秒钟定位到是哪一侧的问题。配合statObject在日志里打一行"对象已写入,大小 xxx 字节",上传链路上的问题基本不需要再靠猜。