這篇要回答的問題:為什麼後端會有「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

寫完後接著看:


1. 同步 web 為什麼擋路

假設你做個活動報名系統。使用者點「報名」之後,後端要做:

  1. 寫一筆 registration 到 DB
  2. 寄確認信給使用者
  3. 推一個訊號給推薦系統「這 user 對 X 主題感興趣」
  4. 產一張個人化的活動 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=redisbroker 連線設定
Horizonworker 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 的關鍵差別

表面看像一回事,但心智模型不同

QueueEvent-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 listenerevent + 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 / EventCron
觸發事件(某事發生了)時間(時鐘到了)
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:runsystem 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:

延遲 eventCron 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
寫訂單到 DBHTTP 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 個問題:

  1. 為什麼這件事不能同步做? — 確認你真的需要 async(過度 async 也是負擔)
  2. 觸發是「事件」還是「時間」? — 決定 cron vs queue/event
  3. 誰需要知道這件事發生了? — 1 個用 queue,多個用 event
  4. 失敗了該怎麼辦? — 框架幫一部分,但 production reliability 還有事要做(#19 Production EDA 系列 系統性地補這層)

工具的歷史告訴你它們為了什麼存在,劃邊界的智慧在工具之外。


8. 相關文章

Queue / message broker 深入

架構 / 應用層面

Production EDA 系列

  • #19 Production EDA 入門 — 用了 EDA 之後 production 還要解的 4 個問題
  • 篇 2-6 → outbox / dedup / DB-write idempotency / retry+DLQ / audit

Runnable demo(涵蓋 queue / event-driven 兩塊)