Day 12:R2DBC,讓資料庫存取也不阻塞執行緒 結尾留下一個問題:資料庫存取這一段的非阻塞問題解決了,但真實的訂單查詢與扣庫存流程往往不只跟資料庫打交道,可能還需要呼叫其他內部服務,或者在扣庫存成功後發送一則通知訊息,這些呼叫又該怎麼處理,才不會讓某個環節悄悄變回阻塞式呼叫。今天要正面回答這個問題。
把示範情境再往前推一步看。訂單服務扣庫存成功之後,往往不是就此結束,還可能需要同時做兩件事:呼叫另一個內部服務確認會員的優惠資格,以及發送一則訂單成立的通知訊息。這兩件事彼此獨立,確認優惠資格用不到通知的結果,發送通知也不需要等優惠資格算完才能動作,兩者之間沒有任何先後依賴關係。
這種「一個請求牽涉多個外部依賴」的情境,在真實後端服務裡其實是常態而非例外。只是這個現實經常在教學情境裡被簡化掉,範例往往只示範單一個 API 呼叫,讓人誤以為協程只需要處理「一步接一步」的情境。今天要用這個新增的情境切片,把這個常被忽略的現實攤開來看。
先看一個直覺但其實藏著浪費的寫法。如果照著習慣,一步一步往下寫,程式碼可能長得像這樣:先呼叫確認優惠資格的 suspend function 並等待結果,等結果回來了,才呼叫發送通知的 suspend function 並等待它的結果。
suspend fun handleAfterStockDeducted(orderId: String, memberId: String) {
val eligibility = checkPromotionEligibility(memberId) // 等待完成才會往下走
val notifyResult = sendOrderConfirmedNotification(orderId) // 前一步結束後才開始
println("優惠資格:$eligibility,通知結果:$notifyResult")
}
這段程式碼確實用了 suspend function,語法上看起來很協程,但骨子裡的執行順序跟傳統依序呼叫沒有本質差異。確認優惠資格這個呼叫如果耗時 150 毫秒,發送通知這個呼叫耗時 100 毫秒,兩者依序執行,整體等待時間就是兩者相加的 250 毫秒。
問題在於,這兩個操作彼此沒有依賴關係,理論上完全可以同時進行。依序呼叫等於白白浪費了原本可以平行處理的那段時間。單一次請求裡,多花的這 100 毫秒可能不太起眼,使用者甚至感覺不出差異。但放進高併發情境裡想像一次,如果同時有數百個請求都在做同樣的事,這個浪費會被同步放大,累積成整體回應時間肉眼可見的落差,也讓原本該用來服務其他請求的運算資源,被這些不必要的等待悄悄佔走。
既然確認優惠資格與發送通知這兩件事彼此獨立,協程有更貼切的工具可以表達這件事,也就是 Day 05:Coroutine Scope,協程住在哪裡、活多久 提過的 async 建構子。
async 跟依序呼叫最大的不同在於,它啟動一個協程之後不會原地等待結果,而是立刻把控制權交還給呼叫端,讓呼叫端可以繼續往下啟動下一個協程。做法是分別用 async 啟動確認優惠資格與發送通知這兩個操作,讓它們幾乎在同一時間點開始執行,而不是等前一個完成才開始下一個,最後才統一等待兩者的結果都就緒。
這種寫法下,整體等待時間不再是兩者相加,而是會趨近於兩者之中較長的那一個。以剛才的例子來說,確認優惠資格耗時 150 毫秒、發送通知耗時 100 毫秒,兩者平行執行後,整體等待時間會趨近於 150 毫秒,而不是依序呼叫時的 250 毫秒。這正是平行呼叫相對於依序呼叫最直接、最容易感受到的效益。
這裡要進入今天真正想講清楚的核心。前面展示的平行呼叫寫法,看起來像是一套新的呼叫技巧,但它其實不是獨立於協程核心觀念之外的新東西,而是 Day 06:Structured Concurrency,為什麼協程不能亂長亂放 定案的結構化並發規則,套用在多外部依賴情境下的具體展現。把 Day 6 的三條核心規則拿出來,逐一對照今天這個情境,會看得更清楚。
規則一,父子關係。確認優惠資格與發送通知這兩個用 async 啟動的協程,都是同一個父範圍底下的子協程,從啟動的那一刻起就被父範圍登記在案,並非憑空冒出來、各自為政的獨立個體。這一點跟 Day 6 查詢訂單詳情與確認庫存兩個子協程的關係完全一致,只是今天的父子關係換了一組具體的業務操作。
規則二,父範圍必須等待子協程。父範圍不會在其中一個結果還沒就緒時就搶先往下走,它必須等確認優惠資格與發送通知這兩者都有了結果,才算真正結束。這也是為什麼平行呼叫依然需要一個明確等待兩者結果的動作,父範圍對子協程的生命週期負有完整責任,不會丟下還在執行中的子協程逕自離開。
規則三,取消與例外沿結構傳播。如果確認優惠資格這個呼叫過程中發生例外,這個失敗會沿著結構往上傳播到父範圍,父範圍可能因此對還在執行中的發送通知協程送出取消訊號,即使發送通知這個操作本身完全沒有出錯。
這裡要誠實面對一個問題:這個行為是不是我們期望的?如果因為確認優惠資格失敗,就連帶取消了原本應該正常送達的訂單成立通知,站在使用者體驗的角度,可能不是理想的結果,畢竟訂單本身已經確定成立,通知使用者這件事理論上不該被優惠資格查詢的失敗牽連。如果不希望這兩個操作互相牽連,Day 08:Coroutine Context 與例外處理,協程出錯了誰負責 提過的例外處理客製化機制,例如調整例外傳播範圍的做法,就是可以考慮的方向,這裡先點出這個選項的存在,具體怎麼客製化不在今天展開。
把這三條規則對照完一輪就能看出,今天示範的平行呼叫模式並沒有繞過結構化並發另立山頭,它從頭到尾都活在 Day 6 建立的那套父子秩序之下,只是把父範圍底下的子協程,從「查詢訂單、確認庫存」換成了「確認優惠資格、發送通知」這組新的業務操作。

