iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0

昨天實戰演練:雲端前哨站 AWS S3 最小權限原則(IAM)到本地服務連線我們成功建立了 AWS S3 Bucket,並利用最小權限原則配置了安全、專屬的 IAM 使用者金鑰。今天,我們就要正式在 Java 21 環境中,雙劍合璧地寫出兩套不同 I/O 模型的 S3 上傳引擎:傳統的 Apache 同步阻塞上傳與現代的 Netty 非同步非阻塞上傳!

理解底層:AWS SDK v2 的連線引擎設計

在 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連線服務,一旦使用者數變增加,每個使用者等待的時間大幅提升。
https://ithelp.ithome.com.tw/upload/images/20260902/20183864mXWJhrnUQI.jpg

AWS SDK v2 的 S3 連線 Client 劃分為兩大介面,且它們底層所綁定的 HTTP 連線引擎截然不同:

  1. S3Client (同步阻塞):
    • 預設引擎:ApacheHttpClient
    • 運作機制:採用一請求一執行緒 (One Thread Per Request) 模型。發起 S3 請求的執行緒會一直卡住,直到 S3 回應了,該執行緒才被釋放。
  2. S3AsyncClient (非同步非阻塞):
    • 預設引擎:NettyNioSdkHttpClient
    • 運作機制:採用事件驅動 (Event-driven) 與多路複用模型。發起請求的主執行緒只負責「註冊上傳任務」,隨後便能立刻回去接別的客;實際的數據傳輸由 Netty 底層少數的 Event Loop 執行緒處理,上傳成功後,再透過 Java 的 CompletableFuture 執行回呼 (Callback) 邏輯。

本次兩種連線執行流程示意圖:

https://ithelp.ithome.com.tw/upload/images/20260901/20183864iEo5lgH6Gq.jpg

實作準備:建立純 Java 的 Maven 專案

為了不受 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,這樣對照起來最清晰!這樣簡單建立就可以瞜~
https://ithelp.ithome.com.tw/upload/images/20260903/20183864VCdcFjfXMV.png

Apache 同步上傳引擎 (S3ApacheSyncRunner.java)

這支程式展現了最傳統的寫法,先在前面填上你的金鑰和密鑰,以及昨天建立的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");
    }
}

Netty 非同步上傳引擎 (S3NettyAsyncRunner.java)

接著請大家仔細思考看看,依樣畫葫蘆,要怎麼完成這隻程式碼呢? 我們會需要用到import software.amazon.awssdk.services.s3.S3AsyncClient; 且改用 NettyNioAsyncHttpClient

執行方式有兩種,因為這兩個檔案裡都各自有一支 public static void main(String[] args) 方法,這在 Java 中代表它們是兩個完全獨立的「程式進入點」。所以可以直接點擊程式畫面左側的RUN或是用cli指令都可以
https://ithelp.ithome.com.tw/upload/images/20260902/20183864TINGX07Wmx.png

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 警告是什麼?

開頭看到 SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder". 其實不用擔心,這只是提醒體SLF4J沒有綁定而已

  • 原因:SLF4J 是日誌的抽象介面(Facade),就像是餐廳的菜單。但我們的專案裡目前還沒有引進任何具體的日誌實現(Slf4j-simple、Logback 或 Log4j2)來當主廚,所以 SLF4J 找不到人工作,只好退化為「什麼都不印」的 NOP (No-Operation) 模式。
  • 解決方案:它不影響程式執行。若想消除這行警告,只需要在 build.gradle.kts 的 dependencies 區塊中,引入具體的日誌實現即可 implementation("org.slf4j:slf4j-simple:2.0.13") 引進一個極簡的日誌實作`

執行緒名稱的差異 main vs. sdk-async-response-0-0

  • Apache中發出請求和執行上傳的執行緒是同一個,代表 main 執行緒在呼叫 putObject 時,被死死地釘在了原地。整整 2.1 秒中,這條執行緒除了在記憶體裡乾等,什麼事都做不了(阻塞 I/O)
  • Netty中 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)架構設計。

單次上傳的總耗時差不多(2105 ms vs 2056 ms)幹嘛還要非同步?

原因很簡單因為我們目前只上傳了一個 1KB 的小型檔案 :

  • 網路物理限制:在單次、小檔案的場景下,時間完全消耗在本機跨越太平洋到 AWS 遙遠機房的網路物理延遲 (RTT) ── 包含 TCP 三向交握、SSL/TLS 安全加密握手、IAM 簽章校驗,最後才是寫入 S3。非同步並不能讓網路封包飛得比光速還快
  • 非同步的精髓:不在於縮短單次任務的耗時,而在於徹底解放主執行緒,大幅提升整體的吞吐量與併發抗壓能力。 同步模型下,100 個併發就會卡死 100 條實體執行緒;非同步模型下,Netty 只需要極少數的執行緒,就能一邊維持著高併發連線,一邊把任務往背景丟

AWS S3 檢查

進入Day 6 提到的aws s3介面就可以清楚看到已經上傳完的檔案了喔!
https://ithelp.ithome.com.tw/upload/images/20260902/2018386482IT1dJCzp.png

總結

今天我們成功用 Java 21 寫出了Apache 同步與Netty 非同步,並且成功在控制台抓到了背景跑腿的 Netty Event Loop 執行緒!

明天,我們將使用spring架構將這些服務包裝成API的形式,不再只是單純本終端機中執行了!後續也會使用 JMeter 對這兩套連線模式做壓力測試,見證同步 Apache 連線池崩潰,以及非同步 Netty 如何用僅僅 8 個 Event Loop 執行緒從容抵抗高併發!

參考資料


上一篇
[ Day 6 ] 實戰演練:雲端前哨站 AWS S3 最小權限原則(IAM)到本地服務連線
下一篇
[ Day 8 ] 從引擎到微服務:用 Spring Boot 同時封裝 Apache 與 Netty S3 API
系列文
學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言