昨天在 Day 00 的地圖裡,我們先按下不表那個會卡住的 API,只留下一句預告:今天要把它攤開來看清楚,它到底卡在哪裡。現在就從這個畫面開始。
想像你維護一個電商後端,裡面有一支再平常不過的 API,使用者點下「確認結帳」,後端收到請求後,去查這筆訂單目前的狀態,確認庫存足夠後扣減庫存,再把結果寫回資料庫。平常流量下,這支 API 反應飛快,幾十毫秒內就回應完畢,你甚至不太會特別去關注它的效能報表。
問題出現在某次促銷活動開賣的那一刻。倒數計時歸零的瞬間,大量使用者同時點擊搶購,請求瞬間湧入。原本幾十毫秒的回應時間,開始悄悄爬升到幾百毫秒,接著是一兩秒,再過幾分鐘,有些請求直接逾時,前端轉圈圈轉到使用者放棄重新整理頁面。客服後台開始湧入抱怨,有人說按了結帳沒反應,有人說同一筆訂單被扣了兩次庫存,還有人乾脆棄單。你盯著監控面板,CPU 使用率其實沒有真的衝到滿載,資料庫也還撐得住,但整個系統就是變得又慢又不穩定。
這個查詢訂單並扣減庫存的情境,接下來會成為這個系列反覆取用的示範情境。它不是一個要你逐天累加程式碼的巨型專案,而是一個輕量、具體、貼近真實電商後端會遇到的場景,往後幾十天談到任何協程觀念時,都會回到這個情境的某個切片上,讓抽象的機制有一個可以對照的畫面。
先別急著問為什麼,這裡只想留一個懸念。你可能會直覺地想,那就多加幾台伺服器分攤流量,理論上應該能撐住。但實務上常常會發現,加了機器,撐住的時間確實延長了一些,可是流量再往上衝一階,系統又開始卡住。為什麼多加機器有時候還是撐不住,問題似乎不只是硬體數量不夠這麼簡單。
要回答上面那個懸念,得先把一次請求實際發生了什麼事拆開來看。使用者送出結帳請求,後端收到之後,第一步查資料庫確認訂單狀態,這一步要等資料庫回應。接著可能需要呼叫另一個內部服務確認庫存數量是否足夠,這一步要等那個服務回應。確認沒問題後,再寫一次資料庫把庫存扣減下去,這一步同樣要等寫入完成。最後才把結果組裝好回傳給使用者。
把這幾個步驟的時間攤開來看,會發現一件有趣的事:真正花在計算的時間其實很少,程式邏輯本身跑起來可能只要幾毫秒。絕大部分的時間,都花在等待外部回應上,等資料庫查詢結果、等內部服務確認庫存、等寫入完成。這些等待動輒占掉整個請求時間的九成以上。
矛盾就出在這裡。負責處理這個請求的執行緒,在等待資料庫回應的那幾十毫秒裡,其實什麼事都沒做,純粹是空等,但它依然被這個請求佔著,動彈不得,沒辦法轉頭去處理排在後面的其他請求。這有點像一個服務生,端菜去廚房前,被要求必須先站在客人桌邊罰站,一直等到那道菜真正出爐端上桌才能離開,即使廚房出餐的這段時間他什麼忙都幫不上。餐廳裡服務生數量固定,一旦每個服務生都被綁在某張桌邊空等,新客人進門就沒有人能接待了。
回到後端的世界,執行緒的數量同樣是有限的。平常流量小,同時要處理的請求數量在執行緒數量之內,大家各自占一條執行緒去等待,看起來一切正常,回應也夠快。可是一旦同時湧入的請求數量超過可用的執行緒數量,新進來的請求就只能排隊,等某條執行緒把手上的請求處理完、被釋放出來,才輪得到它。這個排隊等待的時間,正是促銷活動當下,回應時間從幾十毫秒暴增到數秒甚至逾時的直接原因。系統看起來很忙,佔用率也很高,但忙碌的內容有相當一部分只是純粹的空等,而不是真正的運算。
面對這個症狀,加機器確實是最直覺、也最常見的第一反應。多開幾台伺服器,等於多出幾組可用的執行緒池,理論上能同時扛住的請求數量也會跟著往上加。這確實有用,也是許多團隊實際會採取的做法。
但這個做法有它的極限。伺服器數量往上加,成本不是線性成長,而是隨著流量規模逐漸攀升,甚至可能超線性增加,畢竟除了運算資源本身,還有網路、維運、監控這些跟著擴增的成本。再退一步看執行緒本身,作業系統要建立並維護一條執行緒,本身就有一定的記憶體開銷,不是想開多少就能無限制地開下去。單純靠加機器、加執行緒去因應流量成長,終究會撞到某個現實的天花板。
如果回頭看前一段拆解出來的根本問題,執行緒之所以不夠用,是因為大量執行緒被閒置等待的狀態白白佔著,那麼真正該解決的問題方向,或許不是想辦法生出更多執行緒,而是讓執行緒在等待的時候能被讓出來,先去做別的事,等結果真正回來了再接手繼續處理。這正是協程要解決的核心問題方向。這裡先不展開協程具體是怎麼做到讓執行緒被讓出來又能接手回來的,也不會出現任何程式碼或語法,這一步只建立一個方向性的認識:協程瞄準的不是語法寫起來好不好看,而是執行緒資源怎麼被更有效率地使用。
在往下讀之前,有一個常見的誤解值得先提醒一下,這裡只是點出來,不展開論證。協程並不是多執行緒的另一種寫法,也不是單純把非同步處理包裝得比較好看的語法糖。如果帶著「協程就是換個寫法的多執行緒」這個預期往下讀,後面幾篇談到協程實際運作方式時,可能會覺得哪裡對不上。先把這個提醒放在心上就好,答案會在後續文章逐步補齊。
在深入拆解協程之前,先讓你知道接下來這 30 天大致會走過哪些地形,建立一點期待感,也讓心裡踏實一點。
整個系列採取觀念先行、逐步進階的方式,不會一開始就丟出艱深的並發理論或分散式系統概念。前段會先花幾天把傳統並發模型與協程的基礎地基打穩,讓你清楚知道自己原本熟悉的做法到底是怎麼運作的,再正式寫出並看懂第一個協程程式。接著會進入協程核心觀念,把協程的生命週期、執行環境這些機制建立起正確的心智模型。再往後會走進協程與 Spring Boot 生態系的整合,搞懂協程在不同框架搭配下該怎麼用、為什麼這樣用。之後進入高併發控制與效能調校,這是這個系列標題真正要兌現的部分,也是多數協程教學容易停在語法介紹就打住、沒有真正碰觸的區域。最後在一個實戰情境中,把前面累積的所有觀念收斂起來,補齊監控、壓力測試、部署上線這些真實場景會遇到的經驗。
整個過程不會預設你已經熟悉並發理論或分散式系統概念,凡是用到的新概念,都會盡量從你已經有的經驗出發,一步步拉開差異,而不是丟出一堆術語要你死記。而前面提到的訂單查詢與扣庫存服務,會在系列推進的過程中,隨著觀念逐漸加深而長出更多細節,從限流、逾時,到資料庫連線池,一路加上真實情境的複雜度。你不需要擔心錯過某一天的內容就完全跟不上後面,各天內容會盡量保持可以獨立閱讀。
在談協程怎麼解決問題之前,得先誠實面對一個問題。你現在的 Spring Boot 應用程式,收到每一個請求時,背後到底是怎麼分配執行緒去處理的?這件事聽起來應該不難回答,但仔細想想,很多工程師其實答不太出來,平常寫 Controller、寫 Service,程式能跑、測試能過,很少有機會停下來真正搞懂背後那條執行緒是怎麼被指派、又是怎麼被交還的。
這個疑問,就是明天要正式拆解的起點。