کاربر روی «ثبت سفارش» کلیک میکند و چهار ثانیه به یک چرخدندهی در حال چرخش نگاه میکند. در این چهار ثانیه سرور فاکتور PDF میسازد، ایمیل میفرستد، به یک سرویس پیامک وصل میشود و شاید چیزی هم به سیستم حسابداری خبر میدهد. هیچکدام از اینها لازم نیست قبل از پاسخ به کاربر تمام شود.
صف (queue) در لاراول دقیقاً برای همین است: درخواست کار اصلی را انجام میدهد، کارهای کند را در صف میگذارد و فوراً پاسخ میدهد. یک پردازش جداگانه به نام worker آن کارها را در پسزمینه برمیدارد و اجرا میکند.
در این مقاله از ساخت اولین Job تا اجرای پایدار worker در production را با لاراول ۱۳ مرور میکنیم.
چه کاری را به صف بفرستیم
هر کاری که این سه ویژگی را دارد کاندیدای خوبی است:
- کند است یا زمانش قابل پیشبینی نیست؛ مثل ارسال ایمیل و پیامک، ساخت PDF، پردازش تصویر و فراخوانی API بیرونی.
- کاربر منتظر نتیجهاش نیست؛ کافی است بداند کار ثبت شده است.
- ممکن است شکست بخورد و باید دوباره امتحان شود؛ سرویس پیامک گاهی جواب نمیدهد و این نباید باعث شکست ثبت سفارش شود.
در مقابل، اعتبارسنجی فرم، ذخیرهی خود سفارش و هر چیزی که پاسخ صفحه به آن وابسته است باید در همان درخواست بماند.
ساخت یک Job
با دستور زیر یک کلاس Job ساخته میشود:
php artisan make:job SendInvoice
و این نسخهی کاملشدهی آن است:
<?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همینجا اجرا میشود.
فرستادن به صف
در کنترلر یا سرویس:
<?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 بگذارید.
صفهای جدا برای کارهای با اولویت متفاوت هم مفید است:
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() آن اجرا میشود. جای مناسبی است برای لاگکردن، خبردادن به تیم پشتیبانی یا علامتگذاشتن سفارش بهعنوان «نیازمند بررسی».
دستورهایی که با آنها زیاد کار خواهید داشت:
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 همزمان داشته باشیم:
[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:
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 را ترجیح میدهید
[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:
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