【從一台 Server 到分散式架構】第 08 篇:訂單太多廚房做不完——Message Queue 登場

上一篇我們聊到了「非同步與背景任務」,小明店長為了解決「店員親手寫感謝卡導致結帳大塞車」的問題,發明了一個「盒子」。店員只要把寫卡片的任務化作一張紙條丟進這個盒子裡,就可以馬上轉頭去服務下一位客人,讓後台的專屬員工(Worker)慢慢消化盒子裡的紙條。

這個「盒子」,就是我們今天要談的主角:Message Queue(訊息佇列)

在系統設計中,當我們遇到「處理極度耗時」或是「瞬間流量大到系統吃不消」的情況時,Message Queue 是保護系統、提升吞吐量最關鍵的終極武器。


為什麼需要一個專門的盒子?(資料庫的極限)

你可能會想:上一篇不是說,這個盒子其實可以用資料庫來實作嗎?開一個 jobs 的資料表,把要做的任務 INSERT 進去,背景程式再定期去 SELECT 出來做不就好了?

在流量不大的時候,這樣做完全沒問題。但當小明他們的課程平台迎來了「年度大特價」的瞬間: 短短一分鐘內,湧入了上萬筆結帳訂單。每一筆訂單都要觸發三件事:

  1. 寄送購買成功 Email。
  2. 產生有著學生名字與購買日期的專屬 PDF 收據。
  3. 呼叫第三方金流的 API 來扣款。

如果用資料庫來當這個盒子,意味著:那一分鐘內,資料庫除了要扛住寫入那上萬筆「真正的訂單」外,還要額外承受三萬次寫入 jobs 表的動作。更慘的是,背景那幾十台 Worker 為了搶工作做,正瘋狂地、每秒鐘對著這張表下 SELECT * FROM jobs WHERE status = 'pending' 的指令。

資料庫會被這種瘋狂的讀寫鎖定(Locking)給瞬間擊垮。

資料庫的專長是「保證資料的安全與一致性、提供複雜的關聯查詢」,它天生就不是設計來當作「高頻率丟紙條、搶紙條」的信箱。

我們需要一個專門為了「極高吞吐量、先進先出、發送與接收」而生的儲存系統。這就是 Message Queue,業界最常用的專職軟體包含 RabbitMQKafka 或雲端服務的 AWS SQS。

🤔 你可能會問:那第 05 篇提到的 Redis 不能用嗎? 答案是:可以! Redis 的 ListStream 資料結構經常被拿來當作「輕量級」的 Queue 使用。 如果你的系統流量還沒大到很誇張,且能接受在極端當機情況下有低機率掉一點小資料,用現成的 Redis 當 Queue 是一個極好的初期選擇!因為你不需要再額外架設與維護一台新的 Kafka 伺服器。 不過,當業務成長到「這筆事件訊息絕對不能掉、需要有完美的確認與重試機制、還要能存放在硬碟長久保存」時,多數團隊還是會將核心任務轉移到 Kafka 或 RabbitMQ 這些「專職」的 Queue 加上去。


訊息佇列的三大核心場景

在現代軟體架構中,Message Queue 通常用來解決三大類問題:非同步處理系統解耦、以及最重要的 削峰填谷

1. 非同步處理(Asynchronous Processing)

這就是我們上一篇提到的場景。 把那些「不需要馬上讓使用者立刻看到結果」的耗時任務(如轉檔、寄信、產報表)轉交給 Queue。主系統把訊息(Message)丟進 Queue 之後,立刻回傳成功給前端,大幅縮短了使用者的等待時間(Latency)。

2. 系統解耦(Decoupling / Event-Driven)

原本,如果結帳完要寄信跟產 PDF,結帳模組的程式碼大概會長這樣:

function 結帳() {
    扣款();
    寫入訂單();
    寄送Email服務();   // 結帳模組必須知道寄信模組的存在
    產生PDF服務();     // 結帳模組必須知道 PDF 模組的存在
}

有了 Message Queue 後,結帳模組不需要知道任何人。它只要在結帳成功後,對著 Queue 大喊一聲(這稱為發布事件 Publish Event): 「📢 喔耶!有新的訂單(ID: 1001)成立囉!」

至於誰對這件事有興趣?寄信的 Worker 訂閱(Subscribe)了這個事件,它聽到後就乖乖拿著訂單 ID 去寄信;負責產 PDF 的 Worker 也訂閱了這個事件,它聽到後就去畫 PDF。

這種架構稱為事件驅動(Event-Driven Architecture)。 好處是什麼?如果哪天行銷團隊說:「結帳完要加發 100 元折價券」,你完全不需要去改結帳那邊危險且核心的程式碼。你只要新寫一隻「發折價券 Worker」,讓它也去訂閱同一個 Queue 的事件就好了。兩個模組完美解耦。

3. 削峰填谷(Traffic Shaping / Load Leveling)

