昨天實戰演練:雲端前哨站 AWS S3 最小權限原則(IAM)到本地服務連線我們成功建立了 AWS S3 Bucket,並利用最小權限原則配置了安全、專屬的 IAM 使用者金鑰。今天,我們就要正式在 Java 21 環境中,雙劍合璧地寫出兩套不同 I/O 模型的 S3 上傳引擎:傳統的 Apache 同步阻塞上傳與現代的 Netty 非同步非阻塞上傳!
在 Java 生態系中,AWS Java SDK v2 (2.x 版) 與舊版 v1 最大的底層差異在於:v2 的架構天生支援非同步、非阻塞 I/O。
根據官方提供的依賴選項,我們可以將 SDK 支援的 HTTP 用戶端分為 5 種:
| HTTP 用戶端 | 同/異步 | 使用情況 | 限制/撤回 |
|---|---|---|---|
| Apache HttpClient | 同步 | 偏好低延遲而非高輸送量時使用 | 與其他 HTTP 用戶端相比,啟動時間較慢 |
| URLConnection HttpClient | 同步 | 限制第三方相依性時的輕量選擇 | 不支援 HTTP PATCH 方法 (例如 API Gateway 更新操作 |
| AWS CRT Sync HttpClient | 同步 | 適合 AWS Lambda、追求高輸送量的同步情境 | 不支援 javax.net.ssl 相關 KeyStore 屬性 |
| Netty NioAsync HttpClient | 非同步 | 支援 HTTP/2,適合高併發海量連線 | 相比其他用戶端,啟動時間較慢 |
| AWS CRT Async HttpClient | 非同步 | 適合 AWS Lambda、追求高輸送量的非同步情境 | ,不支援 HTTP/2 與 javax.net.ssl 屬性 |
分享一下之前測試AWS S3的不同HTTP Client端連線高併發下 P90 延遲壓測圖,在面對極端高併發(使用者2000甚至3000以上)寫入的狀況下,Netty 的整體效能與穩定度是最好的,延遲時間保持在最低水準。反觀同步的 Apache 和 URLConnection 連線方式,在併發數攀升後,效能都出現了大幅下降或波動。換言之如果使用apche連線服務,一旦使用者數變增加,每個使用者等待的時間大幅提升。
AWS SDK v2 的 S3 連線 Client 劃分為兩大介面,且它們底層所綁定的 HTTP 連線引擎截然不同:
本次兩種連線執行流程示意圖:

為了不受 Spring Boot 框架啟動時間與背景執行緒的干擾,我們今天先不建立 Spring 專案,而是建立一個最單純的 Java Maven 專案 來進行乾淨的測試。
在 IDE (如 IntelliJ IDEA 或 vscode) 建立一個基礎的 Maven 專案,並參考下方pom.xml的dependency。我們需要引入 AWS SDK v2 的 S3 模組,以及今天要對決的兩個底層 HTTP 用戶端引擎:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>ithome-maven</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
<aws.sdk.version>2.30.38</aws.sdk.version>
<junit.version>5.11.4</junit.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>${aws.sdk.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- AWS S3 SDK -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<!-- Apache 同步 HTTP Client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>apache-client</artifactId>
</dependency>
<!-- Netty 非同步 HTTP Client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>netty-nio-client</artifactId>
</dependency>
<!-- 日誌輸出 (過濾掉 AWS 底層雜訊) -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.12</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
</plugin>
</plugins>
</build>
</project>
設定好依賴後,我們在 src/main/java/com/example 底下建立兩支獨立的執行類別 (Runners),一支跑 Apache,一支跑 Netty,這樣對照起來最清晰!這樣簡單建立就可以瞜~
這支程式展現了最傳統的寫法,先在前面填上你的金鑰和密鑰,以及昨天建立的BucketName。還有第二步驟的 .region(Region.AP_SOUTHEAST_2) 地區要選擇對應自己Bucket的地區,忘記可以上aws console查詢
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.http.SdkHttpClient;
import software.amazon.awssdk.http.apache.ApacheHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
public class S3ApacheSyncRunner {
// 替換為你在 Day 6 建立的 IAM 金鑰
private static final String ACCESS_KEY = "access key";
private static final String SECRET_KEY = "secret key";
private static final String BUCKET_NAME = "your-bucket-name";
public static void main(String[] args) {
System.out.println("【Apache 同步測試】主執行緒啟動:" + Thread.currentThread().getName());
long startTime = System.currentTimeMillis();
// 1. 建立 Apache HTTP Client 底層引擎
SdkHttpClient apacheHttpClient = ApacheHttpClient.builder()
.build();
// 2. 實例化同步 S3Client
try (S3Client syncClient = S3Client.builder()
.region(Region.AP_SOUTHEAST_2) // Day 6 選擇的區域
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)
))
.httpClient(apacheHttpClient) // 指定使用我們配置的 Apache 引擎
.build()) {
// 3. 準備上傳請求
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(BUCKET_NAME)
.key("apache-sync-test.txt")
.build();
System.out.println("【Apache 同步測試】準備發出 S3 請求,執行緒即將阻塞...");
// 4. 執行上傳 (此處會卡死,直到 S3 回應)
syncClient.putObject(putObjectRequest, RequestBody.fromString("Hello Apache Sync Upload!"));
System.out.println("【Apache 同步測試】上傳成功!當前執行緒:" + Thread.currentThread().getName());
} catch (Exception e) {
e.printStackTrace();
}
long endTime = System.currentTimeMillis();
System.out.println("【Apache 同步測試】總耗時:" + (endTime - startTime) + " ms");
}
}
接著請大家仔細思考看看,依樣畫葫蘆,要怎麼完成這隻程式碼呢? 我們會需要用到import software.amazon.awssdk.services.s3.S3AsyncClient; 且改用 NettyNioAsyncHttpClient
執行方式有兩種,因為這兩個檔案裡都各自有一支 public static void main(String[] args) 方法,這在 Java 中代表它們是兩個完全獨立的「程式進入點」。所以可以直接點擊程式畫面左側的RUN或是用cli指令都可以
mvn compile exec:java -Dexec.mainClass=com.example.S3ApacheSyncRunner
mvn compile exec:java -Dexec.mainClass=com.example.S3NettyAsyncRunner
========== 測試 A:Apache 同步上傳 ==========
【Apache 同步測試】主執行緒啟動:main
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
【Apache 同步測試】準備發出 S3 請求,執行緒即將阻塞...
【Apache 同步測試】上傳成功!當前執行緒:main
【Apache 同步測試】總耗時:2105 ms
========== 測試 B:Netty 非同步上傳 ==========
【Netty 非同步測試】主執行緒啟動:main
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
【Netty 非同步測試】準備發出 S3 請求,主執行緒發完直接放行!
【Netty 非同步測試】主執行緒可以去忙別的事了,不需等待上傳完成。
【Netty 背景回呼】上傳成功!負責回呼的執行緒:sdk-async-response-0-0
【Netty 背景回呼】實際完成總耗時:2056 ms
【Netty 非同步測試】程式結束。 ───
開頭看到 SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder". 其實不用擔心,這只是提醒體SLF4J沒有綁定而已
implementation("org.slf4j:slf4j-simple:2.0.13") 引進一個極簡的日誌實作`main vs. sdk-async-response-0-0main 執行緒在呼叫 putObject 時,被死死地釘在了原地。整整 2.1 秒中,這條執行緒除了在記憶體裡乾等,什麼事都做不了(阻塞 I/O)main 發出上傳請求後,下秒就被釋放,後續上傳成功的呼叫是sdk-async-response-0-0來執行,換言之main呼叫完就被解放去做其他事,此時 S3 的上傳甚至才剛剛開始!補充 : 由於Netty 的 EventLoop 執行緒極其寶貴(通常與你的 CPU 核心數相同),它們必須以微秒級的速度不斷處理高頻的網路 I/O 多路複用。故AWS SDK v2導入一個安全防禦機制,設計了一個內部線程池:sdk-async-response。 當 Netty 完成傳輸、收到 HTTP Response 時,它會將「上傳成功」的回呼通知(即你的 .thenAccept 邏輯)丟給這組背景執行緒來處理。這樣,Netty 就能轉身繼續處理下一個網路 Socket,而不需要被使用者的 Callback 邏輯給拖累!這是一種極為完美的職責隔離(Separation of Concerns)架構設計。
原因很簡單因為我們目前只上傳了一個 1KB 的小型檔案 :
進入Day 6 提到的aws s3介面就可以清楚看到已經上傳完的檔案了喔!
今天我們成功用 Java 21 寫出了Apache 同步與Netty 非同步,並且成功在控制台抓到了背景跑腿的 Netty Event Loop 執行緒!
明天,我們將使用spring架構將這些服務包裝成API的形式,不再只是單純本終端機中執行了!後續也會使用 JMeter 對這兩套連線模式做壓力測試,見證同步 Apache 連線池崩潰,以及非同步 Netty 如何用僅僅 8 個 Event Loop 執行緒從容抵抗高併發!