iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Modern Web

我推的Laravel S2!系列 第 20

我推的Laravel S2|Day 19:Queue 佇列:Driver、Job、Worker、Queue::route()

  • 分享至 

  • xImage
  •  

一句話破題

blog-app 如果之後要在文章發布時寄送通知信給訂閱者,這種「不需要讓使用者等待、可以背景處理」的工作,就是 Queue 佇列系統要解決的問題。今天把 Queue 從頭整理一遍,核心概念完全沒過時,額外補上 Laravel 13 新增的集中式佇列路由與 Job 專用 PHP Attribute。

Driver:佇列實際存在哪裡

config/queue.phpdefault 決定預設驅動:

Driver 說明
sync 同步執行,不真的排隊,適合本機開發快速測試(dispatch 出去立刻原地執行完)
database 用資料庫的一張表存放待處理工作,不需要額外服務,Laravel 11 起新專案的預設值
redis 用 Redis 存放,效能較好,適合正式環境有一定流量的專案
sqs Amazon SQS,適合已經在用 AWS 生態的專案
null 直接丟棄工作(測試用)

blog-app 延續 Laravel 11 起的新專案預設,用 database 驅動即可應付這系列的示範需求:

php artisan queue:table
php artisan migrate

建立 Job

php artisan make:job SendPostPublishedNotification
// app/Jobs/SendPostPublishedNotification.php
class SendPostPublishedNotification implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Post $post,
    ) {}

    public function handle(): void
    {
        foreach ($this->post->author->subscribers as $subscriber) {
            Mail::to($subscriber)->send(new PostPublishedMail($this->post));
        }
    }
}

派送工作:

SendPostPublishedNotification::dispatch($post);

SerializesModels trait 讓 Job 可以安全地把 Eloquent Model 序列化進佇列(存的是 ID,執行時重新從資料庫查出來,不是把整個物件內容存進佇列),這是為什麼建構子可以直接接受 Post $post 而不用擔心資料過期或體積過大。

Queue 基本架構:Controller 呼叫 dispatch() 後立即返回,Job 序列化存進 Queue 儲存,由常駐的 Worker 行程取出執行,失敗則進入重試或 failed_jobs

Worker:真正執行工作的行程

php artisan queue:work              # 持續監聽並處理佇列
php artisan queue:work --queue=high,default   # 指定優先處理的佇列,逗號分隔、由左到右優先
php artisan queue:work --tries=3    # 失敗重試次數
php artisan queue:listen            # 每次都重新載入程式碼,方便開發除錯(效能較差,正式環境不建議)

queue:work 啟動後會常駐執行,程式碼有更新時不會自動套用(因為 worker 是把類別載入記憶體常駐執行),部署新版本後記得執行 php artisan queue:restart 讓 worker 重新啟動、載入新程式碼。

Job Middleware:處理併發與節流

use Illuminate\Queue\Middleware\WithoutOverlapping;

class SendPostPublishedNotification implements ShouldQueue
{
    public function middleware(): array
    {
        return [
            (new WithoutOverlapping($this->post->id))->releaseAfter(60),
        ];
    }
}

WithoutOverlapping 確保同一個 $post->id 不會有兩個相同 Job 同時執行(避免同一篇文章的通知信被重複寄送好幾份),這是處理併發情境很實用的內建 Middleware。

其他內建的 Job Middleware

除了 WithoutOverlapping,還有幾個常用的內建 Job Middleware 值得認識:

use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;

public function middleware(): array
{
    return [
        // 限制這個 Job 的執行頻率(例如呼叫外部 API 有速率限制)
        new RateLimited('post-notifications'),

        // 這個 Job 連續失敗達到門檻後,暫停一段時間再繼續嘗試,避免對外部服務造成持續壓力
        (new ThrottlesExceptions(3, 5))->backoff(30),

        // 如果這個 Job 屬於一個已被取消的批次(下面會講到 Batch),直接跳過不執行
        new SkipIfBatchCancelled(),
    ];
}

RateLimited 搭配的限流規則跟 Day 7 的 RateLimiter::for() 是同一套機制,只是套用的對象從「HTTP 請求」換成「Job 執行」——如果 blog-app 呼叫的第三方通知服務有「每分鐘最多幾次呼叫」的限制,這個 Middleware 能確保你的 Worker 不會因為衝太快而被對方封鎖。

