~/blog/laravel-resilient-queue-scheduler-design-bible.md
Laravel 與後端開發 · 2025 / 08 / 22

Laravel Queue 不是跑起來就好!「彈性」與「容錯」背景任務設計完整手冊

Eric — 浪花科技創辦人 / AI 架構師
Eric
浪花科技創辦人 · AI 架構師
Laravel Queue 不是跑起來就好!「彈性」與「容錯」背景任務設計完整手冊
目錄 table-of-contents.md

身為一個天天在程式碼海裡打滾的工程師,最怕的不是遇到 Bug,而是遇到那種「神出鬼沒」的 Bug。尤其是在背景任務(Background Jobs)的世界裡,很多問題都是安靜地發生,直到客戶打電話來抱怨:「我上傳的那個報表,等了一小時了怎麼還沒好?」或是行銷同事跑來問:「我們發送的 EDM,為什麼只有一半的人收到?」這時候你才發現,那個你以為正在勤奮工作的 Laravel Queue Worker,早就不知道在什麼時候罷工了。

很多開發者在學習 Laravel 排程與背景任務(Scheduler / Queue) 時,會停留在「把任務丟進隊列,然後執行 `php artisan queue:work`」的階段。是的,它會跑,但這距離一個穩定、可靠的生產環境系統,還有非常非常遠的一段路。這就像你造了一台車,有引擎有輪子,但沒有避震、沒有安全氣囊、更沒有儀表板。這樣的車,你敢開上高速公路嗎?

這篇文章,不是要再教你一次 Queue 的基本功(如果你還不熟,可以先看看我們之前寫的終極指南),而是要帶你深入探討「容錯性 (Fault Tolerance)」與「彈性 (Resilience)」的設計模式。我們要打造的,是一個即使遇到網路抖動、第三方 API 故障、甚至資料庫暫時連不上,都能優雅地處理失敗、自動重試,並在最後留下完整紀錄的強健系統。喝口咖啡,我們來聊點硬核的。

為什麼「能動」的 Queue 遠遠不夠?從一個血淋淋的案例談起

想像一個情境:你的電商網站有個功能,當訂單完成付款後,會觸發一個 Job,這個 Job 的工作是:

  • 1. 呼叫物流 API 產生出貨單。
  • 2. 呼叫金流 API 核銷款項。
  • 3. 產生 PDF 發票。
  • 4. 發送一封包含發票的 Email 通知給客戶。

看起來很合理,對吧?但如果今天在執行第 1 步時,物流公司的 API 剛好在維護,回傳了一個 503 錯誤,會發生什麼事?如果你的 Job 沒有任何錯誤處理,它會直接失敗。客戶付了錢,卻沒有出貨單、沒有核銷、沒有發票、沒收到通知信。更糟的是,你可能根本不知道這件事發生了,直到客服接到客訴電話。

這就是為什麼我們需要更進階的設計思維:

  • 容錯性 (Fault Tolerance): 系統在部分元件(例如外部 API)失效時,仍能繼續運作或優雅降級的能力。以上述案例來說,至少要能重試,或記錄下失敗的任務供人工處理。
  • 冪等性 (Idempotency): 一個操作執行一次或執行 N 次,結果都應該是相同的。這在可以「重試」的系統中至關重要,我們總不希望重試時,重複產生了另一張出貨單或多扣了一次款。
  • 可觀測性 (Observability): 你必須能夠清楚地知道你的 Queue 系統現在的狀態。有多少任務在排隊?有多少任務失敗了?它們為什麼失敗?

打造金剛不壞之身:Laravel Queue 的容錯設計模式

好,理論講完了,我們來動手。底下是我在多年實戰中,總結出來的幾個核心設計模式,能大幅提升你的背景任務穩定性。

模式一:選擇對的戰場 - Database vs. Redis Queue Driver

Laravel 預設的 Queue Driver 是 `sync`,也就是同步執行,這在開發時很方便,但生產環境等於沒用。最多人接著會選 `database`,因為它最簡單,只需要一張資料表。但這其實是個甜蜜的陷阱。

`database` driver 在高併發下,會有效能瓶頸跟資料庫鎖 (Lock) 的問題。當多個 Worker 同時想從資料庫抓取任務時,很容易互相卡住。講白了,它適合流量不大的應用,但當你的業務起飛,它會是第一個拖垮你的地方。

