你按下「送出」後,伺服器真的要全部做完嗎?

假設一個網站提供「上傳影片後寄 Email 通知」的功能。使用者按下送出後,後端可能要做很多事:驗證檔案、寫入資料庫、轉換影片格式、產生縮圖、上傳儲存空間、寄出通知信。

最直接的寫法,是讓同一個 request 從第一步一路執行到最後一步。這叫做同步處理:前面的工作沒完成,後面的工作就不能開始,而使用者也只能繼續等待。

如果整套流程只花 300 毫秒,通常沒有問題。但影片轉檔可能需要幾十秒,第三方 Email API 可能突然變慢,甚至暫時故障。此時使用者明明只是想確認「檔案有沒有成功送出」,卻被迫等待所有後續工作完成。

這就是 Message Queue 常出現的地方。

Message Queue 的核心想法其實很簡單

Message Queue 可以先理解成一張「待辦工作清單」。

原本的流程是:

使用者 → Web Server → 執行工作 → 完成 → 回應

加入 Queue 後,可以變成:

使用者 → Web Server → 把工作放進 Queue → 立即回應
↓

Consumer

↓

在背景完成工作

Web Server 不再需要親自完成所有事情。它只需要確認輸入有效、保存必要資料,接著建立一則訊息,例如:

{ "type": "generate_thumbnail", "videoId": "v_123" }

這則訊息不是工作結果,而是「請處理這件事」的描述。

之後另一個程式看到訊息,再真正執行縮圖產生工作。於是接收 HTTP request 和執行耗時任務,被拆成兩段獨立流程。

Producer、Queue、Consumer

理解訊息佇列時,最常看到三個角色。

Producer 是產生訊息的一方。例如 API Server 收到影片上傳後,建立一個轉檔任務。

Queue 是暫時保存訊息的地方。當工作一時做不完,訊息可以先排在這裡等待。

Consumer 則負責取得訊息並執行工作。Consumer 也常被稱為 Worker。

因此一條最基本的資料流就是:

Producer → Queue → Consumer

重要的是,Producer 通常不需要知道究竟是哪一台 Consumer 處理工作。Consumer 也不必和 Producer 同時在線。兩邊透過 Queue 解耦,系統因此得到更大的調度空間。

為什麼不直接呼叫另一個 API?

假設 Web Server 可以直接呼叫轉檔服務:

Web Server → Transcoding API

看起來已經把服務拆開了,但兩者仍然在時間上互相依賴。如果 Transcoding API 當下沒有回應,Web Server 就必須等待、重試,或者直接失敗。

Queue 多了一層緩衝。

Web Server 只要成功把任務交給 Queue,就可以結束自己的主要工作。轉檔服務暫時停機時,尚未處理的訊息仍可留在 Queue;服務恢復後再繼續消化。

這種特性常被稱為 temporal decoupling:兩個元件不必在同一時間都正常運作,才能交換工作。

Queue 也是流量的緩衝區

想像平常每分鐘只有 100 個圖片處理任務,Worker 每分鐘能處理 150 個,一切正常。

某個活動開始後,突然在一分鐘內湧入 5,000 個任務。如果沒有 Queue,後端可能同時啟動大量昂貴工作,CPU、記憶體、資料庫連線與第三方 API 都可能瞬間被打滿。

有 Queue 時,5,000 個任務可以先排隊。Worker 仍按照系統能承受的速度處理。

因此 Queue 不會讓工作憑空變快。它真正做到的是把「流量進來的速度」和「系統處理的速度」分開。

這種吸收突發流量的能力,常被稱為 buffering 或 load leveling。

一個 Consumer 不夠,就增加 Consumer

如果一個 Worker 每秒只能處理 10 個任務,而 Queue 已經累積數萬筆訊息,可以在工作彼此獨立的前提下增加 Worker:

→ Worker A
Queue → Worker B
→ Worker C

多個 Consumer 可以平行取得不同訊息,增加整體 throughput。

