Queue 與 Event:讓耗時任務不阻塞使用者
本篇是「PHP/Laravel 完全指南」系列的第 10 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。
上一章成團收款的瞬間,你需要做一堆事:寄成團確認信給所有跟團者、寄通知給開團主、更新團購狀態、生成訂單明細 PDF、紀錄 analytics 事件。如果這些全部塞在同一個 HTTP request 裡同步執行,使用者按下確認後要等五到十秒才看到回應——這種體驗在 2026 年是不能接受的。
Queue(佇列)就是解決這個問題的:把不需要即時完成的工作丟到背景去跑,使用者的 request 馬上就能回應。
但 Queue 只解決了「何時做」的問題。「誰該做什麼」的問題,需要 Event/Listener 模式來處理。當「團購成團」這個事件發生時,可能有五六個不同的動作要觸發——寄信、推播、更新統計、通知倉庫備貨。如果把這些全寫在 Controller 或 Service 裡,程式碼會變成一團義大利麵。
Event/Listener 讓你把「事件」和「反應」解耦:Controller 只要 dispatch 一個 GroupBuyConfirmed event,各個 Listener 自己知道該做什麼。新增需求?加一個 Listener 就好,不用動到原本的程式碼。
Laravel 還有一個被低估的利器:Notification。它提供統一的介面來發送通知,不管你要透過 Email、SMS、Slack、站內訊息還是 push notification——同一個 Notification class 搞定所有管道。在「揪好買」裡,我們會把成團通知、出貨通知、截止提醒全部用 Notification 來實作,搭配 Queue 讓它們在背景發送。
為什麼需要 Queue:使用者不該等你寄 Email
想像這個場景:一個團購有 50 個跟團者,成團後你要寄確認信給每個人。每封信透過 SMTP 發送大約需要 0.5 秒,50 封就是 25 秒。如果是同步執行,使用者按下「確認成團」之後,要盯著 loading 轉圈 25 秒才能看到結果。
同步處理(Synchronous):
使用者按下確認 → 寄信給 A(0.5s)→ 寄信給 B(0.5s)→ ... → 寄信給第 50 人(0.5s)→ 回應使用者
總共 25 秒 ⏳
非同步處理(Asynchronous with Queue):
使用者按下確認 → 把 50 封信丟進 Queue → 回應使用者 ✅(0.1s)
↓
背景 Worker 慢慢寄,使用者不用等
這就是 Queue 的核心價值:把不需要使用者等待的工作延遲到背景執行。常見的適用場景:
- 寄送 Email / SMS / 推播通知——使用者不需要等你跟 SMTP Server 握手
- 生成 PDF 或 Excel 報表——耗時的運算不該卡住 request
- 呼叫第三方 API——外部服務的回應時間你控制不了
- 圖片處理——裁切、壓縮、上傳到 CDN
- 資料同步——把訂單資料推到 ERP 或會計系統
不過在你決定全部丟 Queue 之前,先把帳算清楚。Queue 不是免費的:你的系統會從「一個 PHP process 收 request、回 response」變成「還要養一個常駐 worker、設 Supervisor 讓它掛掉自動拉起、加監控、部署時記得 php artisan queue:restart」的分散式架構。對流量很小的專案,這個維運成本常常大於「使用者少等三秒」省下來的時間——這時候同步處理加一個 loading 轉圈,反而更簡單、更可靠、半夜也不會出事。而且非同步多了一個很容易忽略的 failure mode:worker 沒在跑的時候,任務不會報錯,它只是安靜地躺在佇列裡不動,通知就默默沒送出去,沒人會發現。同步至少會當場噴錯給你看。所以這章後面我會花篇幅講 Supervisor 跟失敗監控,不是順帶提一下——那是用 Queue 的入場費,不是加分題。
如果你用過 Node.js,你可能會想:「JavaScript 本來就是非同步的啊,用 Promise 不就好了?」問題在於,PHP 的每個 request 是獨立的 process。request 結束了,process 就結束了,不會有「背景繼續跑」的機會。
Queue 的做法是把任務序列化後存到某個地方(資料庫、Redis、SQS),然後由獨立的 worker process 去取出來執行。概念上類似 Python 的 Celery 或 Node.js 的 Bull/BullMQ。
Queue 概念與 Driver 選擇
Laravel 的 Queue 系統支援多種 driver(驅動),讓你根據環境和規模選擇適合的後端:
| Driver | 適用場景 | 優點 | 缺點 |
|---|---|---|---|
sync | 本地開發/除錯 | 同步執行,方便 debug | 不是真的 queue,會阻塞 |
database | 小型專案/開發環境 | 不需額外服務 | 效能較差,polling 機制 |
redis | 正式環境首選 | 快、支援優先級、延遲任務 | 需要 Redis 服務 |
sqs | AWS 大規模部署 | 完全託管、自動擴展 | 綁定 AWS |
在「揪好買」的開發環境,我們用 database driver 就夠了——不需要額外安裝 Redis,資料庫裡開一張 table 就能跑。正式環境再切到 redis,只要改一行 .env 設定。
設定 Database Queue Driver
# .env
QUEUE_CONNECTION=database
建立 Queue 需要的資料表:
php artisan queue:table
php artisan migrate
這會建立一張 jobs table,Queue 把待執行的任務序列化後存在這裡。若需要批次處理(Job Batching),另外執行 php artisan queue:batches-table 建立 job_batches table。另外,我們還需要一張 failed_jobs table 來記錄失敗的任務:
php artisan queue:failed-table
php artisan migrate
提示: Laravel 12 的新專案預設就會幫你建好這些 migration。如果你是從舊版升級,才需要手動執行上面的指令。
Job 類別:把任務包裝起來
Queue 裡的每個任務就是一個 Job class。用 Artisan 指令快速生成:
php artisan make:job ProcessGroupBuyConfirmation
這會在 app/Jobs/ 目錄下建立一個檔案:
<?php
namespace App\Jobs;
use App\Models\GroupBuy;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessGroupBuyConfirmation implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly GroupBuy $groupBuy,
) {}
public function handle(): void
{
// 更新團購狀態
$this->groupBuy->update(['status' => 'confirmed']);
// 生成訂單明細 PDF
$pdf = PDF::loadView('pdf.group-buy-summary', [
'groupBuy' => $this->groupBuy,
'participants' => $this->groupBuy->participants()->with('orders')->get(),
]);
Storage::put(
"summaries/{$this->groupBuy->id}.pdf",
$pdf->output()
);
}
}
幾個關鍵點:
implements ShouldQueue——這個 interface 告訴 Laravel:「這個 Job 要丟到 Queue 裡背景執行」。如果拿掉它,Job 會同步執行(跟沒有 Queue 一樣)。SerializesModels——這個 trait 會自動序列化 Eloquent Model 的 ID,然後在 worker 端重新從資料庫取出完整的 Model。避免把整個 Model 物件塞進 Queue(那會很大,而且資料可能過時)。handle()方法——worker 從 Queue 取出任務後,就是執行這個方法。你的業務邏輯寫在這裡。
分派 Job
在 Controller 或 Service 裡,把 Job 丟進 Queue:
// 最常用的方式
ProcessGroupBuyConfirmation::dispatch($groupBuy);
// 延遲執行:5 分鐘後才執行
ProcessGroupBuyConfirmation::dispatch($groupBuy)
->delay(now()->addMinutes(5));
// 指定 Queue 名稱(用來區分優先級)
ProcessGroupBuyConfirmation::dispatch($groupBuy)
->onQueue('high');
// 用 dispatch helper function
dispatch(new ProcessGroupBuyConfirmation($groupBuy));
重試與超時設定
Job 失敗時的行為可以直接在 class 上設定:
class ProcessGroupBuyConfirmation implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3; // 最多重試 3 次
public int $timeout = 60; // 超過 60 秒就算超時
public int $maxExceptions = 2; // 最多容忍 2 個例外
// 退避策略:第一次失敗等 10 秒、第二次等 30 秒、第三次等 60 秒
public function backoff(): array
{
return [10, 30, 60];
}
// ...
}
Job Middleware
如果你需要防止同一個 Job 重複執行(例如同一個團購的確認信不該寄兩次),可以用 Job Middleware:
use Illuminate\Queue\Middleware\WithoutOverlapping;
public function middleware(): array
{
return [
new WithoutOverlapping($this->groupBuy->id),
];
}
WithoutOverlapping 會用 lock 機制確保同一個 key 的 Job 不會同時執行。在「揪好買」裡,用團購 ID 當 key 是最直覺的選擇。
Event 與 Listener:解耦業務邏輯
Queue 解決了「非同步執行」的問題,但還有另一個問題:當一個動作需要觸發多個後續動作時,程式碼要怎麼組織?
不好的寫法是把所有邏輯塞在 Controller:
// ❌ 不好:Controller 變成 God Object
class GroupBuyController extends Controller
{
public function confirm(GroupBuy $groupBuy)
{
$groupBuy->update(['status' => 'confirmed']);
// 寄確認信給跟團者
foreach ($groupBuy->participants as $user) {
Mail::to($user)->send(new GroupBuyConfirmedMail($groupBuy));
}
// 寄摘要給開團主
Mail::to($groupBuy->organizer)->send(new OrganizerSummaryMail($groupBuy));
// 更新統計
$groupBuy->increment('confirmed_count');
// 通知倉庫備貨
Http::post('https://warehouse-api.example.com/prepare', [...]);
// 紀錄 analytics
Analytics::track('group_buy_confirmed', [...]);
return redirect()->route('group-buys.show', $groupBuy);
}
}
問題在哪?每次新增需求(例如「成團後也要發 LINE 通知」),你都要改這個 Controller。這違反了開放封閉原則(Open/Closed Principle)——對擴展開放,對修改封閉。
Event/Listener 模式的做法是:Controller 只做一件事——dispatch 一個 Event。其他的後續動作由各自的 Listener 處理:
// ✅ 好:Controller 只 dispatch event
class GroupBuyController extends Controller
{
public function confirm(GroupBuy $groupBuy)
{
$groupBuy->update(['status' => 'confirmed']);
event(new GroupBuyConfirmed($groupBuy));
return redirect()->route('group-buys.show', $groupBuy);
}
}
建立 Event
php artisan make:event GroupBuyConfirmed
<?php
namespace App\Events;
use App\Models\GroupBuy;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GroupBuyConfirmed
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly GroupBuy $groupBuy,
) {}
}
Event class 本身很單純——它就是一個資料容器,攜帶事件發生時的相關資訊。不需要任何業務邏輯。
建立 Listener
php artisan make:listener SendConfirmationToParticipants --event=GroupBuyConfirmed
php artisan make:listener SendOrganizerSummary --event=GroupBuyConfirmed
php artisan make:listener UpdateGroupBuyStats --event=GroupBuyConfirmed
<?php
namespace App\Listeners;
use App\Events\GroupBuyConfirmed;
use App\Notifications\GroupBuyConfirmedNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendConfirmationToParticipants implements ShouldQueue
{
public function handle(GroupBuyConfirmed $event): void
{
$groupBuy = $event->groupBuy;
foreach ($groupBuy->participants as $user) {
$user->notify(new GroupBuyConfirmedNotification($groupBuy));
}
}
}
注意 implements ShouldQueue——加上這個 interface,Listener 就會自動在背景執行。不加的話就是同步執行。你可以根據每個 Listener 的特性來決定:寄信要 Queue、更新資料庫統計可以同步。
背景化之前先想清楚 debug 怎麼辦
「寄信要 Queue」這句話我得補一個但書。同步寄信失敗時,stack trace 直接打在那個 request 上,使用者當場看到錯誤、你的 log 也立刻有紀錄。一旦丟進 Queue,失敗就搬到另一個 process、另一個時間點發生了——使用者那邊畫面顯示「確認成功」,信其實沒寄出去,而且沒人會知道。這個「使用者以為成功、實際上失敗」的落差會無聲累積,等客訴進來才發現往往已經漏掉一堆。
所以非同步化跟「主動建可觀測性」是綁在一起的,不能只做前者:至少要對
failed_jobs設告警,正式環境上 Horizon 看佇列健康度,例外接到 Sentry。開發期則反過來——把QUEUE_CONNECTION設成sync,或用php artisan queue:work --once一次跑一個,讓失敗回到 request 生命週期裡,你才 debug 得動。背景執行很爽,但你是拿「看得見的失敗」去換的,這筆交易要心裡有數。
<?php
namespace App\Listeners;
use App\Events\GroupBuyConfirmed;
class UpdateGroupBuyStats
{
// 沒有 implements ShouldQueue → 同步執行
public function handle(GroupBuyConfirmed $event): void
{
$groupBuy = $event->groupBuy;
$groupBuy->update([
'participant_count' => $groupBuy->participants()->count(),
'total_amount' => $groupBuy->orders()->sum('amount'),
'confirmed_at' => now(),
]);
}
}
註冊 Event 與 Listener
Laravel 12 支援自動發現——只要 Listener 的 handle() 方法有正確的 type hint,Laravel 會自動把它跟 Event 配對。不需要在任何地方手動註冊。
如果你需要明確控制(或是自動發現沒生效),可以在 AppServiceProvider 的 boot() 方法裡手動註冊:
use App\Events\GroupBuyConfirmed;
use App\Listeners\SendConfirmationToParticipants;
use App\Listeners\SendOrganizerSummary;
use App\Listeners\UpdateGroupBuyStats;
use Illuminate\Support\Facades\Event;
public function boot(): void
{
Event::listen(GroupBuyConfirmed::class, [
SendConfirmationToParticipants::class,
SendOrganizerSummary::class,
UpdateGroupBuyStats::class,
]);
}
要確認所有 Event/Listener 的對應關係,可以用:
php artisan event:list
跨框架概念對照
如果你從其他語言過來,Event/Listener 的概念不會太陌生:
| 框架/語言 | 對應機制 | 差異點 |
|---|---|---|
| Node.js | EventEmitter | Node 的 event 是 in-process、同步觸發;Laravel 可以選擇 Queue 背景執行 |
| Python/Django | Signals(post_save 等) | Django 的 signal 是同步的,沒有內建的非同步機制 |
| React | Custom Events / Context | 前端的事件系統,概念類似但作用域不同 |
| Spring | @EventListener | 最接近 Laravel 的做法,也支援 async |
Laravel 的 Event 系統最大的優勢是它跟 Queue 的深度整合——一個 implements ShouldQueue 就能把 Listener 從同步變成非同步,不需要額外的配置。
Notification:統一的通知發送介面
你可能會想:「寄信直接用 Mail::send() 不就好了?幹嘛還要 Notification?」
問題是,真實世界的通知不只有 Email。成團通知你可能要同時寄 Email + 存到站內訊息 + 推 LINE 通知。如果分三個地方寫,資料格式不一致,日後加一個管道就要改三個地方。
Notification 的設計理念是:一個通知類別,多種發送管道。
php artisan make:notification GroupBuyConfirmedNotification
<?php
namespace App\Notifications;
use App\Models\GroupBuy;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class GroupBuyConfirmedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly GroupBuy $groupBuy,
) {}
/**
* 決定這個通知要透過哪些管道發送
*/
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
/**
* Email 版本
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("🎉 團購「{$this->groupBuy->title}」已成團!")
->greeting("Hi {$notifiable->name},")
->line("好消息!你參加的團購「{$this->groupBuy->title}」已經達到最低人數,正式成團了。")
->line("成團人數:{$this->groupBuy->participant_count} 人")
->line("總金額:NT$ " . number_format($this->groupBuy->total_amount))
->action('查看團購詳情', route('group-buys.show', $this->groupBuy))
->line('感謝你的參與,我們會儘快安排出貨!');
}
/**
* 站內訊息版本(存到 notifications table)
*/
public function toDatabase(object $notifiable): array
{
return [
'group_buy_id' => $this->groupBuy->id,
'group_buy_title' => $this->groupBuy->title,
'message' => "團購「{$this->groupBuy->title}」已成團",
'type' => 'group_buy_confirmed',
];
}
}
via() 方法決定要用哪些管道發送。你甚至可以根據使用者的偏好動態決定:
public function via(object $notifiable): array
{
$channels = ['database']; // 站內訊息一定有
if ($notifiable->email_notifications_enabled) {
$channels[] = 'mail';
}
if ($notifiable->line_token) {
$channels[] = 'line'; // 自訂管道
}
return $channels;
}
發送通知
有兩種方式:
// 方式一:透過 Notifiable trait(User Model 預設就有)
$user->notify(new GroupBuyConfirmedNotification($groupBuy));
// 方式二:透過 Notification Facade(可以一次寄給多人)
use Illuminate\Support\Facades\Notification;
Notification::send(
$groupBuy->participants, // Collection of users
new GroupBuyConfirmedNotification($groupBuy)
);
方式二在「揪好買」更常用——成團的時候要一次通知所有跟團者。
Mail 通知:寄出成團確認信
上面的 toMail() 用的是 MailMessage builder——它會自動套用 Laravel 內建的 Email 模板。但如果你想要更漂亮、更品牌化的 Email,可以用 Markdown 模板:
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("🎉 團購「{$this->groupBuy->title}」已成團!")
->markdown('emails.group-buy-confirmed', [
'user' => $notifiable,
'groupBuy' => $this->groupBuy,
'orders' => $this->groupBuy->orders()
->where('user_id', $notifiable->id)
->get(),
]);
}
對應的 Markdown 模板 resources/views/emails/group-buy-confirmed.blade.php:
<x-mail::message>
# 🎉 {{ $groupBuy->title }} 已成團!
Hi {{ $user->name }},
你參加的團購已經達到最低人數,正式成團了。以下是你的訂購明細:
<x-mail::table>
| 品項 | 數量 | 小計 |
|:-----|:----:|-----:|
@foreach ($orders as $order)
| {{ $order->product_name }} | {{ $order->quantity }} | NT$ {{ number_format($order->amount) }} |
@endforeach
| **合計** | | **NT$ {{ number_format($orders->sum('amount')) }}** |
</x-mail::table>
<x-mail::button :url="route('group-buys.show', $groupBuy)">
查看團購詳情
</x-mail::button>
感謝你使用揪好買!
{{ config('app.name') }}
</x-mail::message>
Laravel 的 Mail Markdown 元件(x-mail::message、x-mail::table、x-mail::button)會自動轉換成漂亮的 HTML Email。
開發環境測試:不要真的寄信
在開發階段,你不會想真的寄 Email 出去。在 .env 裡設定:
MAIL_MAILER=log
這樣所有「寄出的」Email 都會寫到 storage/logs/laravel.log,你可以在 log 裡看到完整的 Email 內容,確認格式正確。
另一個好用的選擇是 Mailpit——一個本地的 Email 測試伺服器,會攔截所有寄出的信,讓你在瀏覽器裡預覽:
MAIL_MAILER=smtp
MAIL_HOST=localhost
MAIL_PORT=1025
Database 通知:站內訊息
不是每個人都會看 Email。站內訊息(in-app notification)是另一個重要的通知管道。使用者登入後在右上角看到小鈴鐺和紅色數字——這就是 Database 通知的用途。
設定 Notifications Table
php artisan notifications:table
php artisan migrate
這會建立一張 notifications table,結構大概是這樣:
| 欄位 | 型別 | 說明 |
|---|---|---|
| id | uuid | 通知 ID |
| type | string | Notification class 名稱 |
| notifiable_type | string | 被通知的 Model(通常是 User) |
| notifiable_id | bigint | 被通知的 Model ID |
| data | json | toDatabase() 回傳的資料 |
| read_at | timestamp | 已讀時間(null = 未讀) |
| created_at | timestamp | 建立時間 |
讀取與標記已讀
在 User Model 上(透過 Notifiable trait),你可以這樣操作:
// 取得所有通知
$user->notifications;
// 取得未讀通知
$user->unreadNotifications;
// 取得未讀數量
$user->unreadNotifications->count();
// 標記單一通知為已讀
$notification->markAsRead();
// 標記所有通知為已讀
$user->unreadNotifications->markAsRead();
在 Blade 裡顯示通知鈴鐺
一個常見的 UI 實作——導覽列上的通知鈴鐺:
{{-- resources/views/components/notification-bell.blade.php --}}
@auth
<div class="relative" x-data="{ open: false }">
<button @click="open = !open" class="relative p-2">
{{-- 鈴鐺圖示 --}}
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11
a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341
C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436
L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
{{-- 未讀紅點 --}}
@if (auth()->user()->unreadNotifications->count() > 0)
<span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs
rounded-full w-5 h-5 flex items-center justify-center">
{{ auth()->user()->unreadNotifications->count() }}
</span>
@endif
</button>
{{-- 下拉通知列表 --}}
<div x-show="open" @click.away="open = false"
class="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg z-50">
<div class="p-4">
<h3 class="font-semibold mb-2">通知</h3>
@forelse (auth()->user()->notifications()->latest()->take(10)->get() as $notification)
<a href="{{ route('notifications.read', $notification->id) }}"
class="block p-3 rounded hover:bg-gray-50
{{ $notification->read_at ? 'opacity-60' : 'bg-blue-50' }}">
<p class="text-sm">{{ $notification->data['message'] }}</p>
<p class="text-xs text-gray-400 mt-1">
{{ $notification->created_at->diffForHumans() }}
</p>
</a>
@empty
<p class="text-sm text-gray-400">暫無通知</p>
@endforelse
</div>
</div>
</div>
@endauth
對應的 Controller 處理「點擊通知 → 標記已讀 → 跳轉」:
class NotificationController extends Controller
{
public function read(string $id)
{
$notification = auth()->user()
->notifications()
->findOrFail($id);
$notification->markAsRead();
// 根據通知類型跳轉到對應頁面
return match ($notification->data['type'] ?? null) {
'group_buy_confirmed' => redirect()->route(
'group-buys.show',
$notification->data['group_buy_id']
),
default => redirect()->route('dashboard'),
};
}
}
失敗 Job 處理與重試策略
Queue 不是萬能的——Job 會失敗。SMTP Server 掛了、第三方 API 回應逾時、資料庫連線斷了,這些在分散式系統裡是家常便飯。Laravel 提供了完整的失敗處理機制。
failed_jobs Table
前面我們已經建立了 failed_jobs table。當 Job 的重試次數耗盡仍然失敗時,它會被記錄到這張 table,包含 Job 的完整資料和錯誤訊息。
在 Job 裡處理失敗
你可以在 Job class 裡定義 failed() 方法,在 Job 最終失敗時做一些處理:
class ProcessGroupBuyConfirmation implements ShouldQueue
{
// ...
public function failed(\Throwable $exception): void
{
// 通知開發者
Log::critical('團購確認處理失敗', [
'group_buy_id' => $this->groupBuy->id,
'error' => $exception->getMessage(),
]);
// 通知開團主處理異常
$this->groupBuy->organizer->notify(
new ProcessingFailedNotification($this->groupBuy, $exception)
);
}
}
重試失敗的 Job
# 查看所有失敗的 Job
php artisan queue:failed
# 重試特定 Job
php artisan queue:retry <job-id>
# 重試所有失敗的 Job
php artisan queue:retry all
# 刪除特定失敗 Job
php artisan queue:forget <job-id>
# 清空所有失敗 Job
php artisan queue:flush
退避策略的最佳實踐
不要用固定的重試間隔。如果 SMTP Server 掛了,每 5 秒重試一次只會浪費資源。用指數退避(Exponential Backoff):
public function backoff(): array
{
return [10, 60, 300]; // 10 秒、1 分鐘、5 分鐘
}
如果 Job 涉及第三方 API 呼叫,加上 Rate Limiting middleware:
use Illuminate\Queue\Middleware\RateLimited;
public function middleware(): array
{
return [
new RateLimited('external-api'),
];
}
在 AppServiceProvider 裡定義 rate limiter:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('external-api', function (object $job) {
return Limit::perMinute(30);
});
}
Laravel Horizon 簡介
當你的 Queue 規模成長到需要監控時,Laravel Horizon 是官方提供的 Redis Queue 儀表板。它提供:
- 即時監控 Job 的吞吐量和執行時間
- 查看失敗 Job 的詳細錯誤
- 自動調整 worker 數量
- 基於 tag 的 Job 搜尋和過濾
安裝很簡單:
composer require laravel/horizon
php artisan horizon:install
php artisan horizon
然後在瀏覽器開啟 /horizon 就能看到漂亮的監控介面。在「揪好買」正式上線後,Horizon 會是你觀察系統健康狀況的重要工具。不過在開發階段,用 php artisan queue:work 就夠了。
實作:揪好買的通知系統
把前面學到的全部串起來。以下是「揪好買」在團購成團時的完整通知流程:
第一步:定義 Event
// app/Events/GroupBuyConfirmed.php
<?php
namespace App\Events;
use App\Models\GroupBuy;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GroupBuyConfirmed
{
use Dispatchable, SerializesModels;
public function __construct(
public readonly GroupBuy $groupBuy,
) {}
}
第二步:建立三個 Listener
Listener 1:通知所有跟團者
// app/Listeners/SendConfirmationToParticipants.php
<?php
namespace App\Listeners;
use App\Events\GroupBuyConfirmed;
use App\Notifications\GroupBuyConfirmedNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Notification;
class SendConfirmationToParticipants implements ShouldQueue
{
public string $queue = 'notifications';
public function handle(GroupBuyConfirmed $event): void
{
Notification::send(
$event->groupBuy->participants,
new GroupBuyConfirmedNotification($event->groupBuy)
);
}
}
Listener 2:寄摘要報告給開團主
// app/Listeners/SendOrganizerSummary.php
<?php
namespace App\Listeners;
use App\Events\GroupBuyConfirmed;
use App\Notifications\OrganizerSummaryNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendOrganizerSummary implements ShouldQueue
{
public string $queue = 'notifications';
public function handle(GroupBuyConfirmed $event): void
{
$groupBuy = $event->groupBuy;
$groupBuy->organizer->notify(
new OrganizerSummaryNotification($groupBuy)
);
}
}
Listener 3:更新統計資料(同步)
// app/Listeners/UpdateGroupBuyStats.php
<?php
namespace App\Listeners;
use App\Events\GroupBuyConfirmed;
class UpdateGroupBuyStats
{
public function handle(GroupBuyConfirmed $event): void
{
$groupBuy = $event->groupBuy;
$groupBuy->update([
'participant_count' => $groupBuy->participants()->count(),
'total_amount' => $groupBuy->orders()->sum('amount'),
'confirmed_at' => now(),
]);
}
}
第三步:在 Service 裡觸發 Event
// app/Services/GroupBuyService.php
<?php
namespace App\Services;
use App\Events\GroupBuyConfirmed;
use App\Models\GroupBuy;
class GroupBuyService
{
public function confirm(GroupBuy $groupBuy): void
{
// 確認條件檢查
throw_unless(
$groupBuy->canBeConfirmed(),
\DomainException::class,
'此團購不符合成團條件'
);
$groupBuy->update(['status' => 'confirmed']);
// 就這一行,所有後續動作都會自動觸發
event(new GroupBuyConfirmed($groupBuy));
}
}
如果這段被包在 transaction 裡,這個 race condition 會咬你
上面的
confirm()沒包 transaction 還算安全,但實務上你很可能會把「更新狀態 + 寫幾張關聯表」一起包進DB::transaction()。問題來了:Listener 用SerializesModels只存了 model 的 ID,到 worker 端會重新find()一次回 DB 撈。Redis worker 很快,可能在你的 transaction 還沒 commit 的瞬間就把 job 撈走執行了——這時 DB 裡那筆confirmed資料還在你這個連線的交易裡,worker 看不到,直接吃ModelNotFoundException。更陰險的是它不一定每次都中,本機跑沒事、上線高併發才偶發,超難重現。解法是叫 job 等 commit 完再派:dispatch 時接
->afterCommit(),或在config/queue.php對應的 connection 設'after_commit' => true一勞永逸。SerializesModels幫你省記憶體、避免資料過時,代價就是這個時序陷阱,兩件事是同一個機制的一體兩面,得一起記。
整個流程是這樣的:
Controller 呼叫 GroupBuyService::confirm()
├── 更新 status = confirmed(同步)
└── dispatch GroupBuyConfirmed event
├── SendConfirmationToParticipants(Queue → 寄 Email + 存站內訊息)
├── SendOrganizerSummary(Queue → 寄開團主摘要信)
└── UpdateGroupBuyStats(同步 → 更新統計欄位)
第四步:截止提醒通知(Scheduled)
除了成團通知,「揪好買」還需要在截止前提醒跟團者。這個用 Laravel 的 Scheduler 搭配 Notification:
// app/Notifications/GroupBuyDeadlineReminder.php
<?php
namespace App\Notifications;
use App\Models\GroupBuy;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class GroupBuyDeadlineReminder extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly GroupBuy $groupBuy,
) {}
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
$hoursLeft = (int) now()->diffInHours($this->groupBuy->deadline, true); // Carbon 3(Laravel 11/12)的 diffInHours 預設回傳帶正負號的 float,第二參數 $absolute=true 取絕對值並 cast int 確保顯示正確小時數
return (new MailMessage)
->subject("⏰ 團購「{$this->groupBuy->title}」即將截止")
->line("你參加的團購還有 {$hoursLeft} 小時就要截止了。")
->line("目前人數:{$this->groupBuy->participants()->count()} / {$this->groupBuy->min_participants}")
->action('查看團購', route('group-buys.show', $this->groupBuy))
->line('趕快分享給朋友一起揪團吧!');
}
public function toDatabase(object $notifiable): array
{
return [
'group_buy_id' => $this->groupBuy->id,
'group_buy_title' => $this->groupBuy->title,
'message' => "團購「{$this->groupBuy->title}」即將截止",
'type' => 'deadline_reminder',
];
}
}
在 routes/console.php 裡設定排程:
use App\Models\GroupBuy;
use App\Notifications\GroupBuyDeadlineReminder;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
// 找出 24 小時內即將截止的團購
$groupBuys = GroupBuy::where('status', 'open')
->whereBetween('deadline', [now(), now()->addHours(24)])
->whereNull('reminder_sent_at')
->get();
foreach ($groupBuys as $groupBuy) {
Notification::send(
$groupBuy->participants,
new GroupBuyDeadlineReminder($groupBuy)
);
$groupBuy->update(['reminder_sent_at' => now()]);
}
})->hourly()->name('group-buy-deadline-reminders');
啟動 Worker
所有 Queue 裡的 Job 需要一個 worker process 來處理。開發時直接在終端機執行:
# 啟動 worker
php artisan queue:work
# 指定處理特定 queue
php artisan queue:work --queue=notifications,default
# 處理一個 job 後就停止(適合測試)
php artisan queue:work --once
# 設定記憶體限制和超時
php artisan queue:work --memory=256 --timeout=120
注意:
queue:work是長時間執行的 process。在開發時改了 Job 的程式碼,需要重啟 worker 才會載入新的程式碼。可以用php artisan queue:restart優雅地重啟,或者在開發時用queue:listen——它每次都會重新載入程式碼(但效能較差,僅限開發使用)。
在正式環境,你會用 process manager(如 Supervisor)來確保 worker 持續運行:
# /etc/supervisor/conf.d/jiuhaobuy-worker.conf
[program:jiuhaobuy-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/jiuhaobuy/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/jiuhaobuy/storage/logs/worker.log
stopwaitsecs=3600
小結:非同步思維讓應用更健壯
這一章我們學了三個核心概念,各自解決不同的問題:
- Queue——解決「何時做」:把耗時任務丟到背景,使用者不用等。Driver 從開發用的
database到正式環境的redis,切換只需改一行.env。 - Event/Listener——解決「誰做什麼」:用事件驅動的方式解耦業務邏輯。新增需求只要加 Listener,不用改原本的程式碼。搭配
ShouldQueue就能讓 Listener 在背景執行。 - Notification——解決「怎麼通知」:一個 class 搞定 Email、站內訊息、SMS 等多種管道。搭配 Queue 在背景發送,搭配 Database driver 實作站內訊息。
在「揪好買」裡,這三者的組合是:Controller 呼叫 Service → Service dispatch Event → Listener 在背景透過 Notification 發送多管道通知。整個流程清晰、解耦、可擴展。
現在你的後端已經能優雅地處理背景任務和通知了。但你的系統目前只服務網頁使用者。下一章,我們要把揪好買的核心功能包裝成 RESTful API,用 Sanctum 做 Token 認證,讓手機 App 和 LINE Bot 也能開團、跟團、查詢訂單——同一套後端,服務所有人。