做 Java 后端这些年,跟云服务打交道基本是绕不开的。去年我负责的一个项目要从旧版工具库整体升级到 AWS SDK for Java 2.0,当时最大的感受就是:变化真大。如果你也正在纠结要不要迁移,或者刚接触这个 SDK 还不知道怎么上手,这篇文章基本能帮你避开我踩过的坑。内容围绕 AWS SDK for Java 2.0 的基础使用展开,适合正在学习 Java 云开发的读者,也适合准备从 v1 迁移的项目组成员。我会把版本变化、环境配置、实际调用、异步处理和排障方法一套讲完,尽量让小白也能照着跑通。
1. 先搞清楚 SDK 2.0 到底改了什么
1.1 从 v1 到 v2,不是换个版本号那么简单
很多老项目还在用 v1 的经典方式写代码:AmazonS3ClientBuilder.standard().withRegion(...).build()。这套 API 本身没什么大毛病,但维护成本越来越高。AWS 官方对 v1 的态度已经转向“只修 bug 不加新功能”,新服务和新增特性基本只出现在 v2 上。所以如果是从零开始新项目,直接用 2.0 几乎是不用犹豫的选择。
v2 最大的变化是把整个 SDK 拆成了按服务分模块的小构件。以前一个aws-java-sdk大包能塞进几十个服务的依赖,现在你只需要引入s3、ec2、dynamodb这种按需的模块。构建体积小了很多,类冲突也少了不少。我第一次用 Maven 引入software.amazon.awssdk:s3的时候还愣了一下,因为坐标和 v1 完全不同。这个细节后面会详细说。
另一个显著变化是客户端创建方式全面转向 Builder 模式。v2 里你很少看到直接new一个客户端,基本都是S3Client.builder().region(...).build()。这种写法看起来啰嗦,但好处很明显:配置项可以按需组合,代码可读性更好,也更容易做参数校验。
异步 API 是 v2 的压轴变化。原来 v1 的异步靠Future<?>硬等,体验很差;v2 全面用CompletableFuture,真正支持了回调式、编排式的异步编程。我后面会单独用一个章节讲这块,因为很多人在同步 API 上踩坑之后才意识到异步的价值。
还有个容易被忽略的点:v2 底层 HTTP 客户端是可替换的。默认情况下,同步客户端用 Apache HttpCore,异步客户端用 Netty。如果你在特定的运行时环境里受依赖冲突困扰,还可以换成基于 URLConnection 的实现。这是一个很强的解耦设计,但需要你在初始化时显式指定,否则代码不会自动切换。
1.2 2.0 的编程模型是怎么组织的
不管你用哪个服务,只要接触过 v2,它的调用链基本都是三板斧:创建客户端、构造请求、执行调用。看起来简单,但这里有两个容易搞混的细节。
第一,请求和响应对象都是不可变的,只能通过 Builder 生成。比如你要构造一个PutObjectRequest,得这样写:
PutObjectRequest request = PutObjectRequest.builder() .bucket("my-bucket") .key("demo/hello.txt") .contentType("text/plain") .build();注意build()方法最后调用,如果漏了参数或者参数类型不匹配,IDE 里会直接报错。这比 v1 里一堆具有重载的方法少了很多歧义。
第二,客户端线程安全问题。v2 的客户端设计上是线程安全的,可以全局共享一个实例。所以不要在每次调用时都new一个客户端,这是新手最容易犯的毛病。频繁创建客户端不仅浪费资源,而且在默认的 HTTP 连接池场景下可能引发连接耗尽。我一般会在项目里用容器管理的单例,或者在启动时创建后放到静态字段里。
响应处理上,同步方法会直接返回响应对象,异步方法返回CompletableFuture。各服务的方法名基本遵循“动词+资源”的命名方式,描述性很强。比如s3.putObject()、ec2.startInstances()、dynamodb.putItem()。习惯了之后,查文档几乎不费劲。
2. 环境与依赖:搭好开发脚手架
2.1 JDK 版本与 Maven 依赖
先明确一点:AWS SDK for Java 2.0 官方要求 JDK 8 以上,但我建议直接在 JDK 11 或 17 上开发。理由很实在,JDK 8 虽然能跑,但 HTTP client 的默认 TLS 支持偏老,而且很多依赖库已经逐渐放弃对 Java 8 的持续优化。如果你是企业项目,Java 11 算是兼容性和性能的折中选择。
依赖管理第一建议是使用 BOM(Bill of Materials)。直接用单模块会有版本号管理麻烦,尤其是项目里同时用了 S3、DynamoDB、KMS 的时候,很容易出现一个模块升级另一个模块落后的问题。Maven 里加上 BOM 之后,子模块可以不写版本号:
<dependencyManagement> <dependencies> <dependency> <groupId>software.amazon.awssdk</groupId> <artifactId>bom</artifactId> <version>2.20.150</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>software.amazon.awssdk</groupId> <artifactId>s3</artifactId> </dependency> <dependency> <groupId>software.amazon.awssdk</groupId> <artifactId>ec2</artifactId> </dependency> </dependencies>版本号 2.20.150 是我项目里用过的比较稳定的版本。实际使用时,建议去官方文档查最新的版本,但不要盲目升级到刚发布的 minor 版本,等社区反馈一两周更稳妥。
Gradle 对应写法也很简单:
implementation platform('software.amazon.awssdk:bom:2.20.150') implementation 'software.amazon.awssdk:s3' implementation 'software.amazon.awssdk:ec2'还有一点:不要在你的项目里同时引入com.amazonaws:aws-java-sdk-bundle和software.amazon.awssdk:*这两个系列的依赖,否则类名和包名冲突会带来一堆NoClassDefFoundError。迁移项目时,最好一次性把所有 v1 的依赖从代码里清理干净。
2.2 凭证配置的几种方式,别把 Key 写死在代码里
每一位云服务开发者都会被告诫:永远不要把 Access Key 硬编码到代码里。但实际开发时,很多人为了省事还是会写在常量类里。我见过某个项目把 AK/SK 提交到 Git 仓库,后来只能紧急轮换密钥,非常被动。
v2 的凭证搜索链是固定的,按下面的优先级依次查找:
- 环境变量
AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY - JVM 系统属性
aws.accessKeyId和aws.secretAccessKey - 默认凭证文件(通常在
~/.aws/credentials) - 容器凭证(比如 ECS 环境)
- 实例关联的 IAM 角色(比如 EC2 上的实例角色)
这意味着你不需要在代码里显式配置凭证,SDK 自己会按这个顺序找到可用的凭证。本地开发时,最常见的方式是配置~/.aws/credentials文件:
[default] aws_access_key_id = YOUR_ACCESS_KEY aws_secret_access_key = YOUR_SECRET_KEY但注意,如果是在生产环境的 EC2 实例上运行,最好什么凭证都不配,直接给 EC2 绑定一个 IAM 角色。SDK 会自动从实例元数据服务获取临时凭证,这个临时凭证会自动轮转,安全性和便利性都很好。
如果你在本地确实需要手动指定凭证做测试,可以用StaticCredentialsProvider.create()。但我会建议你只在单元测试或本地 demo 里这么干:
AwsBasicCredentials creds = AwsBasicCredentials.create("key", "secret"); S3Client s3 = S3Client.builder() .region(Region.AP_SOUTHEAST_1) .credentialsProvider(StaticCredentialsProvider.create(creds)) .build();我的经验是:不要被“静态凭证”这几个字迷惑,它真的只适合本地临时调试。生产环境还是优先用 IAM Role 或环境变量。
3. 基础实操:S3 文件读写用例拆解
3.1 创建客户端与基本上传下载
S3 是很多人开始学这个 SDK 的第一个服务。先创建一个最简单的客户端:
S3Client s3 = S3Client.builder() .region(Region.AP_SOUTHEAST_1) .build();区域这里我用了AP_SOUTHEAST_1,也就是新加坡区域。不要觉得区域是可有可无的,v2 如果不显式设置区域,SDK 在某些环境下会直接抛出RegionException。S3 本身有些特殊,如果没有指定区域,SDK 会尝试从us-east-1访问,但如果你访问的桶不在这个区域,就会得到 301 或 400 错误。所以明确指定区域,是避免很多玄学问题的最好方法。
上传文件需要同时准备请求体和请求参数。下面这段代码是完整的上传逻辑:
S3Client s3 = S3Client.builder() .region(Region.AP_SOUTHEAST_1) .build(); PutObjectRequest request = PutObjectRequest.builder() .bucket("my-demo-bucket") .key("logs/2025/app.log") .contentType("text/plain") .build(); s3.putObject(request, RequestBody.fromFile(Paths.get("/tmp/app.log")));这里要注意RequestBody.fromFile()会直接读取整个文件到内存再上传吗?实际上 SDK 内部会尽量使用流式传输,小文件没问题,大文件有专门的 multipart 机制。如果你用RequestBody.fromString()传大字符串,就要自己估算内存了。我建议上传逻辑不要脱离对文件大小的判断,超过 100MB 时,考虑直接改用 S3 的 multipart API。
下载文件时,getObject返回的是一个ResponseInputStream,必须自己负责关闭流,否则会造成连接泄露。代码这样写:
GetObjectRequest getReq = GetObjectRequest.builder() .bucket("my-demo-bucket") .key("logs/2025/app.log") .build(); try (ResponseInputStream<GetObjectResponse> in = s3.getObject(getReq)) { Files.copy(in, Paths.get("/tmp/downloaded-app.log")); }我一开始总是忘记关闭这个流,导致测试环境出现大量的 TIME_WAIT 连接。后来习惯用 try-with-resources 之后,问题就再没出现过。
3.2 列表分页和大文件断点续传的思考
S3 里列出一个桶的所有对象,接口是listObjectsV2。但注意 S3 的列表接口是分页的,一次最多返回 1000 个对象。v2 给这个场景设计了一个很舒服的迭代器,叫做Paginator,你可以直接遍历所有结果而不需要手动处理下一页标记:
ListObjectsV2Request req = ListObjectsV2Request.builder() .bucket("my-demo-bucket") .prefix("logs/2025/") .build(); S3Iterable responses = s3.listObjectsV2Paginator(req); for (ListObjectsV2Response page : responses) { for (S3Object obj : page.contents()) { System.out.println(obj.key()); } }这个分页迭代器是我最喜欢的功能之一。它内部自动处理continuationToken,不用自己维护循环条件,对于对象数量很大时特别方便。
大文件上传是另一个高频场景。我当时的项目里需要上传 500MB 到 2GB 的备份文件,直接用putObject很容易超时。正确的做法是使用 S3 的分片上传接口。v2 没有提供独立的高层 API,但组合使用createMultipartUpload、uploadPart、completeMultipartUpload也不算复杂。这里分享一个简单的框架思路:
- 用随机 ID 调用
createMultipartUpload拿到uploadId - 将文件切分成 10MB 到 100MB 的分片(s3 限制最大分片数 10000)
- 并发上传分片,收集每个分片的 ETag
- 全部成功后调用
completeMultipartUpload合并分片
这个过程涉及很多细节,比如失败后要abortMultipartUpload,避免残留未完成的分片产生存储费用。如果你只是临时用一下,我建议快速度过,不要一开始就掉进断点续传的坑里。
4. 异步客户端与性能调优
4.1 CompletableFuture 异步调用
很多新手在尝试异步 API 时踩坑,是因为他们以为异步就是换一个返回类型那么简单。v2 的异步客户端命名很直接,在服务名前加Async,比如S3AsyncClient、DynamoDbAsyncClient。它的方法返回不是响应对象,而是一个CompletableFuture<T>。
最简单的使用姿势是:
S3AsyncClient s3Async = S3AsyncClient.builder() .region(Region.AP_SOUTHEAST_1) .build(); CompletableFuture<PutObjectResponse> future = s3Async.putObject( PutObjectRequest.builder().bucket("my-demo-bucket").key("async.txt").build(), RequestBody.fromString("hello async") ); future.whenComplete((resp, ex) -> { if (ex != null) { ex.printStackTrace(); } else { System.out.println("上传成功,ETag: " + resp.eTag()); } });这段代码执行时,主线程不会阻塞在putObject上,而是立即返回。等上传完成,回调会在 SDK 内部的线程池中被执行。这里就出现第一个大坑:回调线程和主线程不是同一个,回调里如果操作了非线程安全的对象,必然出问题。
我当时写过一个数据上报服务,刚用异步时经常出现NullPointerException,排查半天发现是回调里直接更新了一个ArrayList,而这个ArrayList被多个线程共享。后来改用ConcurrentHashMap或者把数据聚合放到主线程,才稳定下来。
还有一个细节:异步请求发出后,务必记得异常处理。如果future链条上没有挂任何回调,异常会被吞掉,甚至可能导致请求超时后无人感知。我习惯在每次请求后都强制接入exceptionally:
future.exceptionally(ex -> { log.error("异步请求失败", ex); return null; });4.2 超时、重试与连接池配置
v2 允许你通过客户端 Builder 自定义多项超时和重试参数,这部分是生产环境高可用的关键。默认配置虽然可以运行,但在网络抖动时表现不够好。
最常用的三个参数:
apiCallTimeout:整个请求的超时时间,包括重试等待、读响应等所有环节。apiCallAttemptTimeout:单次尝试的超时。retryPolicy:配置重试次数和重试策略。
一个比较稳妥的配置是这样:
S3Client s3 = S3Client.builder() .region(Region.AP_SOUTHEAST_1) .overrideConfiguration( ClientOverrideConfiguration.builder() .apiCallTimeout(Duration.ofSeconds(60)) .apiCallAttemptTimeout(Duration.ofSeconds(20)) .retryPolicy(RetryPolicy.builder() .numRetries(3) .retryMode(RetryMode.ADAPTIVE) .build()) .build() ) .build();apiCallTimeout设成 60 秒,适合普通文件操作;如果做视频处理或者大文件备份,我建议调大一些,但不要一概处理。apiCallAttemptTimeout设成 20 秒,是为了单次请求快速失败,避免一个死连接拖垮整个调用链。
重试策略我建议用ADAPTIVE,它相比STANDARD多了对限流错误的指数退避优化。你以为重试次数越多越好?不一定。如果服务端已经在限流,重试太频繁反而容易把熔断级别的错误推高。经验值是 3 到 5 次,超过 5 次的收益很小。
连接池方面,如果使用同步客户端,常见的是ApacheHttpClient.Builder:
ApacheHttpClient httpClient = ApacheHttpClient.builder() .maxConnections(200) .connectionTimeout(Duration.ofSeconds(5)) .socketTimeout(Duration.ofSeconds(30)) .build(); S3Client s3 = S3Client.builder() .httpClient(httpClient) .build();maxConnections需要根据业务并发量调,并不是越大越好。我见过一个团队把连接数调到 10000,结果把本机端口资源打满,应用直接假死。一般起步 100 到 300,然后压测时观察监控曲线微调。
异步客户端对应的底层是 Netty 连接池,配置项名不太一样。通常不需要刻意修改,除非你的请求并发极高。注意异步客户端在关闭应用时最好调用close(),否则 Netty 的线程池会阻止 JVM 正常退出。
5. 从 v1 迁移到 v2:常见问题和排查实录
5.1 凭证和区域错误速查表
迁移或者初次使用的过程中,我整理过一张错误对照表,碰到以下报错基本可以秒定位:
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
Unable to load credentials | 没有找到任何凭证提供方 | 配置环境变量、凭证文件或 IAM 角色 |
The provided token is malformed | 临时凭证过期或格式错误 | 刷新 STS 令牌,或检查系统时间同步 |
Region is missing或region must not be null | 创建客户端时没有设置 Region | builder 中显式.region() |
The bucket you are attempting to access must be addressed using the specified endpoint | S3 桶和请求区域不一致 | 将客户端的 Region 改为桶所在区域 |
Request Time Skewed | 客户端系统时间与云服务时间偏差超过限定 | 启用 NTP 时间同步 |
Connection pool shut down | 客户端已经 close,继续调用 | 不要复用已关闭的客户端 |
这些错误里出现频率最高的是凭证缺失。我建议在项目启动时做一个“自检”,先尝试用凭证提供者执行一次轻量的调用(比如sts.getCallerIdentity()),如果失败就直接抛异常并给出明确的提示文案。这样能在开发期尽早暴露问题,比请求真正发送出去后一无所获要强很多。
5.2 版本冲突与 classpath 问题
迁移中最让人头疼的是依赖冲突。v2 内部使用了一些公共依赖,比如netty、jackson、apache httpclient。如果你的项目里还有其他框架也会引入这些库,同名不同版本就会导致NoSuchMethodError或ClassNotFoundException。
这类错误隐蔽在启动阶段,动不动就让人怀疑是不是自己代码写错了。有一次我的服务启动时直接抛NoSuchMethodError: okhttp3.RequestBody.create,查了 Maven 依赖树才发现是某个消息队列客户端把 OKHttp 版本拉低了,而 AWS SDK 里的 S3 传输层需要更高版本的方法。后来通过 Maven 的 excluded 把冲突的间接依赖排除掉,问题立刻消失。
排查依赖冲突的标准命令:
mvn dependency:tree -Dverbose -Dincludes=software.amazon.awssdk重点看同一个groupId:artifactId是否出现了多个版本。如果存在,使用 dependencyManagement 钉死版本,或者用<exclusions>排除不需要的传递依赖。
在 Gradle 项目里,可以用这个命令:
gradle dependencyInsight --dependency software.amazon.awssdk注意 v2 的包名前缀是software.amazon.awssdk,v1 是com.amazonaws。如果项目从 v1 升级后出现重复的S3Client类,几乎可以确定是两个版本的 SDK 同时存在于 classpath,必须彻底移除 v1 依赖。
5.3 日志与调试的实用技巧
SDK 内部自带日志系统,默认使用 java.util.logging,但很多项目用的是 SLF4J 或 Log4j2。如果你发现 SDK 的日志信息没有出现在自己的日志文件里,大概率是桥接没有做好。我建议引入slf4j-simple或直接配置slf4j绑定到项目现有的日志实现。在开发环境,为了排查请求细节,可以打开 SDK 的请求/响应日志:
System.setProperty("software.amazon.awssdk.http.service.telemetry.enabled", "true");但更常用的是在 logback 或 log4j2 配置里把某个 logger 级别调到 DEBUG:
software.amazon.awssdk.request:查看请求阶段software.amazon.awssdk.response:查看响应阶段software.amazon.awssdk.http.apache:查看底层 HTTP 细节
开启 DEBUG 后你会看到每次请求的连接耗时、TLS 握手耗时、重试次数等。有一次我遇到偶发的高延迟,就是靠这个日志发现某些请求在 TLS 握手阶段消耗了 3 秒,后来定位到是本地网络设备对特定 TLS 指纹做了拦截,换了一种 JDK 后再也没出现。
不过我必须提醒:DEBUG 日志会记录部分请求元信息,但不会打印正文内容。即便这样,在正式环境也别轻易开 DEBUG,日志量会非常大,还会影响性能。我的习惯是生产环境保持 WARN 级别,只在预发环境或临时加日志时打开 DEBUG。
在代码层面,还有一个很实用的排查技巧:为客户端配置一个自定义的ExecutionInterceptor。v2 提供了拦截器机制,你可以在请求发出前和收到响应后插入额外逻辑。下面是一个记录请求耗时的简单实现:
public class TimingInterceptor implements ExecutionInterceptor { @Override public void beforeExecution(Context.BeforeExecution context, ExecutionAttributes executionAttributes) { executionAttributes.putAttribute(new ExecutionAttribute<>("startTime", System.currentTimeMillis())); } @Override public void afterExecution(Context.AfterExecution context, ExecutionAttributes executionAttributes) { long start = executionAttributes.getAttribute(new ExecutionAttribute<>("startTime")); System.out.println("请求耗时: " + (System.currentTimeMillis() - start) + "ms"); } }这个拦截器不只是诊断用,你还可以在请求头里统一添加自定义 header,或者做跨服务的 traceId 透传。我后来把项目里的全链路 traceId 机制就建立在拦截器上,比在业务代码里一层层传参要优雅得多。
最后分享一个我自己的小习惯:无论用哪个服务,我都会在客户端初始化后打印一条包含 SDK 版本、区域、期望超时时间的启动日志。这样即使半年后再回看线上日志,也能立刻判断当前进程使用的是哪一套配置。这个习惯帮我省过不少排查时间,你可以试试。