這也是 Queue 很適合背景工作系統的原因:前端 API 和背景運算可以分別擴充。網站 request 增加時擴充 Web Server;背景任務塞車時擴充 Worker,不需要把整個系統一起放大。

但是,「取到訊息」不等於「完成工作」

假設 Worker 從 Queue 取得一則「寄出通知信」的訊息,剛開始處理就突然當機。

如果 Queue 在 Worker 取走訊息的瞬間就永久刪除它,這封信就再也不會被寄出。

因此許多 Queue 系統會使用 acknowledgement,簡稱 ack。

典型流程是:Consumer 取得訊息、執行工作、成功後回報 ack,Queue 才把這則訊息視為已完成。

如果 Consumer 在 ack 前消失,系統可以讓訊息重新出現,交給同一個或另一個 Consumer 再處理一次。

這提高了可靠性,但也帶來另一個重要問題:同一件工作可能被執行不只一次。

At-most-once、At-least-once 與 Exactly-once

訊息系統常討論 delivery semantics,也就是「一則訊息到底可能被送幾次」。

At-most-once 表示訊息最多處理一次。好處是不會重複,但失敗時可能直接遺失。

At-least-once 表示系統努力確保訊息至少被處理一次。失敗時可以重新投遞,因此比較不容易漏掉工作;代價是同一則訊息可能被 Consumer 看見兩次以上。

Exactly-once 聽起來最理想:每件事情恰好發生一次。但在分散式系統中,「訊息只送一次」和「業務效果只發生一次」是不同問題。網路中斷時,Producer 或 Consumer 可能根本不知道上一個操作究竟成功了沒有。真正的 exactly-once 往往需要額外協議、儲存層支援與明確限制,不能只把它當成一個免費開關。

因此實務上很常見的設計是接受 at-least-once delivery,再讓 Consumer 具備 Idempotency。

Idempotency:同一件事做兩次,結果仍然正確

假設付款完成後有一個「增加會員點數 100 點」的背景任務。

如果訊息因為重試被執行兩次,而程式每次都直接執行:

points = points + 100

使用者就會得到 200 點。

這時可以替事件建立唯一 ID,例如 payment_abc123,並記錄這個事件是否已處理。Consumer 再次收到相同事件時,先檢查處理紀錄,發現已完成便不重複增加點數。

這就是冪等性設計的一種形式。

不是所有操作天生都 idempotent。把資料設定為某個固定狀態通常比較容易做到;「再增加一次」、「再寄一次」、「再扣一次」則必須特別注意重複執行造成的副作用。

Retry 不是無限重試

背景任務失敗時,重試很合理。例如第三方 API 暫時回傳 503,幾秒後可能就恢復。

但如果一則訊息內容本身就是錯的,例如缺少必要欄位,重試一萬次也不會成功。這種訊息有時被稱為 poison message。

因此成熟的 Queue 流程通常會限制 retry 次數,並搭配 exponential backoff:第一次失敗後稍等一下,第二次等待更久,之後逐步拉長間隔,避免故障期間持續轟炸下游服務。

超過重試上限後,訊息可以被移到 Dead-letter Queue(DLQ)。

Dead-letter Queue 不是垃圾桶

DLQ 用來保存「正常流程已經無法成功處理」的訊息。

工程師可以觀察 DLQ 數量、分析失敗原因、修正程式或資料,再決定是否重新投遞。若只是把失敗訊息丟進 DLQ 後永遠不看,問題只是從主 Queue 搬到另一個地方。

因此實務上通常需要監控 Queue depth、oldest message age、retry 次數與 DLQ 數量。Queue 是可靠性元件,也同時增加了一套必須被觀察的系統狀態。

訊息順序也沒有想像中簡單

如果 Queue 裡依序有 A、B、C 三則訊息,不代表業務效果一定按照 A → B → C 完成。

多個 Consumer 平行工作時,A 可能需要 10 秒,B 只需要 100 毫秒,於是 B 反而先完成。失敗重試也可能改變處理順序。

