《Spring Boot 实战》14.2 对象存储集成

以 S3 兼容接口为主线,用 AWS SDK for Java v2 的 S3Client 与 S3Presigner 落地图书封面的对象存储:access key 的配置与轮换、bucket 与区域、对象键设计、预签名 URL 的有效期、PutObject 的 contentType 与元数据、生命周期策略,以及 Spring Boot 4.x 的自动配置模块。

本节目标:把 14.1 的本地落盘换成 S3 兼容的对象存储——用 S3Client 完成上传下载、用 S3Presigner 签发直传 URL、把密钥与区域配置对、把对象键设计好,并说明 Spring Boot 4.x 里相关的自动配置来自哪里。
适用版本:Spring Boot 4.1.x(Java 21)、AWS SDK for Java v2(software.amazon.awssdk:s3)

14.2 对象存储集成

14.1 把文件落在了应用本地磁盘。这套方案在单机 demo 里够用,一旦多实例部署就立刻暴露问题:A 实例写的文件,B 实例读不到;扩容要同步磁盘;备份要单独做。对象存储解决的正是「文件与应用实例解耦」——实例无状态,文件放在一个共享的、可横向扩展、支持 HTTP Range 与 CDN 的地方。

本节用 S3 兼容接口作为统一抽象:生产接 AWS S3 或阿里云 OSS / 腾讯云 COS 的 S3 兼容端点,本地开发用 MinIO 自建。只要都用 S3 API,代码一套即可,差异只在 endpoint 与 region 配置。

14.2.1 为什么选 S3 兼容接口

对象存储的私有协议各有各的 SDK。选 S3 兼容接口的理由很实在:

  • 生态最广:几乎所有对象存储都提供 S3 兼容网关,AWS SDK 是最成熟的客户端。
  • 本地可自建:MinIO 单二进制即可跑起一个 S3 兼容服务,开发环境不用连云。
  • 迁移成本低:换供应商往往只改 endpoint、region 和凭证,业务代码不动。

代价是「兼容」不等于「完全一致」:各家对分片上传的最小分片、生命周期规则、预签名签名的细节可能有出入(14.3 会踩到)。所以本地用 MinIO 测通不代表云端一定通,关键路径要在目标环境再验一遍。

14.2.2 依赖与客户端

用 AWS SDK for Java v2 的 s3 模块(本文核实版本线为 2.31.x,Maven Central 最新已到 2.39.x,API 稳定):

<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>s3</artifactId>
    <version>2.31.68</version>
</dependency>

客户端有两种创建方式。全手工构建适合想完全控制凭证与端点的场景:

@Configuration
public class S3Config {

    @Bean
    S3Client s3Client(@Value("${loan.storage.endpoint}") URI endpoint,
                      @Value("${loan.storage.region}") String region,
                      AwsCredentialsProvider credentials) {
        return S3Client.builder()
            .endpointOverride(endpoint)                 // MinIO/自建时必填
            .region(Region.of(region))
            .credentialsProvider(credentials)
            .serviceConfiguration(S3Configuration.builder()
                .pathStyleAccessEnabled(true)           // MinIO 需要 path-style
                .build())
            .build();
    }

    @Bean
    S3Presigner s3Presigner(@Value("${loan.storage.endpoint}") URI endpoint,
                            @Value("${loan.storage.region}") String region,
                            AwsCredentialsProvider credentials) {
        return S3Presigner.builder()
            .endpointOverride(endpoint)
            .region(Region.of(region))
            .credentialsProvider(credentials)
            .serviceConfiguration(S3Configuration.builder()
                .pathStyleAccessEnabled(true)
                .build())
            .build();
    }
}

