iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Software Development

學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構系列 第 19

[ Day 19 ] 記憶體與連線池殺手 : 放寬timeout限制會降低錯誤率還是會更卡 ?

  • 分享至 

  • xImage
  •  

昨天我們成功在 JMeter 上傳併發測試中,證明了前面寫的保護api的功能將 3 秒超時引發的錯誤率從 53% 降至 0%,並透過 Grafana 發現真正的瓶頸在於外部網路 I/O 延遲。

這時大家可能有個非常直覺的疑問:

既然 bottleneck 是網路延遲,那我把 從 3 秒放大到 15 秒(甚至 30 秒),給外部網路充裕的時間傳輸,不就不會發生超時了嗎?

今天我們就來嘗試一下放寬 Timeout 究竟是效能救星還是另一場悲劇的開端呢?

本地實作

話不多說今天先來做實驗再回頭說理論!

放寬 Timeout

要調整timeout的設定我們目前有三個地方需要調整:

  1. S3Config.java — Netty async client 的底層網路 timeout(connectionTimeout / readTimeout / writeTimeout)

  2. S3Config.java — SDK override 的 apiCallAttemptTimeout / apiCallTimeout

public S3AsyncClient s3AsyncClient() {
  return S3AsyncClient.builder()
    .region(region)
        .credentialsProvider(DefaultCredentialsProvider.create())
        .httpClientBuilder(NettyNioAsyncHttpClient.builder()
            .maxConcurrency(100) // Netty 併發連線數上限
            .connectionTimeout(Duration.ofMillis(15000))
            .readTimeout(Duration.ofMillis(15000))
            .writeTimeout(Duration.ofMillis(15000))
        )
        .overrideConfiguration(configuration -> configuration
            .apiCallAttemptTimeout(Duration.ofMillis(14000)) // 略低於 Netty timeout,讓 SDK 先觸發
            .apiCallTimeout(Duration.ofMillis(15000)))       // 給 HEAD Double-Check 一點空間
        .build();
    }
  1. application.yml — Resilience4j 的 slowCallDurationThreshold(10s → 15s)

因為三層的關係如下:

connectionTimeout/ writeTimeout   ← Netty 底層,最先觸發
        ↓
apiCallAttemptTimeout             ← SDK 單次嘗試上限
        ↓
apiCallTimeout                    ← SDK 整體呼叫上限(含重試)
        ↓
slowCallDurationThreshold 

Jmeter 重新測試舊版 API 壓力測試

昨天的壓測中我們發現,100並發上傳的過程中,沒保護的上傳API錯誤率高達53%,主要問題是等待時間超過,所以我們強制將timeout放寬至 15 秒,接著就要來測試,重新docker compose up --build一次(因為有做程式碼的調整所以要重新建立image),再打開 JMeter 對上傳 API 發射100並發

https://ithelp.ithome.com.tw/upload/images/20260915/20183864uaknEFMsGP.png

透過Jmeter的結果發現錯誤率下降到15%!代表昨天timeout的結論真的是瓶頸,還有失敗就代表時間還是不夠長,不過我們不可能一昧地調長等待時間,使用者是會生氣的!

Grafana監控

我們來看看監控記錄會發現CPU < 30%、Heap 只有 117MB,系統卻在噴錯?有15%失敗率,因為在放寬 Timeout 的情況下,卡在背景等待網路 I/O 的 TCP Socket 在記憶體中佔用極小、也不消耗 CPU 算力。

傳統的硬體監控會給我們「系統很閒」的假象,但底層的 Socket 連線池與通道(Channel)早已在背景被長時間鎖死無法釋放!這也證實了:高可用系統不能只看硬體資源,更必須透過背壓(Backpressure)與 Fail-Fast 限速釋放連線!

https://ithelp.ithome.com.tw/upload/images/20260915/20183864SD43vBgB1D.png

為什麼盲目調大 Timeout 是治標不治本?

所以回過頭來看看為什麼我們不能無限制地放寬 Timeout?原因有三:

1. 使用者體驗的將低

在 HTTP 世界中,使用者點擊上傳後,超過 3 秒沒有回應就會開始感到焦慮,超過 10 秒就會認定系統卡死並開始瘋狂重整頁面。放寬到 15 秒雖然讓後端數據看起來錯誤率下降了,但實際上是讓使用者在手機前白白卡住死等 15 秒!我們在刷社群的時候等個三五秒就受不了了,如果有一個按鈕就要等15秒,根本沒人要用了!

2. 依然有 15% 的失敗:時間長不代表會成功

在我們的 15 秒 Timeout 實驗中,依然產生了 15% 的失敗。這代表:當網路壅塞或 S3 機房回應慢時,單純延長等待時間並不能解決物理上的網路丟包或 S3 速率限制(503 SlowDown)。失敗只是說明「15 秒依然不夠長」,但我們不可能設成無限大!

