کاربر روی «ثبت سفارش» کلیک می‌کند و چهار ثانیه به یک چرخ‌دنده‌ی در حال چرخش نگاه می‌کند. در این چهار ثانیه سرور فاکتور PDF می‌سازد، ایمیل می‌فرستد، به یک سرویس پیامک وصل می‌شود و شاید چیزی هم به سیستم حسابداری خبر می‌دهد. هیچ‌کدام از این‌ها لازم نیست قبل از پاسخ به کاربر تمام شود.

صف (queue) در لاراول دقیقاً برای همین است: درخواست کار اصلی را انجام می‌دهد، کارهای کند را در صف می‌گذارد و فوراً پاسخ می‌دهد. یک پردازش جداگانه به نام worker آن کارها را در پس‌زمینه برمی‌دارد و اجرا می‌کند.

در این مقاله از ساخت اولین Job تا اجرای پایدار worker در production را با لاراول ۱۳ مرور می‌کنیم.

چه کاری را به صف بفرستیم

هر کاری که این سه ویژگی را دارد کاندیدای خوبی است:

  • کند است یا زمانش قابل پیش‌بینی نیست؛ مثل ارسال ایمیل و پیامک، ساخت PDF، پردازش تصویر و فراخوانی API بیرونی.
  • کاربر منتظر نتیجه‌اش نیست؛ کافی است بداند کار ثبت شده است.
  • ممکن است شکست بخورد و باید دوباره امتحان شود؛ سرویس پیامک گاهی جواب نمی‌دهد و این نباید باعث شکست ثبت سفارش شود.

در مقابل، اعتبارسنجی فرم، ذخیره‌ی خود سفارش و هر چیزی که پاسخ صفحه به آن وابسته است باید در همان درخواست بماند.

ساخت یک Job

با دستور زیر یک کلاس Job ساخته می‌شود:

bash
php artisan make:job SendInvoice

و این نسخه‌ی کامل‌شده‌ی آن است:

app/Jobs/SendInvoice.php
<?php

namespace App\Jobs;

use App\Mail\InvoiceMail;
use App\Models\Order;
use App\Services\InvoicePdf;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;

class SendInvoice implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    /** حداکثر تعداد تلاش */
    public int $tries = 5;

    /** حداکثر زمان اجرای هر تلاش، به ثانیه */
    public int $timeout = 120;

    /** قفل یکتایی حداکثر یک ساعت نگه داشته می‌شود */
    public int $uniqueFor = 3600;

    public function __construct(public Order $order)
    {
    }

    /** فاصله‌ی بین تلاش‌ها: ۱۰ ثانیه، ۱ دقیقه، ۵ دقیقه، ۱۵ دقیقه */
    public function backoff(): array
    {
        return [10, 60, 300, 900];
    }

    public function uniqueId(): string
    {
        return (string) $this->order->id;
    }

    public function handle(InvoicePdf $pdf): void
    {
        // اگر قبلاً فرستاده شده، کاری نکن (idempotency)
        if ($this->order->invoice_sent_at !== null) {
            return;
        }

        $path = $pdf->generate($this->order);

        Mail::to($this->order->email)->send(new InvoiceMail($this->order, $path));

        $this->order->forceFill(['invoice_sent_at' => now()])->save();
    }

    public function failed(?Throwable $exception): void
    {
        Log::error('Sending invoice failed', [
            'order_id' => $this->order->id,
            'error' => $exception?->getMessage(),
        ]);
    }
}

چند نکته درباره‌ی این کلاس:

  • trait Queueable در لاراول ۱۳ خودش SerializesModels را هم دارد. یعنی در صف فقط شناسه‌ی سفارش ذخیره می‌شود، نه کل مدل؛ و worker هنگام اجرا مدل را تازه از پایگاه‌داده می‌خواند. پس invoice_sent_at همیشه مقدار فعلی است.
  • وابستگی‌ها (مثل InvoicePdf) را در handle بگیرید، نه در سازنده. سازنده فقط داده‌ای را می‌گیرد که باید در صف ذخیره شود.
  • InvoiceMail نباید خودش ShouldQueue باشد؛ ما همین حالا داخل صف هستیم و send همین‌جا اجرا می‌شود.

فرستادن به صف

در کنترلر یا سرویس:

app/Http/Controllers/OrderController.php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreOrderRequest;
use App\Jobs\SendInvoice;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class OrderController extends Controller
{
    public function store(StoreOrderRequest $request)
    {
        $order = DB::transaction(function () use ($request) {
            $order = Order::create($request->validated());

            SendInvoice::dispatch($order)->afterCommit();

            return $order;
        });

        return redirect()->route('orders.show', $order);
    }
}

afterCommit() مهم است. بدون آن ممکن است worker زودتر از commit تراکنش Job را بردارد، سفارش را در پایگاه‌داده پیدا نکند و Job با خطای ModelNotFoundException شکست بخورد. برای اینکه این رفتار پیش‌فرض همه‌ی Job‌ها باشد، می‌توانید در config/queue.php برای اتصال موردنظر 'after_commit' => true بگذارید.

