技術 6 分鐘閱讀

Laravel Queue 與背景任務:讓使用者不再等待

Laravel Queue 與背景任務:讓使用者不再等待

前言

使用者點了「送出」按鈕後要等 10 秒才看到結果——這種體驗誰都受不了。很多耗時的操作(寄信、圖片處理、PDF 生成)其實不需要讓使用者等待,放到背景處理就好。Laravel Queue 就是解決這個問題的利器。

Queue 的基本概念

Queue(佇列)就像一個待辦清單:

  1. 你把要做的事(Job)丟進佇列
  2. Worker 在背景一個一個把事情做完
  3. 使用者不需要等待

設定 Queue Driver

# .env
QUEUE_CONNECTION=redis

Redis 是最推薦的選擇。開發環境如果沒有 Redis,可以用 database

QUEUE_CONNECTION=database

如果用 database driver,需要建立 jobs 表:

php artisan queue:table
php artisan migrate

建立 Job

php artisan make:job SendWelcomeEmail
// app/Jobs/SendWelcomeEmail.php
class SendWelcomeEmail implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public User $user
    ) {}

    public function handle(): void
    {
        Mail::to($this->user->email)->send(
            new WelcomeMail($this->user)
        );
    }
}

派發 Job

// 在 Controller 中
public function register(Request $request)
{
    $user = User::create($request->validated());

    // 把寄信任務丟到佇列
    SendWelcomeEmail::dispatch($user);

    return response()->json([
        'message' => '註冊成功!歡迎信將在稍後寄出。',
        'user' => $user,
    ]);
}

使用者立即看到「註冊成功」,不需要等信寄完。

實際應用場景

圖片處理

class ProcessProductImage implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public Product $product,
        public string $imagePath
    ) {}

    public function handle(): void
    {
        // 產生不同尺寸的縮圖
        $sizes = [
            'thumb' => [150, 150],
            'medium' => [600, 600],
            'large' => [1200, 1200],
        ];

        foreach ($sizes as $name => [$width, $height]) {
            $resized = Image::make(storage_path("app/{$this->imagePath}"))
                ->fit($width, $height)
                ->encode('webp', 80);

            $filename = pathinfo($this->imagePath, PATHINFO_FILENAME);
            Storage::put("products/{$name}/{$filename}.webp", $resized);
        }

        // 更新資料庫
        $this->product->update([
            'images_processed' => true,
        ]);
    }
}

PDF 報表生成

class GenerateMonthlyReport implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public int $year,
        public int $month,
        public User $requestedBy
    ) {}

    public function handle(): void
    {
        $orders = Order::whereYear('created_at', $this->year)
            ->whereMonth('created_at', $this->month)
            ->with('items')
            ->get();

        $pdf = Pdf::loadView('reports.monthly', [
            'orders' => $orders,
            'year' => $this->year,
            'month' => $this->month,
        ]);

        $filename = "report-{$this->year}-{$this->month}.pdf";
        Storage::put("reports/{$filename}", $pdf->output());

        // 通知使用者報表已生成
        $this->requestedBy->notify(
            new ReportReadyNotification($filename)
        );
    }
}

第三方 API 同步

class SyncInventoryToERP implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $backoff = 60; // 失敗後等 60 秒再重試

    public function __construct(
        public Product $product
    ) {}

    public function handle(): void
    {
        $response = Http::timeout(30)->post('https://erp.example.com/api/inventory', [
            'sku' => $this->product->sku,
            'quantity' => $this->product->stock,
            'price' => $this->product->price,
        ]);

        if (!$response->successful()) {
            throw new \Exception("ERP 同步失敗:{$response->status()}");
        }

        $this->product->update(['erp_synced_at' => now()]);
    }
}

錯誤處理

重試機制

class ImportProducts implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;           // 最多重試 3 次
    public int $maxExceptions = 2;    // 最多允許 2 次例外
    public int $timeout = 120;        // 超時 120 秒

    // 漸進式延遲重試
    public function backoff(): array
    {
        return [10, 30, 60]; // 10秒、30秒、60秒
    }

    public function handle(): void
    {
        // 處理邏輯
    }

    // Job 最終失敗時的處理
    public function failed(\Throwable $exception): void
    {
        Log::error('商品匯入失敗', [
            'error' => $exception->getMessage(),
        ]);

        // 通知管理員
        Notification::route('mail', 'admin@example.com')
            ->notify(new JobFailedNotification($exception));
    }
}

手動失敗處理

public function handle(): void
{
    $data = $this->fetchExternalData();

    if (empty($data)) {
        // 手動標記失敗,不再重試
        $this->fail(new \Exception('外部資料為空'));
        return;
    }

    // 繼續處理
}

Job Chain 與 Batch

Job Chain:依序執行

Bus::chain([
    new ValidateImportFile($file),
    new ImportProducts($file),
    new SendImportCompleteNotification($user),
])->dispatch();

Job Batch:並行執行

$jobs = $products->map(function ($product) {
    return new UpdateProductPrice($product);
});

Bus::batch($jobs)
    ->then(function (Batch $batch) {
        // 全部完成
        Log::info("批次更新完成,共 {$batch->totalJobs} 個任務");
    })
    ->catch(function (Batch $batch, \Throwable $e) {
        // 有任務失敗
        Log::error("批次更新有錯誤:{$e->getMessage()}");
    })
    ->dispatch();

Queue 指定佇列

// 把不同優先度的任務放到不同佇列
SendWelcomeEmail::dispatch($user)->onQueue('emails');
ProcessProductImage::dispatch($product, $path)->onQueue('images');
GenerateMonthlyReport::dispatch($year, $month, $user)->onQueue('reports');

啟動 Worker 時指定處理順序:

# 先處理 emails,再處理 images 和 reports
php artisan queue:work --queue=emails,images,reports

部署與監控

Supervisor 設定

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=2
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/worker.log

Laravel Horizon

如果使用 Redis,強烈推薦安裝 Horizon:

composer require laravel/horizon
php artisan horizon:install
php artisan horizon

Horizon 提供漂亮的儀表板,可以監控佇列的狀態、失敗的任務、處理速度等。

結語

Queue 是 Laravel 最強大的功能之一。善用它可以大幅提升使用者體驗,也能讓系統架構更靈活。從最簡單的寄信開始用,慢慢你會發現越來越多場景可以用 Queue 來處理。

#Laravel #Queue #Redis #後端開發
Thousand Lab

Thousand Lab