半年前我接手了一个内部数据同步服务,代码里跑着的还是 AWS SDK for Java 1.12.x,依赖树里躺着十几个aws-java-sdk-*模块,编译一次要喝杯咖啡。业务上想加一个新的流式处理接口,发现 v1 的同步模型让连接池和线程分配都变得很别扭,于是彻底动了升级 AWS SDK for Java 的念头。这篇文章就是把那次升级过程中踩过的坑、拆解过的思路和最后沉淀下来的迁移步骤完整写出来,希望能给正在犹豫要不要动这块“老代码”的人一个可参考的路线图。
这次升级不是我一个人拍脑袋决定的,团队里有人担心破坏现有功能,也有人说 v2 的 API 变化太大。但真正把所有调用点列出来之后会发现,AWS SDK for Java 从 v1 到 v2 的迁移并不可怕,可怕的是你还没搞清楚变化点就盲目替换依赖。如果你维护的系统中也大量使用 v1 的客户端,或者你正准备把旧服务迁移到更现代的异步架构,这篇文章可以帮你提前避掉至少一半的坑。
1. 升级动机:v1与v2到底差在哪
1.1 老SDK的瓶颈与新版SDK的承诺
先说结论:AWS SDK for Java v2 不是简单换个包名,而是通信层、客户端模型、API 组织方式的一次重写。v1 时代最让我头疼的问题有三个。
第一是依赖臃肿。v1 的每个服务模块几乎都会把 Jackson、Apache HttpClient、旧版 Netty 等一堆公共组件拖进来,管理依赖冲突就像在垃圾堆里找一根针。第二是连接管理同步阻塞。默认的请求方式很容易让线程在 I/O 等待上打转,服务的线程池稍微调不好就被调用方的超时打满。第三是 SDK 本身的演进速度变慢,很多新服务、新接口只在 v2 里有,想要用新功能就必须被迫升级。
v2 的承诺很直接:组件化、可插拔、异步优先。它把 HTTP 客户端拆成了url-connection-client、apache-client、netty-nio-client等独立实现;把服务 API 按模块拆分,比如只引入s3就不需要拉进整个aws-java-sdk胖依赖;异步客户端基于 Java 8CompletableFuture,配合 Netty 可以做到单线程模型处理大量并发请求。这些都不是锦上添花的优化,而是能直接影响服务稳定性的事。
1.2 版本迁移中容易被忽略的三个差异点
很多人在升级前会下意识地看方法名,认为withRegion改成region就可以了。但真正让老代码崩掉的往往不是这些表面差异,而是下面三个容易被忽略的点。
二进制包名完全不同。v1 的类是com.amazonaws.services.s3.AmazonS3Client,v2 是software.amazon.awssdk.services.s3.S3Client。这意味着所有 import 都要改,但也正因为包名不同,v1 和 v2 的 SDK 可以同时存在于同一个 classpath 里。这是渐进式迁移的重要前提。
异常模型从“统一大异常”变成了“专用异常”。v1 基本上所有错误都抛AmazonServiceException,你用一个e.getMessage()去判读错误码。v2 里不同的错误类型对应不同的异常类,比如S3Exception、DynamoDbException,异常里还会带requestId和statusCode。代码里如果到处用instanceof AmazonServiceException做错误处理,迁移时就要逐段改成更细粒度的捕获。
对不可变对象的要求变了。v1 的客户端虽然也是线程安全的,但内部很多配置是可变的,你可以在运行期改setEndpoint,也能通过setConnectionTimeout调整。v2 把客户端设计成不可变对象,所有配置只能在构建时通过builder设置。这个设计更健壮,但也意味着你想在运行期动态调整某个参数,就必须重新构建客户端,或者在一开始就配置好。
1.3 先做选择题:全面迁移还是局部迁移
升级之前一定要先做选择。我的建议是:如果你的项目只是用了两三个服务,代码量在十万行以内,直接全面迁移更划算;如果是一个大型分布式系统,各模块由不同团队维护,最好选择局部迁移。
局部迁移依赖一个事实:v1 和 v2 可以共存。因为 v1 的类名是com.amazonaws.*,v2 是software.amazon.awssdk.*,JVM 把它们当成完全不同的两个库。你可以先让核心模块使用 v2 的新客户端,其他模块继续用 v1 的老客户端,然后按照模块边界逐个替换。但共存也有代价,比如会出现两套 HTTP 连接池、两套指标上报,甚至因为底层 Netty 版本冲突导致启动时类加载异常。所以局部迁移一定要配合严格的依赖排除和版本统一。
我当时采用的做法是“先冷后热”:先把不重要的定时任务和报表服务切换成 v2,跑两周观察指标;再把在线请求链路上的核心客户端迁移过去,最后统一把 v1 的依赖从 pom 里拔干净。整个过程花了一个多月,但每个阶段都有回退方案,错误粒度可控。
2. 动手前的盘底:依赖与代码面
2.1 把依赖树拉出来看
任何迁移的第一步都是盘点现状。如果你用 Maven,直接跑:
mvn dependency:tree -Dincludes=com.amazonaws:* -Dverbose如果你用 Gradle,可以跑:
gradle dependencies --configuration runtimeClasspath | grep "com.amazonaws"这一步能让你看清到底引了哪些 v1 模块、哪些是显式声明的、哪些是传递依赖带进来的。我遇到过一个服务,代码里自己只用 S3,但 pom 里被人写了一个aws-java-sdk全家桶,结果依赖树里全是aws-java-sdk-ec2、aws-java-sdk-sns这类根本用不到的模块。这类胖依赖是升级时最容易出鬼的地方,建议直接把用不到的排除掉。
盘完依赖之后,还要统计代码里出现的import com.amazonaws.*的位置。一个简单的方法是在 IDE 里全局搜索,或者用 grep 命令:
grep -rl "com.amazonaws.services" src/main/java | wc -l我当时统计下来大概有两百多个文件涉及 v1 API,但真正需要手工改的其实只有不到五十个文件,剩下都是自动 import 调整就能搞定的。
2.2 识别高频调用的核心API
依赖清单不等于调用面。你需要把代码里的客户端创建、请求构造、响应解析都找出来,统计每个服务 API 的使用频率。我的习惯是写一个临时脚本,扫描所有 Java 文件,按照AmazonS3、AmazonDynamoDB、AmazonSNS这类类名归类,再按方法名看一眼高频调用。
高频调用意味着迁移风险高,也意味着你必须把这些 API 的 v2 写法吃透。低频调用可以直接照官方迁移指南改,甚至可以暂时不做替换,继续保留一段 v1 代码。
另外,重点检查项目中是否有下面这些 v1 独有写法:
- 直接 new 客户端实现类,比如
new AmazonS3Client(credentials) - 使用
AmazonHttpClient或者自定义Request对象执行原始请求 - 依赖
AmazonServiceException的getErrorCode()做条件分支 - 手动处理分页,比如循环
getNextToken()
这些写法在 v2 里都可能没有对应的方法,迁移时需要重构成新的模式。
2.3 升级节奏:我喜欢用的三阶段法
第一阶段叫“热身”。只引入 v2 的依赖和最基本的客户端,写一个最简单的调用 demo 放到测试环境里跑通。这阶段目的是验证依赖冲突情况,以及本地凭证链能不能正常加载,不要碰业务代码。
第二阶段叫“迁移核心”。把高频调用涉及的客户端和相关逻辑完全切换到 v2,其他模块继续用 v1。此时代码库中会同时出现两种 SDK,构建时要注意排除传递依赖。
第三阶段叫“清理”。当所有模块都切换完毕,把 pom 或 build.gradle 里的 v1 依赖彻底删掉,全局搜索确保没有com.amazonaws开头的 import,然后跑全量回归测试。
这个节奏的好处是,每个阶段都有可验证的产物,不会出现一改改一个月、最后合并时冲突爆炸的情况。
3. 关键迁移实操:构建、客户端与请求写法
3.1 Maven/Gradle坐标替换与BOM管理
v2 的依赖坐标完全不同。以 S3 为例,Maven 下原来的坐标是:
<dependency> <groupId>com.amazonaws</groupId> <artifactId>aws-java-sdk-s3</artifactId> <version>1.12.xxx</version> </dependency>换成 v2 后变成了:
<dependency> <groupId>software.amazon.awssdk</groupId> <artifactId>s3</artifactId> </dependency>注意 v2 的 artifact 名不再带aws-java-sdk-前缀,服务名就是短横线风格,比如dynamodb、sns、sqs。版本号我建议不要写在每个依赖里,而是通过 BOM 统一控制,这样能避免多个服务模块版本不一致。加 BOM 的方式是:
<dependencyManagement> <dependencies> <dependency> <groupId>software.amazon.awssdk</groupId> <artifactId>bom</artifactId> <version>2.25.25</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>然后各模块只写groupId和artifactId,版本全交给 BOM。这样的好处是以后升级只改一处,不用翻遍 pom。如果你用 Gradle,可以写:
dependencies { implementation platform('software.amazon.awssdk:bom:2.25.25') implementation 'software.amazon.awssdk:s3' implementation 'software.amazon.awssdk:dynamodb' }不要一开始就引入netty-nio-client这样的传输层依赖。除非你有明确的异步需求,否则让服务客户端自动选择默认的 HTTP 客户端实现会更省心。默认配置对大多数场景已经够用,额外引入传输层只会增加版本冲突的可能。
3.2 客户端构建:从withX到builder的一一对应
v1 时代最经典的客户端创建方式是这样:
AmazonS3 s3Client = AmazonS3ClientBuilder.standard() .withRegion("us-east-1") .withCredentials(new ProfileCredentialsProvider("default")) .build();v2 变成了:
S3Client s3Client = S3Client.builder() .region(Region.US_EAST_1) .credentialsProvider(DefaultCredentialsProvider.create()) .build();这个变化看起来很简单,但有几个细节值得注意。
第一,v2 的Region是一个枚举类,不要直接往.region()里传一个裸字符串,应该使用Region.of("us-east-1")或对应的枚举常量。第二,credentialsProvider参数的类型是AwsCredentialsProvider,v1 的很多实现类都不能直接搬过来,需要改成 v2 自己的实现。第三,builder模式和 Lombok 的@Builder很像,但你可能会忽略客户端构建是一个昂贵的操作,应该在应用启动时构建一次并且复用,不要在每个请求里 new 一个 client。
我见过一个典型错误:有人把S3Client.builder().region(Region.US_EAST_1).build()写到了请求处理方法里,压测时创建客户端的开销直接把 CPU 打满。v2 客户端是不可变且线程安全的,官方也明确说可以长期持有,放心复用。
3.3 请求对象写法变化:以S3与DynamoDB为例
v1 的请求构造偏向于 JavaBean 风格,先 new 一个PutObjectRequest,再 set 各种属性:
PutObjectRequest putObjectRequest = new PutObjectRequest() .withBucketName(bucket) .withKey(key) .withInputStream(inputStream); s3Client.putObject(putObjectRequest);v2 的请求对象变成了builder风格,甚至可以直接用 lambda 简化:
s3Client.putObject(builder -> builder .bucket(bucket) .key(key) .build(), RequestBody.fromInputStream(inputStream, contentLength));lambda 的写法第一次看会觉得魔幻,但它能让代码更简短,也更符合函数式风格。如果你不习惯,也可以先构造完整的请求对象:
PutObjectRequest request = PutObjectRequest.builder() .bucket(bucket) .key(key) .build(); s3Client.putObject(request, RequestBody.fromFile(file));关键改动点在RequestBody。v1 直接传File或InputStream,v2 需要显式包装成RequestBody,并且推荐使用RequestBody.fromFile,因为能拿到文件长度,避免流传输时的未知长度问题。
DynamoDB 的迁移更麻烦一点。v1 常用DynamoDBMapper来做对象映射,这个类在 v2 中没有直接对应,官方建议用DynamoDbClient搭配software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient来做类似功能。如果你只是用原生的putItem、getItem,那改动相对小:
// v1 Item item = new Item().withPrimaryKey("id", 123).withString("name", "zhang"); table.putItem(item); // v2 Map<String, AttributeValue> item = new HashMap<>(); item.put("id", AttributeValue.fromN("123")); item.put("name", AttributeValue.fromS("zhang")); dynamoDbClient.putItem(req -> req.tableName("demo").item(item));v2 把AttributeValue变得更重要,很多方法参数不再是Item,而是Map<String, AttributeValue>。如果你之前依赖Item的链式方法,切换后需要一个个属性都转成AttributeValue,这一步很容易漏。
3.4 重试、超时与HTTP客户端配置迁移
v1 的客户端超时和重试配置分散在ClientConfiguration里,比如:
ClientConfiguration config = new ClientConfiguration() .withConnectionTimeout(10_000) .withRequestTimeout(10_000) .withMaxConnections(200); AmazonS3 client = AmazonS3ClientBuilder.standard() .withClientConfiguration(config) .build();v2 把这些配置分成了两大部分:一部分放在ClientOverrideConfiguration,用来控制重试策略和 API 调用超时;另一部分放在 HTTP 客户端的 builder 里,用来控制连接池和套接字超时。
推荐做法是使用ClientOverrideConfiguration.builder().retryStrategy(...)设置重试策略,而 HTTP 层面的超时则根据你选择的传输层来配置。比如用 Apache 客户端:
S3Client s3 = S3Client.builder() .region(Region.US_EAST_1) .httpClientBuilder(ApacheHttpClient.builder() .connectionTimeout(Duration.ofSeconds(10)) .socketTimeout(Duration.ofSeconds(10)) .maxConnections(200)) .overrideConfiguration(ClientOverrideConfiguration.builder() .apiCallAttemptTimeout(Duration.ofSeconds(30)) .retryStrategy(RetryMode.STANDARD)) .build();这里要强调一下,v2 的默认重试策略比 v1 更激进,它默认会对某些瞬时错误自动重试,还会考虑流式请求的幂等性。如果你之前在 v1 里自定义过maxErrorRetry,迁移后要注意这个参数在新版中的位置变了,否则你配置了可能不生效。
另一个容易踩坑的是apiCallTimeout和apiCallAttemptTimeout的区别。前者是单次 API 调用的总超时,后者是每次重试 attempt 的超时。不要把两者设置成一个很小的值,否则重试还没开始就整体超时了。
4. 常见问题与排查:升级路上最容易翻车的点
4.1 运行时NoSuchMethodError和依赖冲突
升级后最常见的噩梦就是启动时出现NoSuchMethodError或ClassNotFoundException。我遇到过的一个典型案例是项目里同时有 v1 的aws-java-sdk-core和 v2 的s3模块,v2 底层依赖的新版 Netty 和另一个中间件传递进来的旧版 Netty 撞在一起,启动时某个类加载到了旧版本,直接抛NoSuchMethodError。
排查这类问题没有捷径,先看异常栈里的类名属于哪个 jar,再用mvn dependency:tree -Dverbose找出是谁带进来的旧版本,最后通过exclusion把传递依赖排掉。以 Maven 为例:
<dependency> <groupId>software.amazon.awssdk</groupId> <artifactId>s3</artifactId> <exclusions> <exclusion> <groupId>io.netty</groupId> <artifactId>netty-codec-http</artifactId> </exclusion> </exclusions> </dependency>不过,手动 exclusion 只是治标,更稳妥的办法是让整个项目统一使用 BOM 引入的版本,或者检查中间件是否也依赖了 netty,如果有,把它升级到和 SDK v2 一致的版本。
另一个常见错误是代码里直接用了com.amazonaws.util.IOUtils这类内部工具类。这类工具类在 v2 里可能仍然存在,但包名完全不同,而且官方并不保证它们稳定。迁移时最好替换成 JDK 自带的方法,或者统一封装一个工具方法,避免下次升级又踩一遍。
4.2 凭证加载链变化导致本地开发直接失败
v1 和 v2 的默认凭证链顺序略有差异,最典型的坑是本地开发环境配置了~/.aws/credentials,v1 能正常读,v2 却报了CredentialsNotFoundException。
原因可能是你的 profile 名和默认加载规则不一致。v2 的DefaultCredentialsProvider默认加载名为default的 profile,并且会按顺序检查环境变量、Java 系统属性、Web Identity Token、profile 文件、ECS/EC2 元数据。而你的程序可能用的是 v1 的ProfileCredentialsProvider,它虽然也会读 profile,但默认 profile 文件和 profile 名的处理方式略有不同。
解决办法不是盲改代码,而是先确认本地要使用哪种凭证来源。如果是 profile,建议显式指定:
ProfileCredentialsProvider.builder() .profileName("my-profile") .build();如果服务跑在云环境里,不要依赖本地 profile,直接用环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY,或者用实例配置文件自动获取。代码里不要硬编码任何密钥,因为这个版本升级往往伴随着代码 review,硬编码密钥被扫出来又是一顿麻烦。
4.3 异步客户端资源泄漏与线程池耗尽
v2 的异步客户端很香,但用不好会直接拖垮服务。最常见的问题是异步客户端的内部线程池没有被正确地挂到服务生命周期上。
异步客户端默认基于 Netty 事件循环,如果你的 Spring 服务在关闭时没有调用s3AsyncClient.close(),Netty 线程池会一直占用,严重时会导致 JVM 无法正常退出。更隐蔽的是,如果你为每个请求都创建一个新的异步客户端,那么每个客户端都会启动一套独立的 Netty 线程池,并发一上来,线程数直接爆炸,表现为各种OutOfMemoryError或者Unable to allocate Thread。
正确的做法和同步客户端一样:异步客户端也应该是全局单例,并且需要在服务生命周期结束时关闭。在 Spring Boot 里,可以用@Bean(destroyMethod = "close")来注册:
@Bean(destroyMethod = "close") public S3AsyncClient s3AsyncClient() { return S3AsyncClient.builder() .region(Region.US_EAST_1) .build(); }另外,异步调用返回的是CompletableFuture,很多人拿到 future 后直接.get()阻塞等待,这等于把异步写成了同步,线程池依然被打满。v2 异步客户端最大的价值是让你可以并发发起多个请求,比如同时读多个文件,然后用CompletableFuture.allOf(...)合并结果。如果实在要用阻塞方式,那我建议直接用同步客户端,代码更简单。
4.4 分页、流式响应与资源关闭的坑
v1 的分页非常啰嗦,需要手动判断lastEvaluatedKey或nextContinuationToken,然后循环请求。v2 里大量服务提供了*Paginator机制,可以直接用迭代器或流式处理,比如 S3 的listObjectsV2Paginator:
ListObjectsV2Request request = ListObjectsV2Request.builder() .bucket(bucket) .build(); s3Client.listObjectsV2Paginator(request) .contents() .forEach(obj -> process(obj));分页器会自动帮你发后续请求,不用再手写循环。这个特性我很喜欢,但在迁移时也要小心:分页器返回的流式数据如果来自异步请求,你需要正确关闭底层资源,否则会浪费连接。
另一个和流式响应有关的大坑是getObject的返回处理。v1 的S3Object.getObjectContent()返回S3InputStream,用完要 close。v2 返回ResponseInputStream,属于AutoCloseable,如果你用 try-with-resources 就能安全关闭:
try (ResponseInputStream<GetObjectResponse> input = s3Client.getObject(req -> req.bucket(bucket).key(key))) { // 处理输入流 }如果你没有关闭,连接会一直占着,最终连接池耗尽。这也是很多迁移后线上偶发超时的原因。
5. 上线回检与个人经验
5.1 升级完成后的检查清单
迁移完成后不要急着直接替换线上流量,我建议按下面这个清单回检。
第一,全局搜索import com.amazonaws,确保没有遗漏 v1 的 import。如果确实还有,说明有代码没有迁移,需要确认是故意保留还是漏改。
第二,检查依赖树,确认 v1 的aws-java-sdk-*已经不在运行时 classpath 里。如果还残留,用dependency:tree找到传递来源并排除。
第三,验证客户端数量。在监控或日志里统计S3Client、DynamoDbClient等客户端被创建了多少次,确保它们是单例,不会在请求路径里重复创建。
第四,重点测试重试和超时场景。可以通过人为配置一个错误 endpoint 来观察是否触发重试,也可以设置一个很小的 socket timeout 来看异常是否被正确捕获并返回业务错误。
第五,验证异步资源释放。做一轮压测,观察线程池指标和连接池指标在压测结束之后是否回落到基线。
第六,跑一遍涉及凭证、分页、流式上传下载的用例,确保本地环境和测试环境都通过。
5.2 我的实测体会与建议
这次升级让我最大的感受是:v2 的 API 设计确实更符合现代 Java 的习惯,但迁移成本并不只是改几个 import 那么简单,真正花时间的是清理隐含依赖和处理不可见的状态管理问题。
我特意把同步客户端和异步客户端混用的场景压测过,发现一个很微妙的地方:如果你在同一个服务里同时使用同步S3Client和异步S3AsyncClient,那么默认会创建两套独立的 HTTP 连接池。如果你的服务被压满,连接池之间不能复用,导致资源利用率没有想象中高。混合使用时,建议考虑只用一种客户端,或者至少明确设置两个客户端分别复用相同的 HTTP 客户端实例。
另外,v2 的ClientOverrideConfiguration在设计上很强大,但也容易让人过度工程化。我看到有些团队一上来就配了自定义重试策略、自定义超时、自定义executor,结果排查问题时反而无法确认是 SDK 的哪个配置引发的超时。新手阶段最好先用默认配置跑通,再逐步按性能目标调整。
如果你也被一个老项目的 SDK 版本卡了很久,我的建议是不要等“有大版本空窗期”再动手。先把 BOM 锁好,再把高频使用的接口迁移过去,跑一个月稳定后清理残留依赖,这个过程只要节奏稳,就不会出现事故。等 v1 彻底退出后,你回头看那些旧代码,会觉得整个工程都清爽了很多。