來看一個每個電商後端都遇過的場景。
使用者按下「結帳」,這一下你後端要做的事可不只「建一筆訂單」:要寄確認信、要發紅利點數、要通知倉庫揀貨、要更新推薦模型、可能還要推一則 App 通知。如果你把這些全部塞在同一個 request 裡一件一件做完才回他「下單成功」——
那使用者就只能盯著轉圈圈,等你把信寄完。更慘的是,萬一寄信的服務剛好掛了,整筆訂單跟著失敗。欸不是,我只是想買個東西,干寄信什麼事?
問題的根,是你把「該馬上做的(建訂單)」跟「晚點做也沒差的(寄信、發點數)」綁死在一起了。Queue 就是來幫你把這兩種事拆開的。
它到底是什麼
Queue 就是一條排隊的隊伍:有人把任務丟進去(producer),有人從另一頭一個一個拿出來做(consumer),中間那條線就是 queue。
回到剛剛的下單。有了 queue,流程變成這樣:建好訂單,馬上回使用者「成功」;至於寄信、發點數、通知倉庫,通通寫一張紙條丟進 queue 就不管了。後面有專門的 worker 慢慢從 queue 撈出來做。使用者早就拿到「下單成功」走人了,根本不用陪你等寄信。
這帶來兩個你立刻有感的好處:
第一,快。 使用者只等「真正必要」的那步,副作用全部甩到背景。
第二,穩。 寄信服務掛了?沒關係,紙條還乖乖躺在 queue 裡,等它復活再撈出來重做就好——訂單一點事都沒有。(小提醒:這有個前提——queue 本身要開「持久化」,也就是把紙條寫到硬碟。多數工具可設定但不一定預設全開,broker 自己重啟時沒持久化的紙條會丟,這條件之後的文章會細講。)像這樣「倉庫的事歸倉庫、寄信的事歸寄信,彼此壞掉不互相拖累」,術語就叫解耦。
「那我開一個背景 thread 跑不就好了」
又是這個經典直覺對吧。寄信慢,那我開個 thread 在背景寄、主線程先回不就好了:
# 土法煉鋼版
def checkout(order):
create_order(order)
threading.Thread(target=send_email, args=(order,)).start() # 丟背景
return "下單成功"Demo 階段這樣是會動啦。但它一樣會在幾個地方翻車,而這些翻車點,正好就是「為什麼你需要一個真正的 Queue」:
Thread 掛了,事情就人間蒸發。 那個背景 thread 寄信寄到一半,程式重啟、或它自己拋例外死掉——這封信永遠不會被寄出,而且沒有人知道。沒有重試、沒有紀錄,就這樣沒了。Queue 不一樣:worker 做完一件任務要回報一聲「做完了」(這個確認動作叫 ack,acknowledge 的縮寫),沒收到 ack 之前,任務一直躺在 queue 裡,worker 掛了就換人重做。
Thread 跨不出這台機器。 你的 worker 想擴充成五台一起消化?thread 只活在自己 process 裡,幫不了你。Queue 是大家共用的,你要幾台 worker 來搶著做都行。
爆量的時候 thread 會直接淹死你。 秒殺一來,瞬間幾萬個請求,你就瞬間開幾萬個 thread?記憶體先爆給你看。Queue 則是讓任務乖乖排隊,worker 按自己消化得了的速度慢慢拿——這招有個名字,叫削峰填谷。
削峰填谷,這四個字是 Queue 的靈魂
想像你的 DB 每秒只吞得下 5000 筆,但促銷一開,尖峰瞬間湧進 5 萬筆。差 10 倍。
沒有 queue,這 5 萬筆一起撲上去,DB 當場躺平,連帶把本來好好的使用者也一起拖下水。
有 queue,這 5 萬筆先全部進去排隊(削峰——把那個嚇人的尖峰削平),worker 在後面穩穩地每秒撈 5000 筆出來做,花個幾秒到十幾秒慢慢消化完(填谷——把波峰的量攤平到後面的空檔)。
使用者體感可能就是「通知晚個幾秒到」,但你的系統從頭到尾沒倒。拿一點點即時性,換整個系統在尖峰不崩——這筆交易,九成場景都划算。
當然,天下沒有白吃的午餐
Queue 把同步變非同步,爽是爽,但你得吞下幾個新麻煩:
- 事情變成最終才會完成,不是「馬上保證做完」。使用者下單成功的當下,信其實還沒寄出去。這對「寄信」沒差,但對「要即時給結果」的事情(例如查餘額)就不能這樣搞。
- 同一張紙條可能被撈到兩次(網路抖一下、worker 做到一半掛了重來),所以你的 consumer 得做到「重複做也不會出錯」——這叫冪等(idempotency)。
- 任務失敗了要怎麼辦?重試幾次?一直失敗的丟去哪?(這就是之後會講的重試策略跟死信佇列。)
所以判斷「該不該上 Queue」的關鍵問題是:這件事,使用者需要當場就拿到結果嗎? 需要 → 別丟 queue,乖乖同步做完。不需要、晚一點點也 OK → 丟進去,讓系統喘口氣。
個人經驗:我自己在 Laravel 上踩過一個有點荒謬的版本。當時專案根本沒有一台專門的 queue 機,卻硬做了 event-driven——結果就變成 MVC 加 EDA 各半套的四不像,一出事 debug 超痛苦,因為你永遠不確定一個動作到底是同步走 controller、還是被某個 event handler 非同步接走了。
真正有好好用到 queue 的,反而只有那種「一個完整動作」的場景——寄某封信、跑某張報表,就 cronjob + queue 一包丟出去。本質上是有做到非同步沒錯,但整體就是一種「做一半」的感覺:該解耦的沒解乾淨,不該非同步的地方反而非同步了。這也是為什麼我會一直強調——用 queue 之前先想清楚哪些事該丟、哪些不該丟,半套的 event-driven 真的比老老實實的同步還難維護。
接下來往哪走
這篇只想讓你抓到 Queue 在解什麼問題。再往下就是工具跟模式的世界了:
- 為什麼不能只靠 async / thread,更完整的版本 → 02 ack、worker、持久化三保證
- 那麼多 broker 到底選哪個 → RabbitMQ vs Kafka vs NATS vs Redis Streams:10 分鐘做出選型
- Queue 是怎麼一路演化成現在這樣的 → Message Queue 演進史、Queue 演進驅動力
- 想看實戰素材的 → Queue、Redis Cache、Cold Start:最後三場測試、Event-driven & Queue:非同步處理與訊息佇列
回家試試(10 分鐘,Laravel 版)
不用裝任何 broker,Laravel 內建的 database driver 就能體驗完整流程:
php artisan queue:table && php artisan migrate # 建 jobs 資料表當 queue
php artisan make:job SendFakeEmail # 建一個 job
# 在 handle() 裡 sleep(5) + Log::info('寄完了'),然後在任一 route dispatch(new SendFakeEmail)
php artisan queue:work # 開 worker,看任務被撈出來做兩個實驗:①打 route 後立刻收到 response、5 秒後 log 才出現——這就是「時間解耦」。②在 handle() 裡故意 throw new Exception,看任務進 failed_jobs 資料表——這就是「掛了有紀錄、能重來」,php artisan queue:retry all 就是重做。
反思
Queue 不是什麼高大上的東西,它就是在回答一個很樸素的問題:「進來的速度比我做得完的速度還快,怎麼辦?」
答案是別硬扛——讓它排隊,你按自己的節奏慢慢消化。
下次要決定一件事該不該丟 queue,問自己三題就好:
- 這件事,使用者需要當場拿到結果嗎?(要 → 同步做;不要 → 可以丟)
- 它晚個幾秒、甚至失敗重做幾次,會出事嗎?(不會 → 很適合 queue)
- 我的 consumer 被重複叫到會不會壞掉?(會 → 先把冪等補上再說)
三題想清楚,你大概就知道這東西到底要不要進 queue 了。