Day 09:小結:四個核心觀念如何在一次請求中協同運作 結尾留下一個還沒正面回答的問題:協程終究要活在一個真實的 Spring Boot 應用程式裡,它要接 HTTP 請求,要跟 Controller 這類既有機制搭配運作,這件事到目前為止完全沒有處理過。今天就從這裡開始,正式進入系列的第三階段,生態整合期。
第一個要處理的整合對象,是 Spring MVC,也就是多數 Java 或 Kotlin 後端工程師最熟悉的傳統 Servlet-based Web 框架。選它當作第一站,理由很單純:這是多數讀者現有專案最可能正在使用的框架,比起先介紹一個陌生的新東西,從你已經很熟的 @RestController、@GetMapping 出發,能更快看清楚協程加進來之後,實際上改變了什麼、又有哪些東西沒有改變。
先把答案講在前面:可以。而且改動小到令人意外。
延續訂單查詢與扣庫存服務的情境,假設你原本有一個查詢訂單詳情的 Controller 方法,寫法跟一般 Spring MVC 專案裡看到的,沒有任何差異:
@RestController
@RequestMapping("/orders")
class OrderController(
private val orderService: OrderService
) {
@GetMapping("/{orderId}")
fun getOrder(@PathVariable orderId: Long): OrderDetail {
return orderService.findOrderDetail(orderId)
}
}
現在把它改寫成 suspend function,語法上唯一的差異,只有方法簽章前面多了一個 suspend 關鍵字。
當然,內部呼叫的 Service 方法也一併改寫成 suspend function:
@RestController
@RequestMapping("/orders")
class OrderController(
private val orderService: OrderService
) {
@GetMapping("/{orderId}")
suspend fun getOrder(@PathVariable orderId: Long): OrderDetail {
return orderService.findOrderDetail(orderId)
}
}
// Service 層
suspend fun findOrderDetail(orderId: Long): OrderDetail {
val order = findOrderById(orderId)
?: throw OrderNotFoundException(orderId)
return OrderDetail.from(order)
}
啟動這支服務,用瀏覽器或任何 HTTP 客戶端打這支 API,它會正常回應,行為跟改寫前完全一樣,使用者完全感覺不出差異。
這不是什麼巧合或某種取巧的寫法,現在的 Spring Boot 對 Kotlin 協程有一定程度的內建支援,框架本身知道怎麼處理一個回傳型別是 suspend fun 的 Controller 方法。
背後大致的運作方式,是 Spring 框架在收到這類方法時,會把這次呼叫適配進既有的請求處理流程裡,讓 Controller 方法能夠在框架認得的非同步機制上被排程執行,而不是直接原地報錯說看不懂這個語法。這裡只需要知道有這樣一套適配機制存在,不需要深入框架內部怎麼利用反射或哪一層轉接器去完成這件事,那已經是框架原始碼層級的細節,跟今天想建立的心智模型無關。
到這裡,第一印象已經建立起來了:語法上,Spring MVC 願意讓你用協程核心觀念期建立的寫法去撰寫 Controller,你不需要為了套用 suspend function 而換一整套框架。但這個印象只對了一半,接下來要把另一半攤開來看。
上一節的示範很順利,順利到容易讓人誤以為只要 Controller 方法都改成 suspend function,系統就自動獲得協程模型的全部高併發優勢。這正是這一節要正面糾正的地方。
回到 Day 02:Thread-per-Request 模型的極限,搞懂你原本在用什麼 定案的基準線。Spring MVC 底層依賴的 Servlet 容器,最常見的是 Tomcat,本質上仍然是為每一個進來的 HTTP 請求配置一條執行緒的 Thread-per-Request 模型,容器維護著一個容量有上限的執行緒池。
這件事在你把 Controller 方法改成 suspend function 之後,並沒有跟著改變。你改動的是 Controller 這一層方法怎麼被呼叫、內部怎麼暫停與恢復,你沒有改動、也改不動底層容器骨子裡那套執行緒配置邏輯。
代價具體落在哪裡?
負責接收這個 HTTP 請求、最終要把回應送出去的那條執行緒,在 suspend function 內部真正發生暫停等待的期間,雖然協程本身確實讓出了執行權,但這條執行緒是否因此完全自由、能夠立刻去承接另一個全新的請求,取決於框架在這段等待期間怎麼處理這條執行緒,而不是單純取決於你有沒有寫 suspend 這個關鍵字。
這裡的邊界需要仔細描述:協程讓出執行權,確實能讓這次請求的某些等待階段不再是「函式呼叫原地卡死」的寫法,但這條執行緒能不能因此馬上被騰出來服務下一個完全無關的請求,很大程度仍受限於容器本身處理非同步請求的既有機制,並不等同於在純協程環境下、脫離 Servlet 容器限制那種完全自由調度的情境。
換句話說,效益是有的,但這個效益被包在一層既有容器的骨架裡打了折扣。
誠實的結論就落在這句話上:在 Spring MVC 底下寫 suspend function,能讓程式碼風格與協程核心觀念期建立的心智模型一致,內部呼叫鏈也能延續 Day 5 到 Day 9 建立的 Scope、結構化並發、Dispatcher 那套規則,在某些情境下確實能減少不必要的阻塞等待寫法。但它無法讓系統整體擺脫 Thread-per-Request 執行緒池上限這個 Day 2 已經定案的根本限制。執行緒池還是那個執行緒池,容量還是那個容量,促銷活動開賣那一刻湧入的大量請求,該排隊的還是得排隊。

