iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Software Development

Spring Boot + Kotlin 協程高併發,後端開發新選擇系列 第 19 篇

Day 18:協程遇上 R2DBC 連線池,小心連線被偷偷耗盡

  • 分享至 

  • xImage
  •  

Day 17:背壓是什麼,生產太快時該怎麼踩煞車 結尾留下一個問題:背壓機制確實能避免資料無限制堆積,但消費端實際處理這些資料時,往往需要依賴某項數量有限的資源,例如要把每一筆庫存異動寫入資料庫,就需要用到資料庫連線。如果連線數量本身有上限,這項資源被耗盡時又會發生什麼事?今天要正面回答這個問題。

資料庫連線,一項容易被忽略的有限資源

Day 12:R2DBC,讓資料庫存取也不阻塞執行緒 定案了本系列的資料庫技術堆疊,PostgreSQL 搭配 R2DBC,並且提過一句話:R2DBC 除了查詢與寫入這兩個最基本的操作之外,也有連線池的概念。當時只點出這件事存在,具體的連線池大小設定與高併發下的風險情境,留到今天處理。

今天正是要展開這一塊。前面幾篇陸續看過 Semaphore 節流保護下游服務、withTimeout 逾時取消避免協程卡死、Channel 背壓調節生產與消費的速度落差,這三種控制手段處理的分別是呼叫端節流、單次呼叫逾時、資料流速度失衡。

今天要把焦點收斂到另一項具體資源,資料庫連線本身。無論訂單查詢、庫存異動寫入,最終都要透過一條資料庫連線才能真正跟資料庫對話,連線這項資源如果沒有被妥善管理,同樣可能在高併發情境下出狀況。

一個容易誤解的推論,非阻塞是不是就不怕連線不夠用

熟悉 R2DBC 的讀者,很容易產生一個看似合理的推論:既然 R2DBC 是非阻塞的資料庫存取規格,不會像傳統阻塞式呼叫那樣佔用執行緒,資料庫連線數量是不是就不再是需要擔心的問題?

這個推論混淆了兩件事。回頭看 Day 02:Thread-per-Request 模型的極限,搞懂你原本在用什麼 定案的阻塞定義,一條執行緒在等待某個操作完成之前,無法去處理任何其他請求,即使它自己當下什麼都沒在做。R2DBC 的非阻塞特性解決的正是這個問題,等待資料庫回應的那段期間,執行緒不會被卡住,可以先去處理其他工作。這件事在 Day 12 已經講得很清楚。

但「等待資料庫回應時執行緒是否被佔用」跟「資料庫同時能承受多少條連線」,是完全不同層次的問題。資料庫伺服器本身能同時維持的連線數量,受限於記憶體、檔案描述符、連線處理程序這些實體資源,永遠存在一個上限。這個上限不會因為應用程式端改用非阻塞的存取方式就自動消失,它是資料庫伺服器這一端的限制,跟呼叫端用什麼語法呼叫它,完全是兩回事。

R2DBC 提供的 ConnectionPool,連線池,正是用來管理這項有限資源的機制。

它維護一組數量有上限的連線,供應用程式重複借用,用完歸還,而不是每次查詢都重新建立一條連線。連線池存在的前提,正是連線本身是稀缺資源,需要被集中管理、限量供應。這件事本身建立了一個問題意識:既然是限量供應,供給不足會發生什麼事,具體風險留到下一節展開。

非阻塞連線池的等待,暫停而非阻塞

在往下談具體風險之前,有一件事必須先講清楚,這也是本篇相對於系列其他篇章最需要特別澄清的一點。

如果讀者過去有使用傳統阻塞式連線池的經驗,例如常見的 Java 連線池實作,在連線池已滿時,等待取得連線的執行緒會陷入 Day 2 定義的阻塞狀態,執行緒動彈不得,只能乾等有連線被釋放。這種情境如果搭配協程使用,等待連線的協程所在執行緒會被卡住,而 Dispatcher 背後可用的執行緒資源原本就有限,一旦有執行緒因為等待連線而動彈不得,可能連帶拖累這個 Dispatcher 要處理的其他協程,形成風險。

R2DBC 提供的非阻塞連線池,行為完全不同。當連線池已滿時,等待取得連線的協程,會以 Day 03:第一個 suspend function,協程到底暫停了什麼 定義的暫停方式等待,執行緒會被釋放去處理其他工作,而不是像傳統阻塞式連線池那樣卡住執行緒。這個系列後續章節,包括今天與 《Day 24:庫存扣減的併發正確性,用資料庫機制取代分散式鎖》,談連線池搭配情境時一律以這個非阻塞行為為前提,全系列不涉及傳統阻塞式連線池與協程搭配的風險情境,那是另一套完全不同的問題。

這裡要正式點出今天真正要處理的風險核心是什麼。

等待行為換成暫停而不是阻塞,確實避免了執行緒被拖垮的問題,但連線池已滿這件事本身依然存在,依然會讓後續請求排隊等候。如果排隊等候的時間拉長,使用者實際感受到的回應延遲一樣會上升,這跟執行緒有沒有被卡住無關,純粹是連線這項資源的供給,暫時追不上需求。