صف‌های جدا برای کارهای با اولویت متفاوت هم مفید است:

php
SendInvoice::dispatch($order)->onQueue('emails');
GenerateMonthlyReport::dispatch()->onQueue('reports')->delay(now()->addMinutes(5));

تلاش دوباره، backoff و timeout

هر Job ممکن است شکست بخورد: شبکه قطع می‌شود، سرویس بیرونی خطای ۵۰۰ می‌دهد. سه تنظیم رفتار Job را در این حالت مشخص می‌کنند:

تنظیم معنی
$tries چند بار اجرا شود تا «شکست‌خورده» حساب شود
backoff() یا $backoff چند ثانیه بین تلاش‌ها صبر شود؛ آرایه یعنی فاصله‌ی هر تلاش جدا
$timeout اگر یک تلاش بیشتر از این طول بکشد، worker آن را می‌کشد

مقدارهایی که روی خود Job تعریف شوند بر گزینه‌های --tries و --timeout در دستور queue:work اولویت دارند.

یک رابطه‌ی مهم را فراموش نکنید: در config/queue.php هر اتصال مقداری به نام retry_after دارد (پیش‌فرض ۹۰ ثانیه). اگر Job بیشتر از این زمان در حال اجرا بماند، صف فرض می‌کند worker مرده و Job را به worker دیگری می‌دهد. پس timeout باید همیشه چند ثانیه کمتر از retry_after باشد؛ وگرنه یک Job طولانی ممکن است هم‌زمان دو بار اجرا شود. در مثال بالا timeout را ۱۲۰ گذاشتیم، پس retry_after آن اتصال را باید مثلاً به ۱۸۰ برسانیم.

ShouldBeUnique: یک بار در صف، نه ده بار

اگر کاربر دو بار روی دکمه کلیک کند یا یک event دو بار اجرا شود، ممکن است دو Job یکسان در صف بنشیند. با پیاده‌سازی ShouldBeUnique و متد uniqueId()، تا وقتی یک SendInvoice برای یک سفارش در صف یا در حال اجراست، dispatch بعدی برای همان سفارش نادیده گرفته می‌شود.

قفل یکتایی وقتی آزاد می‌شود که Job با موفقیت تمام شود یا همه‌ی تلاش‌هایش را از دست بدهد. $uniqueFor سقف زمان نگه‌داشتن قفل است، برای حالتی که worker وسط کار از بین برود. این قفل در کش ذخیره می‌شود، پس درایور کش باید از lock پشتیبانی کند و بین همه‌ی سرورها مشترک باشد؛ Redis یا database مناسب‌اند، file روی چند سرور نه.

Job‌های شکست‌خورده و متد failed

وقتی همه‌ی تلاش‌ها تمام شود، Job در جدول failed_jobs ثبت می‌شود و متد failed() آن اجرا می‌شود. جای مناسبی است برای لاگ‌کردن، خبردادن به تیم پشتیبانی یا علامت‌گذاشتن سفارش به‌عنوان «نیازمند بررسی».

دستورهایی که با آن‌ها زیاد کار خواهید داشت:

bash
php artisan queue:failed                 # فهرست Job‌های شکست‌خورده
php artisan queue:retry 5f1c...           # تلاش دوباره برای یک Job با UUID
php artisan queue:retry all              # تلاش دوباره برای همه
php artisan queue:forget 5f1c...          # حذف یک Job از فهرست
php artisan queue:prune-failed --hours=168   # حذف رکوردهای قدیمی‌تر از یک هفته

اجرای queue:prune-failed را در scheduler بگذارید تا این جدول بی‌نهایت بزرگ نشود.

idempotency: فرض کنید هر Job دو بار اجرا می‌شود

صف‌ها تضمین «دقیقاً یک بار» نمی‌دهند. Job ممکن است بعد از ارسال ایمیل و قبل از ذخیره‌ی invoice_sent_at قطع شود؛ تلاش بعدی ایمیل را دوباره می‌فرستد. یا retry_after اشتباه تنظیم شده باشد و دو worker یک Job را بردارند.

راه درست این است که Job را طوری بنویسیم که اجرای دوباره‌اش ضرری نداشته باشد:

  • قبل از انجام کار، وضعیت را بررسی کنید؛ مثل invoice_sent_at در مثال بالا.
  • برای عملیات مالی از کلید یکتا در پایگاه‌داده استفاده کنید (مثلاً unique index روی payment_reference) تا درج تکراری با خطا رد شود.
  • اگر سرویس بیرونی کلید idempotency می‌پذیرد، شناسه‌ی سفارش را به‌عنوان آن کلید بفرستید.
  • انتخاب کنید کدام بدتر است: یک ایمیل تکراری یا یک ایمیل فرستاده‌نشده. اگر invoice_sent_at را قبل از ارسال ذخیره کنید، تکرار ندارید ولی ممکن است ایمیلی گم شود؛ اگر بعد از ارسال ذخیره کنید، برعکس. برای فاکتور معمولاً تکرار کم‌ضررتر است.

database یا Redis

