Lesson 23: 系統的緩衝區-Queue 佇列與非同步處理 (Asynchronous)
佇列
佇列的實作工具非常多,舉凡AWS SQS、RabbitMQ、Kafka…等。
佇列的特性,其實是一個非常強大的系統緩衝區,應用層面非常廣。
什麼是佇列?
佇列可以想像成,在既有流程中外,有另一個”水管”,來連接原有的資料流(或邏輯過程),其中 呼叫方將資料 推(Push)到水管中,接受方(監聽) 從水管中將資料拉(Pull)出處理
為什麼佇列是「強大的緩衝區」?
在同步處理中,系統像是一條緊繃的繩子,任何一個節點斷了或慢了,整個流程就會崩潰。而加入佇列後,系統擁有了以下三個關鍵特性:
削峰填谷(Load Leveling)
當瞬間流量(例如雙 11 搶購)暴增時,後端資料庫通常無法承受。佇列充當了緩衝墊,讓請求先「排隊」,後端再依據自身的處理能力慢慢消耗,避免伺服器因過載而宕機。
非同步解耦 (Asynchronous Decoupling)
呼叫方不需要等待處理結果。以「註冊帳號」為例:
同步: 寫入資料庫 → 寄送驗證信 → 傳送歡迎簡訊 → 回傳成功(若簡訊服務掛了,註冊就失敗)。
非同步: 寫入資料庫 → 丟入 Queue → 回傳成功。寄信與簡訊由後端 Consumer 慢慢處理。
容錯與重試機制 (Fault Tolerance)
如果接收方 在處理時暫時故障,資料依然停留在佇列中。待接收方修復後,可以從上次中斷的地方繼續處理,確保資料不丟失。
核心算法
如果講到佇列,不稍微聊一下FIFO,內容就有點空洞了,FIFO是佇列的核心特性,與堆疊(Stack)的 LIFO形成對比。
FIFO
FIFO是First In , First Out。白話文就是先進先出,先進到佇列的資料也會優先被調出佇列。
公平性: 確保請求按照收到的先後順序處理,不會有後來的請求「插隊」導致先來的請求無窮盡等待(Starvation)。
順序一致性: 在某些情境(如銀行轉帳、訂單狀態變更),處理順序絕對不能錯亂。例如「扣款」必須發生在「出貨」之前。
佇列的變體與進階演算法
在實務的後端開發中,單純的 FIFO 有時不足以應付複雜需求,因此衍生出以下幾種常見模式:
1. 優先權佇列 (Priority Queue)
並非所有資料都一視同仁。例如:
高等級: 處理使用者的付款請求(需立即處理)。
低等級: 處理每週報表的發送(可以稍後再做)。 這時演算法會根據資料的 Priority Tag 重新排序,讓高等級的資料「合法插隊」。
2. 環形佇列 (Circular Queue)
在記憶體有限的情境下(例如嵌入式系統或高效能 Buffer),當指標到達陣列末端時,會繞回開頭重新利用空間。這避免了頻繁搬移資料的效能損耗。
3. 延遲佇列 (Delay Queue)
資料推入後,並不會立刻被 Consumer 拉走,而是設定一個「冷卻時間」。
- 應用場景: 訂單成立後 30 分鐘若未付款,自動取消訂單。
補充說明:分散式系統中如何保證順序性
在分散式系統中,「順序性」是一個極大的挑戰。因為分散式架構的核心是「並行」,而順序要求的則是「串行」。這兩者本質上是衝突的。
要保證順序,通常需要從 生產端、存儲端、消費端 三個環節同時下手:
1. 生產端:分組標記
在海量資料下,我們不可能要求「全球順序」,那會造成嚴重的效能瓶頸。實務上,我們保證的是「局部順序」。
機制: 給予每條訊息一個
MessageKey(例如order_id或user_id)。演算法: 使用一致性雜湊(Consistent Hashing)確保擁有相同 Key 的訊息,永遠會進入同一個 Partition 或 Shard。
結果: 雖然訂單 A 和訂單 B 之間沒有順序關係,但「訂單 A 的建立」與「訂單 A 的付款」會排在同一條水管裡。
2. 存儲端:強順序佇列(FIFO Queue)
一般的雲端佇列(如 AWS SQS Standard)為了效能,通常只保證「盡力交付(Best-effort ordering)」。若要嚴格順序,必須選用 FIFO 類型 的工具:
AWS SQS FIFO: 透過
Message Group ID確保同組訊息嚴格先進先出,並提供Deduplication ID防止重覆發送。Kafka: 內建保證單一 Partition 內的訊息是絕對有序的。
3. 消費端:單一消費者模式
這是最容易出錯的地方。即便佇列是有序的,如果後端開了 10 個 Worker同時拉資料,順序就會亂掉:
訊息 1:進入 Worker A(處理慢,需 5 秒)。
訊息 2:進入 Worker B(處理快,需 1 秒)。
結果: 訊息 2 會比訊息 1 先完成,破壞了邏輯順序。
解決方案:
分區消費: 確保一個 Partition 同一時間只能被一個 Consumer 讀取(Kafka 的預設行為)。
樂觀鎖(Optimistic Locking): 在資料庫增加
version欄位。即便訊息 2 先到,它發現版本號不對,會拒絕寫入並要求重試。
4. 終極殺手鐧:時序標記
如果系統非常複雜,無法依賴中間件的順序,我們會在訊息內核嵌入「邏輯時鐘」:
雪花演算法 (Snowflake ID): 生成帶有時間戳且遞增的唯一 ID。
狀態檢查: 消費端維護一個
last_sequence_id。如果收到的 ID 是 5,但上次處理的是 3,表示訊息 4 掉包或延遲了,此時 Consumer 應將 5 暫存或報錯,直到 4 出現。
總結:代價與權衡
保證順序性是有代價的:
效能下降: 無法充分利用多核並行處理,吞吐量會受限於單一 Partition 的處理能力。
可用性風險: 如果某條訊息處理卡住(Poison Pill),後面所有的有序訊息都會被堵死。
理解「什麼時候不需要保證順序」跟「如何保證順序」一樣重要。盡可能讓業務邏輯具備 冪等性(Idempotency),才是減輕順序依賴的最佳解。
非同步處理
探討 非同步 時,最核心的觀念轉變是:「從 等待結果 變成 訂閱通知」。
同步 vs. 非同步
同步處理 (Synchronous) — 點餐
走道7-11點了一杯大冰拿,點完後就站在櫃檯前死守,直到咖啡拿在手上,才離開去滑手機。
- 問題: 如果咖啡機壞了(I/O 阻塞),後面的排隊人群(其他 Request)全都會被卡死。
非同步處理 (Asynchronous) — 呼叫器點餐
點完咖啡後,店員給你一個呼叫器。可以先回位子坐下處理公事、回 Email(繼續執行其他任務)。當呼叫器響了(Event Triggered),你再去取餐。
- 優點: 櫃檯(CPU/Thread)可以立刻處理下一個人的點餐,系統吞吐量極大化。
非同步處理的關鍵元件
要實作非同步,系統通常需要以下三個部分協作:
呼叫方 (Caller)
發起請求後不再等待,而是立即返回執行下一行程式碼。
回呼機制 (Callback / Promise / Future)
當任務完成時,系統如何通知你?
Callback: 「做完後請執行這個 function」。
Promise / Async-Await: 「這是一個承諾,未來我會把結果填進去」。
事件迴圈 (Event Loop)
這是非同步的心臟(常見於 Node.js 或 Go)。它不斷檢查「任務完成了嗎?」如果完成了,就把對應的回呼函式丟回主執行緒執行。
非同步 與 佇列 的結合
非同步處理通常依賴 Queue 來實現系統間的解耦:
提交任務: API 收到請求,把繁重的任務(如:生成 PDF、發送萬封郵件)丟進 Queue。
立刻回應: API 告訴使用者「已收到請求,處理中」,耗時僅需 10ms。
非同步消耗: 後端有一個 Worker 默默地從 Queue 拿任務出來跑。
通知結果: 跑完後透過 WebSocket 或 Webhook 通知前端。
進階思考:非同步的代價
雖然非同步很強大,但身為 工程師,我們必須考慮以下挑戰:
Race Condition: 當兩個非同步任務同時修改同一個資料庫欄位,誰才是對的?
Error Handling: 非同步錯誤很難追蹤(Stack Trace 通常會斷掉)。如果背景任務失敗了,使用者怎麼知道?
複雜度: 程式碼不再是從上到下執行,除錯與邏輯串接變得困難。