工程師的囉嗦建議:只要條件允許,請直接使用 `redis` 作為你的 Queue Driver。Redis 是基於記憶體的 Key-Value 資料庫,操作是原子性的 (Atomic),速度快到不可思議,完美符合 Queue 系統高頻讀寫的需求。想深入了解 Redis 如何為 Laravel 提速?可以參考這篇《Laravel 效能卡關?Redis 就是你的神兵利器!》

模式二:不怕重來一次 - 設計「冪等性 (Idempotent)」的 Job

這是最重要,也最常被忽略的一點。既然任務可能失敗重試,你就必須假設你的 Job `handle()` 方法會被執行很多次。如何確保執行很多次,結果跟執行一次一樣?答案是在執行核心邏輯前,先做「狀態檢查」。

我們改寫一下剛剛那個訂單處理的 Job:

<?php

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ProcessOrder implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
    }

    public function handle()
    {
        // 冪等性檢查:如果物流單號已存在,就不要再重複呼叫 API
        if (is_null($this->order->shipping_tracking_code)) {
            // 呼叫物流 API...
            $trackingCode = LogisiticsService::createShipment($this->order);
            $this->order->update(['shipping_tracking_code' => $trackingCode]);
        }

        // 冪等性檢查:如果款項已核銷,就跳過
        if (!$this->order->is_payment_captured) {
            // 呼叫金流 API...
            PaymentService::capturePayment($this->order);
            $this->order->update(['is_payment_captured' => true]);
        }

        // ... 其他邏輯以此類推
    }
}

看到關鍵了嗎?我們在每一個敏感操作前,都先檢查訂單的狀態。這樣一來,即使這個 Job 因為網路問題失敗重試,已經完成的步驟也不會再被執行一次,完美避免了重複操作的災難。

模式三:給它一次機會(或很多次)- 精通重試與超時機制

Laravel 提供了非常方便的屬性來控制 Job 的重試與超時行為。

  • public $tries = 5;: 指定這個 Job 最多可以嘗試執行 5 次。
  • public $timeout = 120;: 指定這個 Job 的執行時間上限為 120 秒,超過就會被視為失敗。
  • public function backoff() { return [1, 5, 10]; }: 這招更厲害,叫做「指數退讓」。它指定了第一次重試延遲 1 秒,第二次延遲 5 秒,第三次延遲 10 秒。這對於應付暫時性的服務不穩(例如 API 流量管制)非常有效,避免在短時間內瘋狂重試把對方服務打掛。
<?php

namespace App\Jobs;

// ... 其他 use

class ProcessOrder implements ShouldQueue
{
    // ...

    // 最多嘗試 5 次
    public $tries = 5;

    // 每次執行最長 120 秒
    public $timeout = 120;

    // 重試的延遲秒數
    public function backoff()
    {
        return [60, 300, 600]; // 第一次失敗後等 1 分鐘,第二次 5 分鐘,第三次 10 分鐘
    }

    public function handle()
    {
        // ... 你的 Job 邏輯
    }
}

模式四:建立安寧病房 - 善用 Failed Jobs Table

如果一個 Job 試了 5 次(或你設定的次數)後還是失敗了,它會去哪?Laravel 會把它丟進 `failed_jobs` 資料表裡。這就是你的安寧病房,所有搶救無效的 Job 都會被記錄在這裡,包含它的 payload、錯誤訊息等。

首先,你得先建立這張表:

php artisan queue:failed-table
php artisan migrate

之後,你就可以透過指令來管理這些失敗的任務:

  • php artisan queue:failed: 列出所有失敗的任務。
  • php artisan queue:retry [uuid]: 重試某個指定的失敗任務。
  • php artisan queue:flush: 刪除所有失敗的任務紀錄。

養成定期檢查 Failed Jobs Table 的習慣,是維持系統健康的重要一環。很多時候,你會從這裡發現一些潛在的系統問題。

進階戰術:串起複雜工作流的藝術

當你的應用越來越複雜,單一的 Job 可能無法滿足需求。你需要的是一個能互相協調、串連的「工作流」。

工作流的交響樂 - Job Chaining 與 Batching

Laravel 的 `Bus` Facade 提供了強大的工作流編排工具:

  • Chaining (鏈式): 當你需要一系列任務依序執行時使用。例如:`下載報表` -> `壓縮報表` -> `上傳到 S3` -> `發送下載連結`。只要中間有一個失敗,整個鏈就會中斷。
  • Batching (批次): 當你需要並行處理大量任務,並在全部完成後做某件事時使用。例如:處理 1000 張使用者上傳的圖片,每張圖片是一個 Job,當 1000 張都處理完畢後,發送一個總結通知。

