iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Software Development

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

Day 17:背壓是什麼,生產太快時該怎麼踩煞車

  • 分享至 

  • xImage
  •  

Day 16:逾時與取消,讓卡住的協程別拖垮整個系統 結尾留下一個問題:withTimeout 解決的是某個協程異常卡住太久的狀況,但如果問題根本不是卡住,而是整體資料產生的速度本來就持續比處理速度快,這種結構性的速度落差,逾時取消解決不了。

今天要正式回答這個問題。

不是卡住,是天生就跑得比較快

先把 Day 16 的疑問收個尾。逾時機制假設的前提是,正常情況下一次呼叫應該能在合理時間內完成,只有少數異常狀況才會卡住不動。但今天要談的情境完全不是這麼回事,每一次個別的處理都很正常,沒有任何一次是卡住的,問題出在整體速度,生產資料的一方持續比消費資料的一方跑得快。

把這件事放進一個新的情境切片裡具體想像。訂單服務除了處理查詢與扣庫存這類單筆請求,也需要持續處理一連串陸續到來的庫存異動事件。假設供應商系統會每隔一小段時間就回報一批新到貨的庫存數量,訂單服務收到這些事件後,需要逐一寫入資料庫更新庫存。如果供應商回報事件的速度,持續快過訂單服務實際把庫存寫入資料庫的速度,會發生什麼事?

這個新情境切片和查詢訂單與扣庫存服務原本聚焦的單筆請求不太一樣,它談的是一段持續不斷湧入的資料流,而非一次來一次去的請求應答。後面幾段會延用這個庫存異動事件的情境,把速度落差的問題攤開來看。

生產者比消費者快,會發生什麼事

如果沒有任何因應機制,最直覺的做法是把來不及處理的事件,全部先堆在記憶體裡的某個佇列中,等待消費端慢慢處理。這個做法乍聽之下很合理,反正消費端遲早會處理完,先讓事件排隊等著就好。

問題在於,如果供應商回報事件的速度不是短暫的尖峰,而是長期且持續地超過訂單服務寫入資料庫的速度,這個佇列不會穩定在某個大小,它會持續增長。每過一秒,堆積的事件就比上一秒更多,沒有任何一個時間點會自然回到平衡。這個佇列最終會把記憶體空間吃光,輕則拖慢整台伺服器的效能,重則直接導致訂單服務因記憶體不足而崩潰,連原本能正常處理的查詢與扣庫存請求也一併遭殃。

這聽起來與 Day 15:併發限制,用 Semaphore 保護下游別被打爆 保護下游資源的邏輯有點相似,兩者確實都是在討論資源保護,但保護的對象不太一樣。

Day 15 的 Semaphore 保護的是下游外部服務不被瞬間湧入的呼叫量打爆,今天要保護的對象換成了消費端自己,它自己的記憶體與處理能力,一旦被無限制堆積的事件耗盡,倒下的就是訂單服務本身,而不是某個外部依賴。維度不完全相同,但背後那份「不能讓壓力無限累積」的警覺是共通的。

背壓,讓消費端有能力喊停

面對這個問題,真正該做的事情不是想辦法擴充記憶體容量,讓佇列可以撐得更久,這只是把問題延後發生,而不是解決問題。真正該做的事情,是讓消費端有能力回頭告訴生產端「慢一點,我處理不過來了」,藉此調節整體資料流動的速度,不讓事件無限制地堆積下去。

這套機制有一個正式名稱,今天要把它定案下來:背壓,Backpressure,此系列保留英文原文並附中文,全系列後續一律使用此詞。背壓指的是,當生產資料的速度超過消費資料的速度時,透過某種機制讓消費端能夠回頭影響生產端的速度,藉此讓整體資料流動維持在消費端能夠負荷的範圍內。

這裡有一個角色反轉,值得停下來確認清楚。沒有背壓的世界裡,生產端自顧自地產生資料,消費端只能被動承受,來多少就得想辦法扛住多少。有了背壓之後,主動權轉移到了消費端手上,消費端不再是被動承受的一方,它可以視自己實際的處理能力,回頭調節生產端該以什麼速度送出資料。理解背壓的關鍵就在這個主動權的轉移,消費端不再只是排隊等著被灌爆的那一方。

用 Channel 實作一個具備背壓能力的資料流