這才是 Message Queue 最強大的魔法,也是大流量系統不能沒有它的原因。

回想一下餐廳的比喻:中午 12 點,一瞬間湧入了 500 個客人要點餐。 如果沒有排隊機制(Queue),這 500 個客人會同時擠到廚房門口,大喊自己要吃什麼,廚師(資料庫 / 後端)瞬間聽不懂、手忙腳亂,最後鍋子燒掉(系統當機)。

所謂的 Queue,就是餐廳門口的那條紅龍(排隊動線)

這 500 個客人的請求,全部先被丟進 Queue 緩衝起來。此時,雖然外面的流量是像海嘯一樣的「高峰(Peak)」,但廚房裡的廚師完全不慌。廚師依舊按照自己「一分鐘只能炒 10 盤」的速度,從 Queue 裡面一張一張把訂單抽出來做。

這就叫做削峰(把暴衝的流量高峰給削平)填谷(用離峰的谷底時間來慢慢把積壓的訂單消化完)

舉個最明顯的例子:Uber 叫車系統。 跨年夜那張圖按下「叫車」的瞬間,如果有幾萬人同時按,Uber 的伺服器絕對不可能同步去幫每個人運算並尋找附近的司機。你的「叫車請求」其實是被丟進了 Kafka(一種強大的 MQ)。APP 畫面上轉圈圈顯示「正在為您尋找司機」,實際上就是在等待後端的調度引擎從 Queue 裡把你的請求拿出來、匹配司機,再透過 WebSocket 把結果推播回給你。

有了 Queue 橫在中間,再大的流量海嘯,都不會直接重擊脆弱的資料庫。

⚠️ 等等!所以 Queue 是無底洞,可以無限排隊嗎? 絕對不是!既然 Queue 是軟體,它就一定受限於機器的**記憶體(RAM)硬碟(Disk)**容量。

如果暴衝進來的流量實在太大,或是背後的 Worker 罷工罷太久(例如資料庫當機,Worker 消化速度變成 0),Queue 裡堆積的訊息卡片很快就會把整台 Queue 伺服器的硬碟或記憶體塞爆。這被稱為 訊息堆積(Message Accumulation / Backlog)

一旦 Queue 滿了,它可能會開始拒絕接收新訂單,此時上游的 API 就會全數報錯;又或者是更糟的,它會根據設定開始「丟棄最舊的訊息」。 因此在實務上,我們必須對 Queue 進行嚴密的監控:一旦發現堆積的長度超過危險值,就要緊急啟動應對措施(例如:發出警報、緊急增加後端 Worker 的數量來加速消化、或是開啟限流(Rate Limiting)直接在上游擋下客人的請求)。


引入 MQ 帶來的代價

如同我們在讀寫分離那篇說過的:「這世界上沒有免費的午餐」。

  1. 維運複雜度大增:系統裡從此多了一個(或一叢)龐然大物需要維護。Kafka 或 RabbitMQ 掛了怎麼辦?訊息堆積如山消化不完怎麼辦?
  2. 不再保證絕對的及時:你原本以為扣完款程式就走完了,現在丟進 Queue,如果 Worker 壞了或塞車,使用者的 Email 可能半小時後才收到。你必須接受這種最終一致性(Eventual Consistency)
  3. 訊息遺失與重複消費:萬一 Worker 拿了紙條還沒寫完就當機了,紙條會不見嗎?這牽涉到 MQ 裡非常有名的 At least once(至少送達一次)保證,但也意味著你的 Worker 程式碼必須是冪等的(Idempotent):也就是同一張訂單如果因為某些意外被傳了兩次進來,你的程式要能判斷並確保不會寄出兩封一樣的信。

小結與預告

今天我們介紹了在高流量系統中保護後端的救命稻草:Message Queue。 它將那些耗時的、不需要立即回應的、或是流量太大的任務,統統轉變成一張張的紙條(訊息),丟進一個專門用來排隊的緩衝區。這賦予了系統強大的非同步處理能力、做到了模組間的解耦、並實現了保護資料庫的削峰填谷

走到這裡,小明他們的課程平台已經長得非常完整了:

  • 負責擋讀取流量的:Redis
  • 負責分流資料庫的:主從複製 (Primary/Replica)
  • 負責擋寫入海嘯的:Message Queue

不過,還記得我們在第 03 篇(水平擴充)留下的那個伏筆嗎? 當小明把所有的這些新武器都架好,且伺服器也早就擴充成三台一起分擔流量時,客服信箱卻傳來了一個詭異的問題: 「小明啊,我剛剛把一堂課加入購物車,然後按了重新整理,購物車怎麼就空了?再按一次重新整理,剛剛那堂課又跑出來了?」

到底發生了什麼事?這就是多台伺服器並存時的經典惡夢。 我們下一篇要談:兩間廚房帳本卻只有一本——多台後端的狀態(Session)問題。我們下一篇見。