這件事值得停下來多想一秒。程式碼看起來換了一種寫法,心智模型也確實延續了前面九天建立的協程觀念,但如果你因此以為系統的高併發承載能力已經被協程徹底改寫,那就是對這個整合方式的誤解。今天定案的「協程與 Spring MVC 整合模式」,指的正是這種语法上可行、但受限於底層仍是 Thread-per-Request 容器骨架的整合方式,這個定義後面會反覆被引用,值得在這裡先畫一條底線。
看到這裡,很容易得出一個過度悲觀的結論:既然底層限制擺在那裡,那在 Spring MVC 裡寫 suspend function 是不是根本沒有意義。這個結論同樣是錯的,這一節就是要把天平重新扶正。
第一種站得住腳的情境,是團隊手上已經有大量既有的 Spring MVC 專案與對應的維運經驗,短期內沒有大幅度更換框架的計畫,但希望在新增功能時開始採用協程風格撰寫非阻塞的業務邏輯,替未來可能的技術轉換先鋪路。與其在既有 Controller 裡繼續累積一般函式,不如從現在起讓新寫的部分先套用 suspend function,等到有一天真的需要往底層設計就不是 Thread-per-Request 的框架遷移時,業務邏輯層的協程風格程式碼已經現成,遷移的阻力會小很多。
第二種站得住腳的情境,是專案內部有一部分邏輯需要呼叫其他同樣以協程風格撰寫的內部函式庫或服務。假設訂單服務內部已經有一套以 suspend function 包裝的庫存確認邏輯,Controller 這一層若還維持一般函式的寫法,呼叫鏈中間就得反覆做協程與非協程風格的轉換,程式碼會變得零碎又容易出錯。讓 Controller 方法也宣告成 suspend function,可以讓整條呼叫鏈風格一致,讀起來是一條線,不必在中途切換心智模型。
這兩種情境有一個共同的重點:它們都不是在主張「這樣寫能讓系統扛住更大的流量」,而是在主張「這樣寫能讓程式碼風格與維護成本得到改善」。這是一個過渡方案,也是特定情境下合理的選擇,但不是這個系列後續要主推的高併發解法,這一點需要說清楚,避免讀者誤把今天的示範當成終點。
今天追出來的限制,根源其實非常單純:Spring MVC 底層容器骨子裡仍然是 Day 2 定案的 Thread-per-Request 模型。
協程只是在這副既有骨架上盡力發揮,能做的事有限,能改變的事更有限。
這個根源反過來也給出了一個很自然的追問方向。如果限制的源頭是底層容器骨架,那有沒有一種框架,從底層設計開始就不是這個模型,執行緒與請求之間的關係從一開始就不是一對一綁定到底,會不會是讓協程真正徹底發揮優勢的選擇?今天先把這個疑問留在這裡,不急著給答案,也還不到把那個名字搬出來的時候。
下一篇會正式認識這個選項:一個從底層設計就與今天談的整合方式站在不同起點的框架。
Day 09:小結:四個核心觀念如何在一次請求中協同運作 打穩了協程自己的世界觀,今天則確認了協程進到既有 Spring MVC 框架後,語法上真的能動,但底層容器沒有變,效益因此受限。生態整合期才剛開始,接下來還有更多選項要一一攤開來看。