Kotlin 協程提供了一個工具,能讓這個「消費端喊停」的機制自然發生:Channel。Channel 是一種可以在協程之間傳遞資料的管道,一個協程負責把資料放進去,另一個協程負責從裡面取出來。

關鍵在於容量。建立 Channel 時可以指定它的容量上限,當 Channel 裡已經塞滿資料、還沒被消費端取走時,生產端協程如果試圖繼續放入新的資料,這個放入的動作會被暫停,直到消費端取走一筆資料、騰出空間為止,生產端才會被恢復執行、繼續放入下一筆。這個暫停正是 Day 03:第一個 suspend function,協程到底暫停了什麼 建立的「暫停不等於阻塞」心智模型的另一種應用場景,生產端協程暫停在放入資料這個動作上時,並不會卡住底層執行緒,執行緒可以先去做其他事情,等 Channel 騰出空間之後,這個生產端協程才會被恢復。

回到訂單服務持續處理庫存異動事件的情境,具體看看這個行為怎麼運作:

class StockEventProcessor(
    private val stockEventChannel: Channel<StockEvent> = Channel(capacity = 100),
) {
    suspend fun receiveEvent(event: StockEvent) {
        stockEventChannel.send(event)
    }

    suspend fun startProcessing() = coroutineScope {
        launch {
            for (event in stockEventChannel) {
                updateStockInDatabase(event)
            }
        }
    }
}

Channel(capacity = 100) 建立出一個最多能暫存 100 筆事件的緩衝區。供應商每回報一筆庫存異動事件,就呼叫一次 receiveEvent,把事件透過 send 放進這個 Channel。訂單服務內部另外啟動一個協程,用 for (event in stockEventChannel) 逐一取出事件,寫入資料庫更新庫存。

如果供應商回報事件的速度,快過 updateStockInDatabase 實際寫入資料庫的速度,Channel 裡的事件會越積越多,一旦累積到 100 筆這個容量上限,接下來 send 這個動作就會被暫停,供應商端後續呼叫 receiveEvent 送入下一筆事件時,也就自然跟著被暫停,直到消費端的協程取走一筆事件、空出一個位置為止。生產端因此被迫慢下來,配合消費端實際能負荷的速度,這正是背壓在程式碼層級的具體樣貌,不需要額外寫任何判斷佇列長度、手動擋下生產端的邏輯,容量限制加上 send 本身的暫停行為,就自然達成了節流效果。

除了 Channel,Kotlin 協程還提供了 Flow 這個更高階的資料流處理工具,在特定使用方式下,例如透過 channelFlow 建立的 Flow,內部其實就是靠 Channel 撐起緩衝與背壓行為,本質上與這裡示範的機制是同一套底層邏輯的延伸。Flow 完整的操作符與使用方式篇幅不小,不在今天要展開的範圍內,這裡只需要知道,Flow 背後的背壓能力,跟 Channel 是同一件事的不同外觀。

供應商事件來源、容量 100 的 Channel、訂單服務消費協程的背壓流程,Channel 已滿時 send 暫停而非阻塞,消費端取出一筆事件後生產端恢復執行

速度控制好了,但消費端能用的資源也有限

今天正式定案了背壓,Backpressure,這個概念,也透過容量有限的 Channel 具體示範了消費端如何靠暫停生產端的放入動作,讓整體資料流動速度維持在自己能負荷的範圍內,不再是生產端自顧自產生資料、消費端只能被動硬扛。

但這裡冒出一個新的問題。背壓機制確實能避免資料無限制堆積,可是消費端實際處理這些事件時,往往需要依賴某項數量有限的資源。以訂單服務為例,要把每一筆庫存異動寫入資料庫,就需要用到資料庫連線,而這種連線的數量本身通常有一個上限。如果背壓已經把資料流動的速度控制住了,但消費端這一側倚賴的資源本身數量有限,這項資源被耗盡時,又會發生什麼事?

下一篇會討論協程與資料庫連線池搭配時需要注意的風險。


上一篇
Day 16:逾時與取消,讓卡住的協程別拖垮整個系統
下一篇
Day 18:協程遇上 R2DBC 連線池,小心連線被偷偷耗盡
系列文
Spring Boot + Kotlin 協程高併發,後端開發新選擇 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言