換句話說,非阻塞解決的是執行緒資源被浪費的問題,解決不了資源數量本身有限這個事實,這正是今天要正式定案的既定錨點:協程與 R2DBC 連線池搭配時,等待連線的行為雖然是暫停,但連線供需失衡依然會造成排隊,兩件事必須分開看待,不能因為前者成立就誤以為後者也一併消失。

連線池被打滿時,具體會發生什麼事

把這個風險放進示範情境具體化一下。假設訂單查詢與扣庫存服務設定的 R2DBC 連線池大小為 50,這代表任何時刻最多只有 50 條連線能同時被應用程式使用。組態設定大致長這樣:

spring:
  r2dbc:
    url: r2dbc:postgresql://localhost:5432/orders
    pool:
      enabled: true
      initial-size: 10
      max-size: 50
      max-acquire-time: 3s

max-size 設定了連線池能同時提供的連線上限,這裡是 50。max-acquire-time 則是協程等待取得連線的時間上限,超過這個時間仍拿不到連線,會直接以逾時失敗收場,而不是無止盡地排隊等下去,這件事等一下會再回來討論。

回到情境本身。當同時湧入的查詢請求遠超過 50 個時,超出的請求對應的協程必須排隊等候有連線被釋放,才輪得到自己使用。如果連線池大小設定得偏小、或者某些查詢因為某種原因遲遲不釋放連線,例如一筆交易卡在中間沒有正常結束,排隊等候的協程會越積越多,整體回應時間可能明顯上升,甚至在漫長的等待過程中觸發逾時而直接失敗。

連線池大小為 50 被打滿時的示意圖,左側是數量遠超過 50 的同時湧入請求,中間是 50 條連線全數借出的 R2DBC 連線池,右側是排在等候佇列中、狀態為暫停而非阻塞的請求協程,下方列出排隊協程的三種後續走向,有連線歸還後恢復、超過 max-acquire-time 逾時失敗、父範圍被取消時一併取消,並對照執行緒層面已解決、資源層面連線數量依然有上限

這正好可以回扣 [[Day 16:逾時與取消,讓卡住的協程別拖垮整個系統]] 建立的逾時概念。前面組態範例裡的 max-acquire-time 就是同樣精神的體現,如果為等待連線的協程設定合理的逾時時間,能在連線長期供不應求時,讓請求快速失敗並回應使用者「暫時無法處理,請稍後再試」,而不是讓協程無限期排隊等待,直到使用者自己放棄為止。具體的逾時秒數該怎麼設,跟 Day 16 談過的道理一樣,需要依實際情境權衡,這裡不重複展開。

連線池大小本身要設多大,也是一個需要綜合考量的設計決策,並非越大越好。

連線池能撐多大,最終要看資料庫伺服器本身能承受的連線數量上限,還有應用程式實際的併發規模。設得太大,可能讓資料庫伺服器同時要維護的連線數超出負荷,反而拖累整體效能;設得太小,則容易在請求量攀升時提早出現排隊。這是一個需要權衡的設計決策,這裡只建立這個問題意識,不提供放諸四海皆準的具體數值建議。

排隊等待連線的協程,一樣要守結構化並發的規矩

還有一件事值得簡短回扣。正在排隊等待資料庫連線的協程,並不是脫離所有約束、自己孤立存在的個體,它仍然是所屬父範圍底下的子協程。

回頭看 Day 06:Structured Concurrency,為什麼協程不能亂長亂放 定案的結構化並發規則,如果這個父範圍因為某種原因被取消,例如使用者提早斷線,這個正在等待資料庫連線的協程也應該能被一併取消,不會繼續佔用連線池的排隊名額。這個保障來自結構化並發的取消傳播機制,跟 Day 16:逾時與取消,讓卡住的協程別拖垮整個系統 談過的取消沿父子結構往下傳播,是同一套邏輯。等待連線這件事本身沒有豁免這條規則,該收的協程一樣會被收掉,不會因為它正卡在排隊佇列裡就變成例外。

控制手段都看過了,但真正的效果有多少

從 Day 15:併發限制,用 Semaphore 保護下游別被打爆 到今天,依序看過節流、逾時取消、背壓、連線池搭配這四種控制手段。每一種處理的有限資源不太一樣,Semaphore 保護的是下游服務的承載能力,逾時取消處理的是單次呼叫卡死的風險,背壓調節的是生產與消費之間的速度落差,今天談的連線池則是資料庫連線這項具體資源的供需平衡。四種手段各自對應一種資源型態,合起來構成高併發情境下相對完整的資源保護思維。

這四篇的敘事與情境描述,讀起來應該都算合理,每一種手段解決的問題也都說得通。但一個必須誠實面對的問題是,協程模型相對於 Day 02:Thread-per-Request 模型的極限,搞懂你原本在用什麼 定案的 Thread-per-Request 模型,實際上能帶來多少效能差異?光憑直覺描述、憑情境合理性判斷,並不足夠,這些說法終究需要具體數據來驗證,才能從「聽起來有道理」變成「確實可以這樣說」。

下一篇開始,會用具體數據把這件事說清楚。


上一篇
Day 17:背壓是什麼,生產太快時該怎麼踩煞車
系列文
Spring Boot + Kotlin 協程高併發,後端開發新選擇 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言