如果業務真的要求嚴格順序,例如同一個帳戶的狀態事件必須依序套用,就必須在 Queue 的 partition key、consumer concurrency 或應用層設計中明確處理。要求「所有訊息全域嚴格排序」通常會犧牲平行處理能力。

Queue 和 Pub/Sub 不完全一樣

兩者都在傳遞訊息,但目的不一定相同。

工作佇列常強調「這件工作需要被某個 Worker 完成」。如果有五個 Worker,它們通常競爭任務,每則工作由其中一個處理。

Publish/Subscribe 則常強調「某件事發生了,所有有興趣的訂閱者都可以知道」。例如 order.created 事件可能同時被 Email、Analytics、Inventory 三個服務接收。

不同產品可能同時提供 Queue、Topic、Stream 或 Pub/Sub 能力,所以不能只看產品名稱判斷語意;真正要看的是訊息如何保存、投遞,以及多個 Consumer 之間如何分配訊息。

什麼工作適合放進 Queue?

常見例子包括 Email 寄送、圖片或影片處理、PDF 與報表產生、搜尋索引更新、Webhook 投遞、資料同步、批次匯入,以及耗時的 AI inference 工作。

它們通常有共同特徵:不需要在使用者眼前立即完成、可能耗時、容易受到外部服務影響,或者需要吸收突發流量。

例如使用者要求產生一份大型報表時,API 可以先建立 job,回傳 job ID 與「processing」狀態。Worker 在背景完成後,把狀態更新為 completed,前端再取得下載結果。

這比讓瀏覽器維持一條 HTTP connection 等五分鐘通常更容易管理。

但不要為了「架構看起來很專業」就加 Queue

Queue 不是免費的抽象層。

加入它之後,你需要處理訊息格式、序列化、retry、duplicate、timeout、監控、DLQ、Consumer 部署與訊息相容性。原本一個 function call 能理解的流程,會變成跨程序、跨時間的非同步流程,除錯也更困難。

如果工作只花幾毫秒、失敗必須立刻回報給使用者,而且系統流量很小,直接同步執行通常更簡單。

好的架構不是元件越多越好,而是在可靠性、延遲、吞吐量與複雜度之間做適當交換。

從一個 request 看完整流程

最後把整個概念串起來。

使用者上傳一張大型圖片。API 驗證請求並建立資料紀錄,接著把 image.process 任務送進 Queue,然後立即回傳 202 Accepted 與 job ID。

Worker 從 Queue 取得訊息,開始產生縮圖與壓縮版本。成功後更新資料庫狀態,再向 Queue ack。若處理失敗,訊息依 retry policy 延後重試;連續失敗超過限制後進入 DLQ,等待調查。

當流量突然增加時,Queue 暫時累積工作,而不是讓所有圖片處理程序同時壓垮 API Server。若積壓持續上升,系統可以增加 Worker 數量提高消化速度。

這就是 Message Queue 最重要的價值:它不是單純把工作「丟到背景」,而是在系統不同部分之間建立一個可以緩衝、重試、擴充與隔離故障的邊界。

結語

Message Queue 解決的核心問題,不是「如何讓程式更快」,而是「哪些事情真的需要現在一起完成」。

當一個 request 裡混進太多耗時、容易失敗或可以延後的工作時,Queue 可以把它們拆開:Producer 負責描述工作,Queue 保存待處理訊息,Consumer 在自己的節奏中執行。

但非同步系統也會帶來重複投遞、順序、重試與可觀測性等新問題。因此真正重要的不是知道 RabbitMQ、Kafka、SQS 或其他產品名稱,而是先理解 delivery semantics、idempotency 與 failure handling。

只要能回答「如果 Consumer 在最糟的時間點當機,這件工作最後會怎樣?」你就已經開始用正確的方式思考 Message Queue 了。

把概念放回真正的系統流程裡看,通常比只背名詞更容易理解它。