派送方式的進階用法

SendPostPublishedNotification::dispatch($post)->onQueue('notifications');   // 指定佇列名稱
SendPostPublishedNotification::dispatch($post)->delay(now()->addMinutes(5)); // 延遲執行
SendPostPublishedNotification::dispatchIf($post->author->wantsNotifications(), $post); // 條件派送
SendPostPublishedNotification::dispatchAfterResponse($post); // 等 HTTP 回應送出後才執行,使用者不用等

Job Chaining 與 Batches:多個工作的組合

鏈式派送:一個接一個依序執行

如果幾個 Job 必須按照特定順序執行,而且前一個失敗就不該繼續執行後面的:

Bus::chain([
    new GeneratePostThumbnail($post),
    new OptimizePostThumbnail($post),
    new SendPostPublishedNotification($post),
])->dispatch();

鏈裡的 Job 會依序執行,只要有一個失敗,後面的都不會被執行——這對「產生縮圖 → 壓縮縮圖 → 才發通知」這種有明確前後依賴關係的流程很合適。

批次派送:一組工作、關心整體完成狀態

如果你有一批獨立、不需要互相依賴順序的 Job,但想知道「整批什麼時候全部做完」,用 Batch:

$batch = Bus::batch(
    $post->author->subscribers->map(fn ($subscriber) => new SendSubscriberNotification($subscriber, $post))
)->then(function (Batch $batch) {
    Log::info('所有訂閱者通知都已送出');
})->catch(function (Batch $batch, Throwable $e) {
    Log::error('批次通知過程中有工作失敗', ['error' => $e->getMessage()]);
})->finally(function (Batch $batch) {
    // 不管成功失敗都會執行
})->dispatch();

Chain 依序執行、任一失敗後面全部不跑;Batch 讓多個互不依賴的 Job 並行處理,並提供 then/catch/finally 追蹤整批完成狀態

Batch 適合 blog-app 這種「一篇文章要發給幾百個訂閱者」的情境——每個訂閱者的通知是獨立的 Job(互相不依賴,也不需要照順序),但你希望能追蹤「這批通知整體的完成進度」,甚至在使用者介面顯示一個進度條($batch->progress())。Batch 需要先執行 php artisan queue:batches-table && php artisan migrate 建立對應的資料表。

Laravel 13:Queue::route() 集中式佇列路由

以前如果想讓不同 Job 送到不同的 connection/queue,通常會在每個 dispatch() 呼叫時個別指定 ->onQueue(),或在 Job 類別裡寫死屬性。Laravel 13 新增 Queue::route(),讓你在一個地方集中定義「哪個 Job 類別該送去哪個 connection/queue」:

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Queue;

public function boot(): void
{
    Queue::route(SendPostPublishedNotification::class, connection: 'redis', queue: 'notifications');
}

定義好之後,之後任何地方 SendPostPublishedNotification::dispatch($post) 都會自動送去 redis 連線的 notifications 佇列,不需要每次呼叫都手動指定,也不用擔心某個地方忘記加 ->onQueue() 而送錯佇列——對於 Job 種類多、佇列拓樸複雜的大型專案,這讓路由規則變成單一事實來源,比散落在各處的 ->onQueue() 呼叫好維護。

Laravel 13:Job 專用 PHP Attribute

Job 的重試次數、逾時時間,傳統上用 public 屬性設定:

// 傳統寫法(依然可用)
class SendPostPublishedNotification implements ShouldQueue
{
    public $tries = 3;
    public $backoff = 30;
    public $timeout = 60;
}

Laravel 13 把這類設定補上了一整組對應的 PHP Attribute,官方 Queue 文件目前列出的有這些:

Attribute 對應的舊寫法 用途
#[Tries(5)] public $tries 最多嘗試幾次
#[Timeout(120)] public $timeout 單次執行逾時秒數
#[FailOnTimeout] public $failOnTimeout 逾時就直接視為失敗
#[MaxExceptions(3)] public $maxExceptions 最多容許幾次例外
#[UniqueFor(3600)] public $uniqueFor 唯一性鎖的保留秒數
#[DebounceFor(30)] (無) 在指定秒數內把重複派送合併成一次
#[WithoutRelations] #[WithoutRelations] 序列化 Model 時不連帶序列化關聯
use Illuminate\Queue\Attributes\FailOnTimeout;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Tries(3)]
#[Timeout(60)]
#[FailOnTimeout]
#[MaxExceptions(2)]
class SendPostPublishedNotification implements ShouldQueue
{
    // ...
}