لاراول ۱۳ به‌طور پیش‌فرض از درایور database برای صف استفاده می‌کند و migration جدول jobs هم در پروژه‌ی تازه وجود دارد.

database redis
راه‌اندازی بدون سرویس اضافه نیاز به نصب و نگهداری Redis
کارایی برای چند ده Job در دقیقه کاملاً کافی برای حجم بالا و تأخیر کم
فشار روی پایگاه‌داده worker‌ها مدام جدول را poll می‌کنند صفر
Horizon پشتیبانی نمی‌شود پشتیبانی می‌شود

برای بیشتر سایت‌ها و پروژه‌های کوچک و متوسط، database نقطه‌ی شروع خوبی است. وقتی تعداد Job‌ها بالا رفت، worker‌ها زیاد شدند یا به داشبورد Horizon نیاز داشتید، به Redis مهاجرت کنید؛ کد Job‌ها تغییری نمی‌کند و فقط QUEUE_CONNECTION در .env عوض می‌شود.

اجرای worker در production

در محیط توسعه php artisan queue:work در یک ترمینال کافی است. در production باید پردازشی داشته باشیم که همیشه بالا باشد، بعد از کرش دوباره اجرا شود و بعد از ریبوت سرور خودکار شروع شود. دو ابزار رایج برای این کار Supervisor و systemd هستند. روی Ubuntu 24.04 که systemd از قبل هست، نیازی به نصب چیز اضافه نیست.

واحد systemd

یک unit قالبی (template) می‌سازیم تا بتوانیم چند worker هم‌زمان داشته باشیم:

/etc/systemd/system/laravel-worker@.service
[Unit]
Description=Laravel queue worker %i
After=network-online.target
Wants=network-online.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/example.com
ExecStart=/usr/bin/php artisan queue:work database --queue=emails,default --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=5
# فرصت کافی برای تمام‌شدن Job فعلی هنگام stop
TimeoutStopSec=150

[Install]
WantedBy=multi-user.target

و فعال‌سازی دو worker:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now laravel-worker@1 laravel-worker@2
sudo systemctl status 'laravel-worker@*'
journalctl -u laravel-worker@1 -f

چند توضیح:

  • --queue=emails,default یعنی worker اول صف emails را خالی می‌کند و بعد سراغ default می‌رود.
  • --max-time=3600 باعث می‌شود worker بعد از یک ساعت با آرامش خارج شود و Restart=always آن را دوباره بالا بیاورد. این کار جلوی رشد تدریجی مصرف حافظه را می‌گیرد. --max-jobs هم همین کار را بر اساس تعداد Job انجام می‌دهد.
  • systemd هنگام stop سیگنال SIGTERM می‌فرستد و worker لاراول Job فعلی را تمام می‌کند و بعد خارج می‌شود. TimeoutStopSec را از timeout طولانی‌ترین Job بیشتر بگذارید.

اگر Supervisor را ترجیح می‌دهید

/etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php /var/www/example.com/artisan queue:work database --sleep=3 --tries=3 --max-time=3600
user=www-data
numprocs=2
autostart=true
autorestart=true
stopwaitsecs=150
stdout_logfile=/var/www/example.com/storage/logs/worker.log

سپس sudo supervisorctl reread && sudo supervisorctl update.

ری‌استارت worker بعد از هر دیپلوی

worker یک پردازش بلندمدت است و کد برنامه را یک بار در حافظه بارگذاری می‌کند. اگر بعد از دیپلوی آن را ری‌استارت نکنید، با کد قدیمی به کار ادامه می‌دهد؛ یکی از رایج‌ترین دلیل‌های «تغییر دادم ولی اثری ندارد».

در اسکریپت دیپلوی، بعد از به‌روزکردن کد و اجرای migration:

bash
php artisan migrate --force
php artisan optimize
php artisan queue:restart

queue:restart پردازشی را نمی‌کشد؛ فقط علامتی در کش می‌گذارد. هر worker بعد از تمام‌کردن Job فعلی آن علامت را می‌بیند و خارج می‌شود، و systemd یا Supervisor آن را با کد جدید بالا می‌آورد. دو پیش‌شرط دارد: درایور کش نباید array باشد و همه‌ی worker‌ها باید به همان کش دسترسی داشته باشند.

جمع‌بندی

  • هر کار کند، غیرضروری برای پاسخ و قابل‌شکست را به صف بفرستید.
  • tries، backoff و timeout را صریحاً روی Job تعریف کنید و timeout را کمتر از retry_after نگه دارید.
  • dispatch داخل تراکنش را با afterCommit() انجام دهید.
  • با ShouldBeUnique جلوی Job‌های تکراری را بگیرید، ولی Job را همیشه idempotent بنویسید.
  • failed() را پیاده کنید و جدول failed_jobs را مرتب پاک کنید.
  • با database شروع کنید و هر وقت لازم شد به Redis بروید.
  • worker را با systemd یا Supervisor اجرا کنید و در هر دیپلوی queue:restart بزنید.

برای جزئیات بیشتر، مستندات رسمی صف در لاراول را ببینید: https://laravel.com/docs/queues