這讓你可以將巨大、複雜的任務,拆解成一個個小而美的獨立 Job,大幅提升程式碼的可維護性與可靠性。

維運的最後一哩路:監控與部署

程式碼寫得再好,如果部署跟監控沒做好,一切都是白搭。

你的 Queue Worker 需要一位工頭:Supervisor

千萬、千萬不要在你的正式環境伺服器上,只用 `php artisan queue:work &` 來啟動 Worker。這種方式只要 SSH 連線一斷,或程式噴出一個致命錯誤,你的 Worker 就會跟著死亡,而且不會自動重啟。

你需要一個程序監控工具,例如 Supervisor。它的作用就像一個盡責的工頭,會持續監視你的 Worker 進程,如果發現它掛了,會立刻重新啟動一個新的,確保你的隊列永遠有人在處理。

一個基本的 Supervisor 設定檔可能長這樣:

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /path/to/your/project/artisan queue:work redis --sleep=3 --tries=3
autostart=true
autorestart=true
user=your-user
numprocs=8
redirect_stderr=true
stdout_logfile=/path/to/your/project/storage/logs/worker.log

這個設定檔告訴 Supervisor,我要啟動 8 個 Worker 進程,如果它們死了,要自動重啟,並將所有日誌輸出到指定的檔案。這才是生產環境該有的樣子。

(選配) 豪華儀表板:Laravel Horizon

如果你的專案大量使用 Redis Queue,那 Laravel Horizon 就是你的神器。它提供了一個精美的儀表板,讓你即時監控隊列的吞吐量、任務等待時間、失敗任務,還可以直接在介面上重試任務。它甚至能自動平衡不同隊列的 Worker 數量。安裝 Horizon 能讓你的 Queue 可觀測性提升好幾個檔次。

走到這裡,你應該已經發現,Laravel 的排程與背景任務系統,遠比想像中更深、更強大。從單純地把任務丟進隊列,到設計出一套具備容錯、冪等性、可觀測性的強健系統,這中間的差距,就是資深與初階工程師的區別。希望今天的分享,能幫助你打造出更穩定、更可靠的應用程式。記住,好的系統不是不會出錯,而是在出錯時,有能力優雅地恢復。

相關資源與延伸閱讀

如果你正在打造複雜的 WordPress 或 Laravel 應用,並且遇到了棘手的架構問題或效能瓶頸,別單打獨鬥了。浪花科技的團隊擁有豐富的實戰經驗,能幫助你從架構設計、開發實作到後期維運,打造出企業級的強健系統。歡迎點擊這裡,填寫表單與我們聊聊,讓我們一起把你的想法變成現實。

// FAQ

常見問題

Laravel Queue 應該選 database 還是 redis 作為 driver?
建議盡量使用 redis。database driver 雖然只需一張資料表、設定最簡單,但在高併發下會有效能瓶頸與資料庫鎖的問題,多個 Worker 同時抓取任務時容易互相卡住。Redis 是記憶體資料庫、操作具原子性、速度快,較符合 Queue 高頻讀寫需求。
什麼是冪等性(Idempotency),背景任務為什麼需要它?
冪等性指一個操作執行一次或執行 N 次,結果都相同。由於可重試的 Job handle() 方法可能被執行多次,必須在每個敏感操作前先做狀態檢查(例如物流單號已存在就不再呼叫 API、款項已核銷就跳過),確保重試時不會重複出貨或重複扣款。
Laravel Job 的 $tries、$timeout、backoff 各自控制什麼?
public $tries 設定 Job 最多嘗試執行的次數;public $timeout 設定單次執行的秒數上限,超過視為失敗;backoff() 方法回傳每次重試的延遲秒數陣列(例如 [60, 300, 600]),實現指數退讓,避免短時間瘋狂重試把對方服務打掛。
Job 重試多次後仍失敗會發生什麼事?
當 Job 超過設定的嘗試次數仍失敗,Laravel 會把它記錄到 failed_jobs 資料表,含 payload 與錯誤訊息。需先以 php artisan queue:failed-table 與 migrate 建立此表,之後可用 queue:failed 列出、queue:retry [uuid] 重試指定任務、queue:flush 清除所有失敗紀錄。
~/roamer-tech/newsletter // FREE
// newsletter

訂閱免費電子報

把 AI 自動化、企業系統設計與 WordPress / Laravel 開發的真實案例和可直接照做的技巧,整理成電子報寄給你。只寄精選內容、不灌垃圾信,一鍵就能退訂。

$
// final.exec()

準備好讓你的網站開始為你工作了嗎?