Attribute 寫法的好處跟 Day 9 提到的 #[ObservedBy] 是同一個道理:設定資訊直接標註在類別定義上,閱讀類別時一眼就能看到這個 Job 的重試策略,不需要往下捲動找屬性宣告。

這裡有個文件本身不一致的地方值得提醒(跟 Day 25 會遇到的 withSchedule() 情況類似):Laravel 13 的 Release Notes 在介紹「擴充的 PHP Attributes」時,把 #[Backoff]#[Tries]#[Timeout]#[FailOnTimeout] 並列,但 Queue 文件頁的 Attribute 清單裡目前找不到 #[Backoff] 的說明與範例。遇到這種官方文件兩處說法對不上的情況,最可靠的做法一樣是以你的 IDE 自動完成、或直接翻 Illuminate\Queue\Attributes 命名空間底下實際存在的類別為準,不要硬記任何一份文件的說法。下面示範的 backoff() 方法寫法則是確定可用的。

動態重試間隔:遞增式退避

固定的 $backoff = 30 代表每次重試都間隔 30 秒,但更常見的策略是「遞增式退避」(exponential backoff)——第一次失敗等 10 秒、第二次等 30 秒、第三次等 60 秒,避免對暫時性故障的外部服務造成持續壓力:

class SendPostPublishedNotification implements ShouldQueue
{
    public function backoff(): array
    {
        return [10, 30, 60];
    }
}

backoff() 方法回傳的陣列對應每一次重試各自要等待的秒數,比單一固定值更貼近真實世界「暫時性故障通常會自己恢復,給它一點時間比一直猛敲更有效」的處理邏輯。

唯一性 Job:避免重複排入相同工作

WithoutOverlapping 解決的是「不要同時執行」,如果你想解決的是「不要重複排入佇列」(例如使用者手滑連點兩次「發布」按鈕,不該產生兩個一模一樣待處理的通知 Job),用 ShouldBeUnique 介面:

use Illuminate\Queue\Attributes\UniqueFor;

#[UniqueFor(3600)]   // 這個「唯一」的保護,最多維持一小時
class SendPostPublishedNotification implements ShouldQueue, ShouldBeUnique
{
    public function uniqueId(): string
    {
        return (string) $this->post->id;
    }
}

#[UniqueFor(3600)] 就是前面表格提到的 Attribute 寫法,等同舊的 public $uniqueFor = 3600;;兩種都能用,這裡採 Attribute 是為了跟本系列一貫的風格一致。)

只要相同 uniqueId() 的 Job 還在佇列裡等待處理(尚未完成),新的 dispatch() 呼叫會被直接忽略,不會產生第二筆重複的待處理工作。這個機制需要用支援原子鎖的快取驅動(redis/memcached/database),確保多個 Worker 行程同時檢查時不會產生競爭條件。

失敗的作業

php artisan queue:failed-table && php artisan migrate  # 建立 failed_jobs 資料表
php artisan queue:failed        # 查看失敗清單
php artisan queue:retry 5       # 重試指定 ID 的失敗作業
php artisan queue:retry all     # 全部重試
php artisan queue:forget 5      # 刪除指定失敗紀錄
php artisan queue:flush         # 清空所有失敗紀錄

Job 類別也可以定義 failed() 方法,在所有重試次數都用完、最終確定失敗時執行額外的收尾邏輯:

class SendPostPublishedNotification implements ShouldQueue
{
    public function failed(Throwable $exception): void
    {
        Log::error('文章發布通知最終失敗', [
            'post_id' => $this->post->id,
            'error' => $exception->getMessage(),
        ]);

        // 例如通知作者「訂閱者通知信寄送失敗,請檢查」
    }
}

測試 Queue 相關邏輯