pathStyleAccessEnabled(true) 是本地自建的常见坑:云厂商默认用「虚拟主机式」寻址(https://bucket.s3.region.amazonaws.com/key),而 MinIO 常以「路径式」暴露(http://minio:9000/bucket/key)。地址风格不匹配会出现签名或 404 类错误。

14.2.3 access key 的配置与轮换

绝对不要把 access key / secret key 硬编码或提交进仓库。 凭证来源按环境分层:

环境凭证来源配置方式
本地开发MinIO 的固定测试凭证环境变量或本地未提交的 profile
生产(跑在云主机/K8s)实例角色 / IRSA / 工作负载身份让 SDK 自动从环境获取,不配静态 key
生产(无实例角色的混合环境)密钥管理服务启动时注入环境变量,配合轮换

云上的最佳实践是根本不下发静态 key:给运行实例绑定角色,SDK 的默认凭证链会自动取到短期凭证并自动轮换。只有拿不到实例身份时,才退而用静态 key,且必须:

  1. 通过环境变量或密钥管理服务注入,不落配置文件。
  2. 遵循最小权限:只给该 bucket 的 PutObject/GetObject/DeleteObject,不给全账户权限。
  3. 定期轮换:轮换不是「生成新 key 就完事」,顺序很重要——先新建 key 并让应用同时能读新旧两把(或灰度切换),确认新 key 生效后再删除旧 key。直接删旧 key 会让还在用旧凭证的实例瞬间失败。

Spring Cloud AWS 提供了便捷配置(见 14.2.8),静态凭证写成:

spring.cloud.aws.credentials.access-key=${AWS_ACCESS_KEY_ID}
spring.cloud.aws.credentials.secret-key=${AWS_SECRET_ACCESS_KEY}
spring.cloud.aws.region.static=us-east-1
spring.cloud.aws.endpoint=http://localhost:9000

14.2.4 bucket 命名、区域与对象键设计

bucket 命名有硬约束:3–63 字符,只含小写字母、数字、. 和 -,必须以字母或数字开头结尾,不能像 IP。常见约定是「项目-环境-用途」,例如 loan-prod-covers。bucket 名全局唯一(在 AWS 上是跨账户全局唯一),所以通常带组织或环境前缀。

**区域(region)**要与计算资源就近,否则跨区传输既有延迟又有流量费用。区域一旦选定,bucket 一般不可迁移,选之前想清楚数据合规与用户分布。

对象键(key)设计比 bucket 更值得推敲。键就是对象存储里的「路径」,它决定了:

  • 列举(list)效率:S3 的 list 按字典序返回,键前缀就是「伪目录」。带日期前缀 covers/2026/10/03/<id>.jpg 便于按时间范围清理与排查。
  • 热点分布:S3 内部按键前缀分区。如果所有对象都以同一个前缀开头(如纯自增 id),高并发写入可能集中到同一分区形成热点。在键里加入随机或散列前缀(如日期 + 短哈希)能打散分布,这是大流量场景的经验做法。
  • 多租户隔离:键以租户/图书馆 id 开头,便于按前缀授权与统计。

本项目采用:

covers/{tenantId}/{yyyy}/{MM}/{bookId}/{uuid}.jpg
attachments/{tenantId}/{yyyy}/{MM}/{bookId}/{uuid}.pdf

键用 / 分层但它只是字符串,不是真正的目录:对象存储没有目录概念,covers/ 只是恰好有共同前缀。别在代码里依赖「移动目录」这种操作——对象存储里没有,只能逐个复制加删除。

14.2.5 上传:PutObject 与元数据

S3Client.putObject 接收一个 PutObjectRequest 和 RequestBody:

public void uploadCover(Long tenantId, Long bookId, MultipartFile file, String key) throws IOException {
    Map<String, String> metadata = Map.of(
        "tenant-id", String.valueOf(tenantId),
        "book-id", String.valueOf(bookId),
        "original-name", file.getOriginalFilename() == null ? "" : file.getOriginalFilename());

    PutObjectRequest request = PutObjectRequest.builder()
        .bucket("loan-prod-covers")
        .key(key)
        .contentType("image/jpeg")           // 决定浏览器如何对待该对象
        .contentDisposition("inline")        // 或 attachment,控制内联/下载
        .contentLength(file.getSize())
        .metadata(metadata)                  // 自定义元数据,键名会被加上 x-amz-meta- 前缀
        .build();

    try (InputStream in = file.getInputStream()) {
        s3Client.putObject(request, RequestBody.fromInputStream(in, file.getSize()));
    }
}

几个要点:

  • contentType 必须显式给。 不给的话对象会被存成 binary/octet-stream,后续用预签名 URL 直接访问时浏览器会强制下载而不是显示。这个值在上传时就固化进对象,事后改要重传或复制。
  • RequestBody.fromInputStream(in, length) 的长度参数必填且必须准确。 SDK 需要它来设置 Content-Length;长度不对会报签名不匹配或流截断。用 file.getSize(),不要自己猜。
  • 自定义 metadata 的键是小写,SDK 会自动加 x-amz-meta- 前缀。适合放检索用的小字段,不适合放大内容。
  • 元数据不参与签名校验之外的业务逻辑,不要把它当数据库用。

上传小文件(几十 KB 到几 MB)用 putObject 单次即可;大文件要走分片上传,14.3 展开。

14.2.6 预签名 URL

预签名 URL 是「带签名的临时访问链接」:服务端用凭证对一个即将发生的操作(GET/PUT)签名,客户端拿着这个 URL 在有效期内直接访问对象存储,字节流不经过应用服务器。

生成下载用的预签名 URL:

public String presignDownload(String bucket, String key, Duration ttl) {
    GetObjectRequest get = GetObjectRequest.builder().bucket(bucket).key(key).build();
    GetObjectPresignRequest presign = GetObjectPresignRequest.builder()
        .getObjectRequest(get)
        .signatureDuration(ttl)              // 有效期
        .build();
    return s3Presigner.presignGetObject(presign).url().toString();
}

要点:

  • 有效期要短。 下载 URL 通常给几分钟到几小时;上传 URL 更短。URL 一旦泄露,在有效期内任何人都能用,所以它是「临时通行证」,不是权限本身。
  • 有效期上限由凭证类型决定:用临时凭证(STS)时,预签名 URL 不能超过凭证本身的有效期;用长期 key 时通常最多 7 天。设得过长会在访问时因签名过期而失败。
  • URL 由「操作 + bucket + key + 有效期 + 签名」共同决定,改任何一个参数签名都会失效。这意味着预签名 URL 天然限定了单个对象、单个操作,不能用来列目录或越权访问别的对象——这正是它比「公开 bucket」安全的地方。

一个常见的错误是把整个 bucket 设为公开可读来省事。那等于所有对象永久对外,既无法撤权也易被爬。正确做法是 bucket 保持私有,访问一律走预签名 URL 或 CDN + 回源鉴权。

14.2.7 删除与生命周期

删除单个对象:

s3Client.deleteObject(b -> b.bucket(bucket).key(key));

S3 的删除默认是「幂等」的:删除不存在的对象也返回成功,不会报错——这让「删两次」不会出问题,但也意味着你无法靠删除接口判断对象是否真的存在,需要 headObject 单独确认。

更省事的是生命周期策略(lifecycle):在 bucket 上配置规则,让对象存储自己处理过期与分层,而不是应用写定时任务。

  • 过期清理:如「tmp/ 前缀下超过 7 天的对象自动删除」,用于清理未完成的上传、临时导出文件。
  • 存储分层:如「30 天未访问转到低频访问层,180 天转到归档层」,降低长期存储成本。
  • 清理未完成的分片上传:分片上传若中途放弃,已上传的分片会一直占空间并计费。生命周期规则里要配 AbortIncompleteMultipartUpload,这是最容易被忽略的一笔隐性成本(14.3 再展开)。

生命周期是 bucket 级配置,通常在基础设施代码(Terraform 等)里声明,而不是在应用启动时用 SDK 去设——应用不该有改 bucket 全局策略的权限。

14.2.8 Spring Boot 4.x 的自动配置来自哪里

这里要澄清一个容易误解的点:Spring Boot 核心并没有内置对象存储的自动配置。 Spring Boot 4.x 的模块化清单里没有 S3 相关的 starter;spring-boot-starter-webmvc 只管 Web,不碰对象存储。

对象存储的自动配置来自 Spring Cloud AWS 项目,坐标为 io.awspring.cloud:spring-cloud-aws-starter-s3(核实版本线为 4.2.0,4.x 对应 Spring Boot 4.x)。它提供的自动配置类是:

io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration

引入 starter 后,它会根据 spring.cloud.aws.* 配置自动装配三个 bean:S3Client、S3Presigner、S3Template。属性前缀是 spring.cloud.aws.s3(由 io.awspring.cloud.autoconfigure.s3.properties.S3Properties 承载),其中对本项目最有用的几个:

# 自建对象存储需要 path-style 寻址
spring.cloud.aws.s3.path-style-access-enabled=true
# 上传校验相关(按需)
spring.cloud.aws.s3.checksum-validation-enabled=true

用 starter 的好处是少写 14.2.2 里那段手工构建代码;代价是多一个依赖,且版本要与 Spring Boot 对齐。如果不想引入 Spring Cloud AWS,就自己按 14.2.2 用 S3Client.builder() 建 bean,功能完全等价——两条路选一条,别混着配,否则可能出现两个 S3Client bean 冲突。

14.2.9 常见坑

坑一:静态 access key 硬编码并提交。 一旦泄露,攻击者能读写甚至删除整个 bucket。用实例角色或密钥管理服务,静态 key 只作兜底并定期轮换。

坑二:bucket 设为公开读。 省了预签名的麻烦,换来永久暴露与无法撤权。保持私有,访问走预签名 URL。

坑三:预签名 URL 有效期设成 7 天以上。 用临时凭证时会超过凭证有效期直接失败;即便用长期 key,长有效期也放大了泄露风险。按业务给几分钟到几小时。

坑四:不给 contentType。 对象存成 binary/octet-stream,预签名访问时浏览器强制下载。上传时就固定正确的类型。

坑五:对象键集中在同一前缀。 高并发写入可能形成分区热点。键里加日期与短随机段打散。

坑六:忘了配 AbortIncompleteMultipartUpload。 未完成的分片长期占空间并计费。生命周期里补上。

坑七:本地 MinIO 用 path-style、云上没关。 寻址风格不匹配导致签名或 404 错误。按环境正确设置 pathStyleAccessEnabled。

小结

  • 对象存储把文件与应用实例解耦;用 S3 兼容接口可获得最广的生态与最低的迁移成本,本地用 MinIO 自建。
  • 凭证优先级:实例角色/工作负载身份 > 密钥管理服务 > 静态 key;静态 key 必须最小权限并定期轮换,轮换要「先加后删」。
  • bucket 名有硬约束且全局唯一,区域就近选;对象键用「租户/日期/bookId/uuid」分层,兼顾列举效率、热点分散与多租户隔离。
  • 上传用 putObject,contentType 与 contentLength 必填准确,自定义元数据只放小字段;大文件走分片(14.3)。
  • 预签名 URL 是限定「单对象单操作」的临时通行证,有效期要短;不要用公开 bucket 替代它。
  • 删除幂等,但真正的清理交给生命周期策略,尤其别忘 AbortIncompleteMultipartUpload。
  • Spring Boot 核心不含对象存储自动配置;S3 自动配置来自 Spring Cloud AWS 的 spring-cloud-aws-starter-s3,自动配置类为 io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration,属性前缀 spring.cloud.aws.s3。

对象键与预签名打好了底,接下来处理最难的部分:几十 GB 的大文件怎么传、断了怎么续、以及如何让客户端绕过应用服务器直传。

阅读导航:上一节:14.1 上传下载与大文件 · 下一节:14.3 断点续传与直传 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计