把前面談的原則放進一個更完整的 Service 方法裡看一次。這個方法內部有一段依序執行的操作,也就是扣減庫存這個有先後依賴的資料庫操作,扣庫存成功之後,才用 async 平行發起確認優惠資格與發送通知這兩個互不依賴的操作,最後統一等待兩者結果,組成整合後的回應。
suspend fun deductStockAndFollowUp(orderId: String, memberId: String): OrderFollowUpResult =
coroutineScope {
// 有先後依賴的部分,保持依序執行
val deductResult = deductStock(orderId)
// 互不依賴的部分,用 async 平行發起
val eligibilityDeferred = async { checkPromotionEligibility(memberId) }
val notifyDeferred = async { sendOrderConfirmedNotification(orderId) }
// 統一等待兩者結果
val eligibility = eligibilityDeferred.await()
val notifyResult = notifyDeferred.await()
OrderFollowUpResult(
stockDeducted = deductResult,
promotionEligible = eligibility,
notified = notifyResult,
)
}
這段範例的重點不在於程式碼本身有多複雜,而在於它示範了一個很單純的判斷原則:有先後依賴的部分保持依序,扣庫存必須先確認成功,後面的動作才有意義;真正互不依賴的部分才用 async 讓它們真正平行執行。範例刻意保持精簡,沒有加入例外處理的完整客製化邏輯,也沒有處理逾時控制,這些屬於 《Day 16:逾時與取消,讓卡住的協程別拖垮整個系統》 要處理的課題,今天只聚焦在「該平行的地方讓它真正平行」這件事本身。
從 Day 10:在 Spring MVC 裡寫 suspend function,能動但有代價 到今天,依序看過協程跟 Spring MVC、WebFlux、R2DBC 的整合方式,再到今天協程如何組織多個外部依賴的呼叫。這幾天累積的,是協程與既有生態系統搭配的具體經驗,也讓平行呼叫這個看似新穎的模式,穩穩地落回結構化並發這套系列一路建立起來的秩序底下。
這些技術選型各自看起來都理解了,但實務上該根據什麼判斷依據去選擇,是接下來要做的整理工作。
《Day 14:小結:三種技術選型的判斷依據,MVC、WebFlux 各用在哪》 會把這些選型判斷依據收斂成一張清楚的地圖。