Day 27 會深入測試主題,這裡先提一個跟今天內容直接相關的技巧:測試「某個操作有沒有正確派送 Job」,不需要真的讓 Job 執行:

use Illuminate\Support\Facades\Queue;

test('文章發布時會派送通知 Job', function () {
    Queue::fake();

    $post = Post::factory()->create(['published_at' => null]);
    $post->publish();   // 假設 Post Model 有這個方法

    Queue::assertPushed(SendPostPublishedNotification::class, function ($job) use ($post) {
        return $job->post->id === $post->id;
    });
});

Queue::fake() 讓所有 dispatch() 呼叫都被攔截記錄下來,不會真的送進佇列驅動、也不會真的執行——測試只需要驗證「有沒有正確地想要派送這個 Job」,Job 本身的邏輯是否正確,應該用另一個獨立的測試直接呼叫 handle() 驗證,兩者關注點分開會讓測試意圖更清楚。

程序管理工具:讓 Worker 不會意外停掉

queue:work 是一個會持續運作的行程,正式環境需要程序管理工具確保它意外中斷後能自動重啟:

  • Supervisor(Linux/Mac):寫一個 .conf 設定檔指定要監控的指令、行程數量、自動重啟策略。
  • PM2(跨平台,Node.js 生態常用但通用於任何行程):用 pm2.config.js 定義要管理的行程。

兩者選擇主要看你的部署環境慣用哪一套,效果上都是「行程掛掉自動重啟、可以指定要跑幾個平行 worker」。

如果 blog-app 的佇列規模成長到需要更精細的監控(多個佇列、多台 Worker 伺服器、想看視覺化的儀表板),官方套件 Laravel Horizon(限 Redis 驅動)提供一個完整的 Web 儀表板,能即時看到吞吐量、失敗率、各佇列的工作堆積狀況,比自己土法煉鋼看 log 檔案有效率得多,這系列不深入 Horizon 的安裝設定,但值得知道有這個選項存在。

常見錯誤與踩雷點

  • 部署新版本後忘記 queue:restart:Worker 是常駐行程,不會自動載入新程式碼,這是最常見、也最容易讓人一頭霧水的踩雷點(「我明明改了程式碼,為什麼 Job 還是執行舊邏輯?」)。
  • 在 Job 建構子裡塞進大量資料,而不是傳 Model 的 ID/實例讓框架序列化:Job 會被序列化存進佇列(資料庫或 Redis),塞進不必要的大型資料會讓佇列儲存空間暴增,善用 SerializesModels 讓 Eloquent Model 自動只存 ID。
  • 本機開發用 sync 驅動測試「非同步」行為,結果邏輯其實是同步執行sync 驅動下 dispatch() 會立即原地執行完,如果你想確認「派送後使用者不用等」這種非同步特性,開發環境也要切到 database 或其他真正非同步的驅動才能驗證。
  • Job Chain 裡某個 Job 失敗,卻不知道為什麼後面的都沒執行:這是鏈式派送的預期行為(一個失敗、後面全部不執行),如果你需要「即使某個環節失敗,其餘的還是要跑」,代表這些工作之間其實沒有真正的順序依賴關係,應該改用 Batch 而不是 Chain。
  • 忘記 ShouldBeUnique 的鎖需要支援原子操作的快取驅動:如果快取驅動設定成 filearray 這類不支援分散式鎖的驅動,唯一性保護可能無法在多台 Worker 伺服器之間正確生效。

小結

Queue 的核心概念——Driver、Job、Worker、失敗重試——這幾年維持穩定,今天除了補上 Queue::route()、Job PHP Attribute 這兩個 Laravel 13 更新,也深入了 Job Chaining、Batch、唯一性 Job、動態退避策略這幾個實務上處理複雜背景工作流程時會用到的進階工具。

一鼓作氣,再而衰,三而竭 — 《左傳・莊公十年》

明日預告

Day 20 進入多語系 Localization,核心 API 沒有版本差異,但「Session + Middleware 持久化語系」這個經典範例要對照 Day 16 的新版中介層寫法改寫。


上一篇
我推的Laravel S2|Day 18:Exception 例外處理:bootstrap/app.php 的新寫法
下一篇
我推的Laravel S2|Day 20:多語系 Localization
系列文
我推的Laravel S2!22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言