半年前我接手了一个内部数据同步服务代码里跑着的还是 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.AmazonS3Clientv2 是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 -Dincludescom.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 groupIdcom.amazonaws/groupId artifactIdaws-java-sdk-s3/artifactId version1.12.xxx/version /dependency换成 v2 后变成了dependency groupIdsoftware.amazon.awssdk/groupId artifactIds3/artifactId /dependency注意 v2 的 artifact 名不再带aws-java-sdk-前缀服务名就是短横线风格比如dynamodb、sns、sqs。版本号我建议不要写在每个依赖里而是通过 BOM 统一控制这样能避免多个服务模块版本不一致。加 BOM 的方式是dependencyManagement dependencies dependency groupIdsoftware.amazon.awssdk/groupId artifactIdbom/artifactId version2.25.25/version typepom/type scopeimport/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参数的类型是AwsCredentialsProviderv1 的很多实现类都不能直接搬过来需要改成 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或InputStreamv2 需要显式包装成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 MapString, 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而是MapString, 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 groupIdsoftware.amazon.awssdk/groupId artifactIds3/artifactId exclusions exclusion groupIdio.netty/groupId artifactIdnetty-codec-http/artifactId /exclusion /exclusions /dependency不过手动 exclusion 只是治标更稳妥的办法是让整个项目统一使用 BOM 引入的版本或者检查中间件是否也依赖了 netty如果有把它升级到和 SDK v2 一致的版本。另一个常见错误是代码里直接用了com.amazonaws.util.IOUtils这类内部工具类。这类工具类在 v2 里可能仍然存在但包名完全不同而且官方并不保证它们稳定。迁移时最好替换成 JDK 自带的方法或者统一封装一个工具方法避免下次升级又踩一遍。4.2 凭证加载链变化导致本地开发直接失败v1 和 v2 的默认凭证链顺序略有差异最典型的坑是本地开发环境配置了~/.aws/credentialsv1 能正常读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 的listObjectsV2PaginatorListObjectsV2Request 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 (ResponseInputStreamGetObjectResponse 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 彻底退出后你回头看那些旧代码会觉得整个工程都清爽了很多。
阅读完成 · 觉得有帮助?