Day 14:小結:三種技術選型的判斷依據,MVC、WebFlux 各用在哪 結尾留下一個尖銳的問題:即使底層是 WebFlux 搭配 R2DBC 這種從骨子裡就非阻塞的組合,如果同時湧入的請求量大到連下游資料庫或外部服務都吃不消,架構本身的非阻塞特性並不會自動幫忙節流。
今天要正式回答這個問題,也正式進入併發深化期的第一篇。
生態整合期花了五天處理「怎麼讓執行緒不被白白浪費」這件事,協程搭配 WebFlux 與 R2DBC,確實讓應用程式本身能夠輕鬆接住數千個同時湧入的請求,不再需要為每個等待中的請求綁死一條執行緒。
但這件事帶來一個容易被忽略的副作用,應用程式能扛住的併發量,跟它背後依賴的下游資源能扛住的併發量,完全是兩回事。
回到訂單查詢與扣庫存服務這個示範情境。
假設確認庫存這件事,不是單純查一張本地資料表就能完成,而是需要呼叫另一個內部服務,這個內部服務可能還連著自己的資料庫,也可能還要再往下呼叫倉儲系統。這個服務本身有自己的處理能力上限,不會因為訂單服務換了架構就跟著變強。
促銷活動開賣那一刻,數千個查詢請求幾乎同時湧入訂單服務。因為協程與 WebFlux 打底,訂單服務接得住,沒有任何一條執行緒被占死等待。
但接下來訂單服務要做的事,是把這幾千個請求裡涉及確認庫存的部分,幾乎同時轉發給那個內部服務。原本被上游擋住、被迫排隊的壓力,一點沒少,只是換了個地方爆發。內部服務可能因為瞬間湧入遠超過自己能處理的併發呼叫,回應時間急遽拉長,甚至直接過載當掉。協程沒有製造出新的問題,它只是把上游原本吸收下來的壓力,原封不動轉嫁給了下游。
面對這個現象,直覺的反應可能是「那就讓下游服務也升級成扛得住高併發的架構」,但這個思路有個盲點,下游資源的處理能力上限往往不是訂單服務這邊能決定的事,可能是另一個團隊維護的服務,可能牽涉到資料庫本身的連線數上限,也可能單純是外部合作方提供的 API 有明確的流量配額。與其假設下游永遠能被無限擴充,不如換一個角度思考:呼叫端能不能主動控制自己同時發出去的請求數量。
這正是節流要解決的問題。核心邏輯並不複雜,與其讓所有請求一股腦地同時衝向下游資源,不如主動限制同時間真正在執行呼叫下游動作的協程數量,其餘的協程暫時排隊等候,等前面的協程完成後再依序輪到。這樣一來,下游資源感受到的壓力就是可控且穩定的,而非隨著上游流量瞬間暴衝。
這裡有一點需要澄清,節流不是在犧牲系統的處理能力,也不是承認架構有缺陷才不得不妥協。它做的事情,是把原本可能失控的瞬間尖峰流量,轉化成下游能夠承受的穩定流量,讓整體系統的行為變得可預期。這個思路其實不陌生,Day 02:Thread-per-Request 模型的極限,搞懂你原本在用什麼 提過執行緒池會設定一個明確的上限,超過上限的請求先在容器層排隊,而不是放任所有請求同時搶佔執行緒導致系統整體崩潰。執行緒池上限保護的是應用程式自己的執行資源,今天要談的節流手段,保護的對象換成了下游服務,但背後那套「主動設一道關卡、把壓力控制在可承受範圍」的資源保護邏輯,其實是同一件事的不同應用場景。
Kotlin 協程提供了一個現成的工具,用來實作這種節流手段:Semaphore。
它的行為可以想像成一道只允許固定數量的人同時通過的關卡,設定好允許通過的數量之後,超過這個數量的協程會暫停在關卡前排隊等候,直到有其他協程完成手上的工作、釋放出一個名額,排在最前面的協程才會被放行繼續執行。
回到示範情境具體展示這個行為。
假設確認庫存的內部服務經過評估,最多只能同時處理 20 個併發呼叫,超過這個數字回應時間就會開始惡化。這時候可以建立一個允許同時 20 個協程通過的 Semaphore,包裹住呼叫這個內部服務的那段程式碼:
class StockService(
private val stockClient: StockClient,
) {
private val stockCallSemaphore = Semaphore(permits = 20)
suspend fun checkStockAvailability(orderId: String): StockResult {
return stockCallSemaphore.withPermit {
stockClient.checkStock(orderId)
}
}
}
Semaphore(permits = 20) 建立出一個最多允許 20 個協程同時通過的關卡,withPermit { } 則負責整個借用與歸還名額的流程,進入區塊前先嘗試取得一個名額,拿不到就暫停等候,離開區塊時,不論這段程式碼是正常完成還是拋出例外,名額都會被正確釋放,不需要自己手動處理釋放邏輯。
加上這層機制,即使同時有數百個查詢請求湧入 checkStockAvailability,實際同時執行到 stockClient.checkStock 這一步的協程數量,也會被穩穩控制在 20 個以內,其餘協程會在 withPermit { } 這一關卡前排隊等候輪到自己。
值得留意的是,Semaphore 節流的對象,是「呼叫下游資源的那個動作」本身,而不是整個請求處理流程。一個請求裡可能只有確認庫存這一段操作,會碰到有處理能力上限的脆弱下游資源,查詢訂單詳情這類不涉及脆弱下游的操作,完全不受這個 Semaphore 影響,該怎麼跑就怎麼跑。
節流永遠是針對具體的資源瓶頸做精準保護,而非籠統地把整個系統的處理速度全部拖慢。
看到這裡,可能會覺得 Semaphore 是一個獨立於協程結構之外、額外掛上去的新機制,但事實並非如此。Day 06:Structured Concurrency,為什麼協程不能亂長亂放 定案的結構化並發,規範的是協程之間的父子收斂規則,這套規則在被 Semaphore 限制的協程身上,依然完整成立。
被 withPermit { } 包裹住的那段程式碼,仍然執行在原本呼叫它的那個協程裡,這個協程原本屬於誰的子協程,現在還是誰的子協程,並沒有因為多了一道 Semaphore 關卡就改變了它在協程家族裡的位置。父範圍依然會等待這些協程完成,取消訊號依然會沿著結構往下傳播。Semaphore 唯一做的事情,是在這些子協程真正開始執行呼叫下游那一刻之前,多加了一道排隊等候的關卡,協程結構本身完全沒有被打亂。
這一點在取消場景下特別重要。
假設父範圍所在的整個請求,在協程正排隊等候 Semaphore 名額的過程中被取消,例如使用者提早斷線導致這個請求失去意義,正在排隊等候名額的協程也應該能夠被一併取消,不會傻傻地繼續佔用排隊位置、繼續等一個已經沒有人在乎結果的名額。這正是結構化並發的取消傳播機制帶來的額外保障。
Semaphore 節流是嵌在這套結構之內運作的手段,而非另立山頭、自成一套的獨立技巧。這裡只需要建立這個方向性的認識,取消實際上如何中斷排隊中的協程,留到後面談逾時與取消機制時再具體展開。
到這裡,可能會冒出一個看似合理、實則危險的聯想。
訂單服務扣減庫存這個操作,同時被多個請求併發呼叫時,也是一種「併發」問題,會不會只要用 Semaphore 把同時執行扣庫存邏輯的協程數量限制得夠低,甚至限制成同時只允許一個,就能避免庫存被重複扣減或扣成負數?
這個類比是錯誤的,而且是本篇最需要講清楚的一點。
Semaphore 節流解決的問題,是「呼叫端同時發起過多請求,需要限制同時執行的數量以保護下游資源」,這件事發生在流量控制這個層次,關注的是協程本身在單一應用程式行程裡的排程行為。庫存被多個請求同時修改導致資料不正確,關注的則是資料庫交易層級的併發正確性,兩者處理的完全是不同層次的問題。
問題出在真實的生產環境幾乎不會只跑一份應用程式行程。訂單服務為了應付高併發流量,通常會部署在多台伺服器上,前面掛一個負載平衡器把請求分散出去。就算把某台伺服器內的 Semaphore 設定為同時只允許一個協程執行扣庫存邏輯,這個限制也只在那台伺服器內部有效,另外幾台伺服器上同樣在跑的 Semaphore,彼此互不知情,各自允許自己那一個協程通過。多台伺服器加總起來,同一筆庫存資料還是可能同時被好幾個協程各自讀取、各自計算、各自寫回,資料錯亂的風險完全沒有因為單機的 Semaphore 而消失。
這件事必須交給資料庫本身提供的機制處理,靠限制應用程式端的併發數量是解決不了的。這裡只需要建立這個清楚的問題邊界,訂單服務日後真正要解決庫存扣減的併發正確性問題,具體會用什麼手段,留到後面深入資料庫層級的併發控制時再正式定案。
今天定案了 Semaphore 節流這個手段,它確實能把原本可能瞬間打爆下游服務的尖峰流量,轉化成下游能夠承受的穩定流量,也確認了這道關卡是嵌在結構化並發框架之內運作,而非另一套獨立機制。同時也劃清了一條重要的邊界,Semaphore 節流保護的是下游資源不被同時湧入的呼叫量壓垮,跟庫存扣減這種資料列層級的併發正確性問題,是兩件不該混為一談的事。
但 Semaphore 本身也帶來一個新問題。它確實能保護下游資源不被瞬間打爆,可是如果同時湧入的請求量遠遠超過 Semaphore 允許通過的數量,排隊等候的協程可能要等上相當長一段時間才輪到自己。如果使用者早就等得不耐煩、放棄了這次操作,這個還在排隊、或者已經開始執行卻遲遲沒有結果的協程,該怎麼處理?繼續讓它占著資源傻傻等下去,顯然不是理想答案。
下一篇會正式定案這個問題的答案:逾時與取消機制。