3. 連線池與 Socket 資源被硬生生乾涸(Connection Starvation)

這也是最可怕的後端災難:放寬 Timeout 會讓每一個失敗或卡住的連線,在背景多佔用 Socket 資源 15 秒才肯釋放!


利特爾法則與容量規劃公式

如果放寬timeout或是提升連線池其實都可以降低錯誤率,但又不能開太大會造成socket負擔,那摩最佳組合到底是多少呢?現在為了讓大家理解Timeout 與連線池的物理關係,我們引入 queuing theory 經典的利特爾法則 (Little's Law):

https://ithelp.ithome.com.tw/upload/images/20260915/20183864y3ZqJwIOQY.png
(我打不出這些符號先用截圖的XD,參考gemini 摘要)

1. 利特爾法則與硬體

假設我們的微服務目標吞吐量 lambda = 100 RPS

  1. 當 Timeout = W(回應時間) = 2 秒時:L(所需的背景連線數) = 100 * 2 = 200 (個併發 Socket)
  2. 當 Timeout = W(回應時間) = 15 秒時:L(所需的背景連線數) = 100 * 15 = 1500(個併發 Socket)

我們可以發現等待時間和背景所需連線數成正比,換言之當把 Timeout 放寬 7.5 倍時,為了維持相同的 RPS,微服務背景必須同時維持 7.5 倍的連線數!

Linux 作業系統對每一個 TCP 連線都有 ulimit -n 開啟檔案限制,如果你的 Netty 連線池(maxConcurrency)或伺服器 Socket 數量沒有跟著放大 7.5 倍,連線池會在幾秒內被全數乾涸,引發更慘烈的排隊延遲與雪崩!

2. 記憶體 (JVM Heap / Direct Memory)

記憶體預算公式:容器記憶體預算 (MB) = 併發連線數 * 單一請求 Buffer 大小 + JVM 基礎開銷

在 Docker 容器設定中:

  • 假設 Semaphore 門閥限制 L = 50 個併發 Permits。
  • 每個 S3 上傳請求包含 5MB 的檔案快取(Buffer)。
  • JVM 基礎開銷約 150MB。

預估記憶體 = (50 * 5 MB) + 150 MB = 400 MB

我們在 Day 18 將 Docker Compose 記憶體限制設定為 memory: 512M、JVM -Xmx384m 的背後邏輯都是精確計算出來的,絕非憑空捏造!

如果沒有 Semaphore(50) 控流,當 L 暴增到 1500 時: 所需記憶體 = (1500 * 5 MB) + 150 MB = 7650 MB (7.65 GB) 這會直接觸發 Linux OOM Killer,當場把 Docker 容器殺死!

除了連線數暴增,放寬 Timeout 也會直接吞噬 JVM 堆積記憶體(Heap Memory)。


正確的解法:Fail-Fast 與背壓控流

既然放寬 Timeout 是在向未來借債,那正確的架構設計是什麼?

答案是 Fail-Fast(快速失敗)與 Semaphore 背壓控流:

  1. 控制合理的 Timeout:將 API Timeout 設定在安全的 2.5s ~ 3s 之間。
  2. Semaphore(50) 門閥秒級攔截:當背景已經有 50 個連線在等待網路時,第 51 個請求在 0.1ms 內直接回傳 HTTP 429 Too Many Requests
  3. 把不確定的死等轉化為確定的 429 背壓:前端收到 429 後可以進行帶有退避演算法(Exponential Backoff)的重試,微服務背景執行緒完全不卡死!

總結

今天透過理論與實作來告訴大家幾件事:

  1. Timeout 迷思:放寬 Timeout 雖能將錯誤率從 53% 降至 15%,但代價是連線池乾涸、記憶體暴增與使用者體驗將低。
  2. 利特爾法則:Timeout 放大 N 倍,背景連線池開銷同步放大 N 倍。
  3. 容量規劃:透過數學公式精算連線數與 RAM 預算,做到真正的量入為出。
  4. 架構正道:保持短超時,搭配 Semaphore(50) 實現 Fail-Fast,才是高併發微服務的防禦正道!

明天我們將為listObject api加入保護措施必且也為其做新舊api的壓力測試,來見證一個完善的服務中在listObject和putObject這兩種最常使用呼叫的api應該要怎麼設計!


上一篇
[ Day 18 ] CPU 不到 40% 卻卡死?Grafana監控網路瓶頸與防禦型 API 實戰對照
下一篇
[ Day 20 ] 讀寫保護大不同 - ListObject API 加上 Semaphore 是反效果?快取機制才是王道
系列文
學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言