這篇要回答的問題:為什麼後端會有「queue / event / cron」三個 async primitive?它們各自解什麼問題?場景到底該用哪個?這篇從根源講動機,不是 feature 比較。
3 分鐘結論
- 三個原語各解不同問題:queue 解「使用者按按鈕後在背景處理」、event-driven 解「某事發生 → 多個下游同時做反應」、cron 解「每天/每分鐘固定觸發」
- 它們不互斥,同個系統常常 3 個一起用
- 重疊區(例:30 分鐘催繳)兩個都做得到,trade-off 看流量 / 及時性 / debug 難度
這篇假設你知道
- 寫過 web 後端(任何語言、任何框架)
- 不熟下面這些詞時 → #16 EDA 名詞速查:queue / event-driven / cron / handler / async
寫完後接著看:
- 要不要用 event-driven 整體架構?→ #18 MVC vs Event-driven
- 用 event-driven 該選哪個 broker?→ #14 Broker Selection
- 用了 EDA 上 production 還要解 4 個問題?→ #19 Production EDA 入門
1. 同步 web 為什麼擋路
假設你做個活動報名系統。使用者點「報名」之後,後端要做:
- 寫一筆 registration 到 DB
- 寄確認信給使用者
- 推一個訊號給推薦系統「這 user 對 X 主題感興趣」
- 產一張個人化的活動 PDF 寄到信箱(5 秒 CPU 工作)
最直覺:controller 一路同步呼叫四個 service:
class RegistrationController {
public function create(Request $r) {
$reg = Registration::create($r->all());
EmailService::sendConfirmation($reg);
RecommenderService::signal('event_registered', $reg);
PdfService::generateAndEmail($reg); // 慢
return response($reg, 201);
}
}跑得起來。但隨著系統長大會撞到 4 個限制:
| 限制 | 具體痛點 |
|---|---|
| 響應時間累加 | response time = DB write + email + recommender + PDF = 可能 6-10 秒。使用者瀏覽器轉圈圈 |
| 失敗綁在一起 | PDF service 暫時掛 → 報名也失敗 → 使用者看到「報名失敗」即使 DB 已經寫進去(更糟) |
| 流量被卡住 | 報名活動開搶那 5 分鐘流量飆 20 倍,PDF service 撐不住 → 拖累整個報名入口 |
| 「沒人來但時間到了該做」做不到 | 例如「報名後 24 小時自動寄提醒信」這種需求,同步流程裡沒位置放 |
這 4 個痛點對應三個 async primitive 的存在動機:
- Queue 解前 3 個(背景處理、削峰、失敗隔離)
- Event-driven 加上「多個 subscriber 對同一件事反應」這層
- Cron / Scheduler 解第 4 個(時間觸發)
下面分開講。
2. Primitive A:Queue(背景處理)
簡史
訊息佇列的概念比現代 web 老很多。1970-80 年代 mainframe 時代就有 message queue(IBM MQSeries / 各家 enterprise MQ),用來讓不同 process / 不同機器互傳訊息,最初動機是「我這台主機算完了,要把結果傳給隔壁那台處理」。
到 1990s-2000s 漸漸下放到應用層:
- 開放標準 AMQP (RabbitMQ 後來主要實作這個)
- 用 Redis 當輕量 queue 從 2010s 流行
- 雲服務商提供 managed queue (Amazon SQS / Google Cloud Tasks 等)
- 各框架內建 queue 抽象(Laravel queue / Sidekiq / Celery)
最小心智模型
producer ──── push ────▶ [ Queue ] ──── pull ────▶ worker (consumer)
│ │
└─ 推完就 return,不等 worker └─ 自己節奏處理,做完 ack
兩個關鍵性質:
- 時間解耦:producer 不必等 worker 完成
- 空間解耦:producer 跟 worker 是分開的 process(甚至分開的機器)
解決什麼問題
用前面的活動報名範例改寫:
class RegistrationController {
public function create(Request $r) {
$reg = Registration::create($r->all());
dispatch(new SendConfirmationEmail($reg));
dispatch(new SignalRecommender($reg));
dispatch(new GeneratePdf($reg));
return response($reg, 201);
}
}dispatch(...) 把工作丟進 queue 就 return。Controller 的 response time 從 6-10 秒 → ~10 ms。Worker 在 background 慢慢處理。
對應前面的 4 個限制:
- ✅ 響應時間累加 → 解掉(推給 worker)
- ✅ 失敗綁在一起 → 解掉(worker 失敗有自己的 retry / failed_jobs,不影響使用者)
- ✅ 流量被卡住 → 解掉(broker queue 暫存 burst,worker 平緩消化)
- ❌ 時間觸發 → 沒解
Sweet spot
「我有件事要做,但不需要立刻完成」:
- 寄信 / push notification
- 產報表、產 PDF、image processing、影片轉檔
- 計算量大但不阻塞主流程的工作
- 外部 API 呼叫(容易 timeout、容易失敗、要 retry)
💡 如果你用過 Laravel
| 你看過的 | 對應本節 |
|---|---|
dispatch(new Job) | producer push 一筆 |
php artisan queue:work | 啟動 worker |
QUEUE_CONNECTION=redis | broker 連線設定 |
| Horizon | worker manager + UI |
tries backoff() | retry policy(#23 詳細) |
Laravel 把 queue 包得很好 — 預設就有 retry、failed_jobs table、Horizon 監控。一般 web app 起步用內建 queue 完全夠。
3. Primitive B:Event-driven(多訂閱者解耦)
簡史
事件驅動的概念比 web 還老。源自:
- 1990s GoF design pattern 的 Observer pattern(程式內訂閱-通知)
- 圖形界面框架(Java Swing、Windows MFC)一直在用這套
- 2000s 進入分散式系統 SOA(Service-Oriented Architecture)
- 2010s 微服務時代成主流(每個 service publish 自己的事件,其他 service 訂閱)
跟 Queue 的關鍵差別
表面看像一回事,但心智模型不同:
| Queue | Event-driven | |
|---|---|---|
| 對應數 | 1 to 1:producer push,1 個 worker 拿走 | 1 to N:publisher emit,N 個 subscriber 都收到 |
| 語意 | 「我要請你做某事」(命令式) | 「某事發生了」(事實陳述) |
| 是否預先知道 consumer | 通常 yes(你 dispatch 哪個 job class 是寫死的) | no(新 subscriber 隨時加入,publisher 不必動) |
用範例感受差別
繼續活動報名:
// Queue 寫法:「我要請你寄信」「我要請你通知推薦系統」「我要請你產 PDF」
// 命令式 — controller 知道有哪些工作要被做
dispatch(new SendConfirmationEmail($reg));
dispatch(new SignalRecommender($reg));
dispatch(new GeneratePdf($reg));
// Event 寫法:「報名這件事發生了」
// 事實陳述 — controller 不必知道有誰要對這件事反應
event(new RegistrationCreated($reg));Event 版本的差別:
- 半年後新增「報名通知 Slack channel」時,controller 完全不用改,只要加一個新 listener
NotifyOpsSlack - 移除「推薦系統訊號」時也是只動 listener,不動 controller
這個性質叫 publisher-subscriber decoupling。Producer 不知道誰在聽,所以團隊 / 模組之間可以獨立演進。
心智反過來:什麼時候 Queue 比較對?
不是所有 async 工作都該寫成 event。命令式語意寫成 event 反而誤導:
// 不該的:寄一封特定的「報名確認信」是命令,不是事件
event(new SendConfirmationEmail($user)); // ❌
// 該的:報名這件事發生了,是事件
event(new RegistrationCreated($reg)); // ✅
// 然後 SendConfirmationEmailListener 訂閱這個 event關鍵判準:publisher 心裡在想「我要請對方做 X」是 queue,「我要昭告天下這件事」是 event。
Sweet spot
「事情發生了,誰想知道誰來聽」:
- 業務狀態變化(訂單成立、付款完成、user 升級會員)
- 多個 subscriber 對同一事件做不同反應
- 跨團隊 / 跨 module / 跨 service 解耦
- 預期未來會加新 subscriber
💡 如果你用過 Laravel
| 你看過的 | 對應本節 |
|---|---|
event(new XHappened) | publish event |
EventServiceProvider 的 $listen | 訂閱 mapping |
ShouldQueue listener | event + queue 結合(listener 不立刻跑、丟 queue 後台跑) |
Laravel event() + ShouldQueue listener 是「最方便進 EDA 的 entry」— 但只解決上半(解耦 + async)。Production 還會撞到 broker dual-write / consumer 重複處理 / handler 失敗回收 / 端到端追蹤 4 個問題,這是 #19 Production EDA 系列 在補的。
4. Primitive C:Cron / Scheduler(時間觸發)
簡史
cron 是 Unix daemon,1975 年就有了。整整比現代 web 老了 20 年。設計初衷是 system maintenance:
- 每天 02:00 跑 backup
- 每小時 rotate log
- 每週 cleanup 過期 tmp 檔
- 每月 1 號跑帳單
現代 web 框架擴展了這個概念,把 cron 從「系統層」抬到「應用層」:
- Quartz Scheduler (Java)
- APScheduler (Python)
- Laravel
Console\Kernel::schedule() - Sidekiq Cron / Whenever (Ruby)
跟前兩者的關鍵差別
| Queue / Event | Cron | |
|---|---|---|
| 觸發 | 事件(某事發生了) | 時間(時鐘到了) |
| Producer | 有(誰 push / publish) | 沒有(沒人觸發,scheduler 自己跑) |
| 處理時機 | 事件抵達後盡快 | 預先設定的時間點 |
最關鍵:cron 沒有 publisher。是 scheduler 看時鐘自己決定「現在該跑什麼」。
解決什麼問題
回到活動報名範例 — 報名後 24 小時自動寄活動提醒信。這個需求沒有 event 對應:
- 不是「報名」這個事件當下要做(那會等 24 小時、卡住 worker)
- 不是「活動開始」這個事件當下要做(活動還沒到那個 milestone)
- 是「時間到了該寄了」
用 cron / scheduler:
// Laravel Console\Kernel::schedule()
$schedule->call(function () {
$regs = Registration::where('reminder_sent', false)
->where('created_at', '<=', now()->subHours(24))
->get();
foreach ($regs as $reg) {
dispatch(new SendReminder($reg)); // 推給 queue 寄
}
})->everyFiveMinutes();每 5 分鐘檢查一次,找出「24 小時前報名但還沒寄提醒的」,每筆 dispatch 給 queue 處理。
注意 cron 通常不直接做事,而是「找出符合條件的,丟給 queue / event」— cron 跟 queue/event 共生。
Sweet spot
「無人觸發,但時間到了該做」:
- 定期 report 產生(日報、週報、月報)
- 定期 cleanup(過期 session、暫存檔、軟刪除清理)
- 定期帳單扣款 / 退款 / 月費結算
- 系統健康檢查、外部服務探活
- 「OO 之後 N 時間自動 XX」這種延遲需求(部分情境)
💡 如果你用過 Laravel
| 你看過的 | 對應本節 |
|---|---|
Console\Kernel::schedule() | 集中宣告 |
php artisan schedule:work | 跑 scheduler daemon |
* * * * * cd /path && php artisan schedule:run | system cron 入口(每分鐘一次) |
->everyFiveMinutes() / ->daily() 等 | 排程頻率 DSL |
->withoutOverlapping() / ->onOneServer() | 多 worker 共存的鎖定 |
Laravel 把「跑 cron」這件事抬到應用層 — system 的 crontab 只用一行入口(每分鐘觸發 schedule:run),具體排程由 PHP code 集中管理。比直接編輯 crontab 維護成本低很多。
5. 三者重疊區
實務上三者不互斥,而且常常搭配使用。整理 3 個常見組合:
5.1 Cron + Event:「定期掃描 → 發 event」
cron 每 5 分鐘
↓
SELECT pending orders WHERE created < NOW() - 30 min
↓
foreach: event(new OrderPendingTooLong($order))
↓
N 個 listener: 寄催繳信 / 降權重 / 通知客服
cron 負責「找」,event 負責「廣播」。
5.2 Event + Queue:「事件用 queue 傳遞」
Laravel event(...) + ShouldQueue listener 就是這個模式:
event(new OrderCreated)
↓
Listener with ShouldQueue
↓
丟進 queue
↓
worker 從 queue 拉出來跑 handler
底層是 queue,心智模型是 event。新人常困惑「queue 跟 event 是同一件事嗎?」:
- 實作上 queue 經常是 event 的傳輸媒介
- 概念上 不一樣(queue 是 1:1 工作分發、event 是 1:N 事件廣播)
- 一個 event publish 觸發 3 個 listener → 底層可能是 3 個 queue job
5.3 「延遲 event」vs「cron 掃描」哪個贏
兩種方式都能達成「某事發生後 N 時間後做某事」:
方法 A — 延遲 event(事件鏈):
event(new OrderCreated)
↓
Listener delays by 30 min:
dispatch(new SendReminder)->delay(now()->addMinutes(30));
方法 B — Cron polling:
cron 每 5 分鐘 SELECT WHERE created < NOW() - 30 min AND reminder_sent=false
↓ foreach
dispatch(new SendReminder($order));
各有 trade-off:
| 延遲 event | Cron polling | |
|---|---|---|
| 響應準確度 | 高(30 分到就 fire) | 低(每 5 分鐘 batch) |
| 系統資源 | 每筆事件占一個 delayed job | 每 5 分鐘掃一次 DB |
| Downtime 後恢復 | 看 broker 持久化(broker 重啟可能丟) | 自動補跑(下次掃就掃到) |
| 「漏掉的」處理 | 較難 | 內建 |
| 適合的訊號量 | 中量、每筆都要管 | 大量、可批次處理 |
實際上業務關鍵的延遲動作通常 cron 比較穩,因為 downtime 後自動恢復;非關鍵 / 個別響應重要的用延遲 event。
6. 一個 codebase 三者並存怎麼劃邊界
三者並存是常態,不該為了「統一」把所有事都做成同一種。劃邊界的判斷:
問三個問題依序回答:
Q1: 這件事什麼時候要做?
├─ 時間到了再做 ──────────────────────── → Cron
└─ 事件發生後馬上做(或盡快) ────────── 繼續 Q2
Q2: 這件事是「命令」還是「通知」?
├─ 命令(我要請對方做 X) ─────────────── → Queue / direct job
└─ 通知(某事發生了) ────────────────── 繼續 Q3
Q3: 有幾個地方要對這事反應?
├─ 1 個 ──────────────────────────────── → Queue / direct job 也 OK
└─ 多個(或未來可能多個) ────────────── → Event
訂單系統範例對應
| 動作 | 觸發 | 命令還是通知 | 適合 primitive |
|---|---|---|---|
| 寫訂單到 DB | HTTP request | 命令 | MVC sync(強一致,response 必看到) |
| 寄確認信 | 訂單成立 | 命令(單一目的) | Queue |
| 推薦系統 + 數據分析 + Slack 通知 | 訂單成立 | 通知(多接收方) | Event + 多 listener |
| 30 分鐘後催繳 | 時間 | — | Cron(穩) or 延遲 event(響應) |
| 每天 02:00 結算 | 時間 | — | Cron |
| user 登入失敗 5 次封鎖 | 事件 | 命令 | Queue 或 inline(取決於即時性需求) |
常見邊界違反
| Anti-pattern | 症狀 | 修正方向 |
|---|---|---|
| 過度 event 化 | 所有事都 publish event,debug 要追 N 個 listener | 命令式語意用 queue,事件式留給「真的多 subscriber」 |
| Cron polling 取代 event | 一秒鐘掃整張 table 找變化 | 用 event 立刻 trigger,cron 留給「真的時間觸發」 |
| 同步硬扛 async 工作 | API response 5 秒以上、failure 牽連 | 移到 queue / event |
| 延遲 event 取代所有 cron | 系統重啟 / broker 重啟後延遲 event 全丟 | 業務關鍵的「時間觸發」用 cron + state in DB |
7. 反思
把三個 primitive 攤平來看,它們都源自同一個原始問題:「同步呼叫不夠用」。
但解的是不同子問題:
- Queue 解「時間」維度 — 主流程不被慢動作拖死
- Event 解「空間」維度 — publisher 不必知道誰要反應
- Cron 解「時鐘」維度 — 沒人觸發但時間到了該做
框架(Laravel / Rails / Django)把這三個 primitive 都包好了 — 你可以幾行 code 就用上。但「邊界該怎麼劃」是設計問題,框架不幫你決定。
下次寫 async 邏輯時,可以問自己這 4 個問題:
- 為什麼這件事不能同步做? — 確認你真的需要 async(過度 async 也是負擔)
- 觸發是「事件」還是「時間」? — 決定 cron vs queue/event
- 誰需要知道這件事發生了? — 1 個用 queue,多個用 event
- 失敗了該怎麼辦? — 框架幫一部分,但 production reliability 還有事要做(#19 Production EDA 系列 系統性地補這層)
工具的歷史告訴你它們為了什麼存在,劃邊界的智慧在工具之外。
8. 相關文章
Queue / message broker 深入:
- #04 Message Queue 演進史 — broker 自己的歷史
- #06 Queue 演進的驅動力 — broker 為什麼長成現在這樣
- #14 RabbitMQ vs Kafka vs NATS vs Redis Streams — 選型 cheat sheet
架構 / 應用層面:
- #18 MVC vs Event-driven Architecture — 同 process 內部要不要用 EDA
Production EDA 系列:
- #19 Production EDA 入門 — 用了 EDA 之後 production 還要解的 4 個問題
- 篇 2-6 → outbox / dedup / DB-write idempotency / retry+DLQ / audit
Runnable demo(涵蓋 queue / event-driven 兩塊):
tools3455147/mq-event-driven-demo— Node.js + 5 個 broker,可以docker compose up跑