طوابير Laravel والمهام غير المتزامنة
الطوابير هي "فريق العمل الخلفي" في Laravel — يتم تفويض المهام المستغرقة للوقت إلى الطابور للمعالجة غير المتزامنة، بحيث لا يضطر المستخدم للانتظار وتعود الاستجابة فورًا.
1. ما ستتعلمه
- تهيئة مشغلات الطوابير: مقارنة بين sync/database/redis/sqs
- إنشاء المهام وإرسالها: dispatch() / Queue::push()
- آلية إعادة المحاولة عند الفشل: جدول
tries/backoff/failed_jobs - أولوية الطوابير والتأخير والمهام الدفعية: Batch/Bused
- المراقبة بواسطة Supervisor لعملية
queue:work
2. قصة مستخدم
(1) المشكلة: تصدير التقارير يتسبب في تجميد النظام بالكامل
نقر Bob على "تصدير التقرير الشهري" في لوحة إدارة ShopMetrics — استغرق إنشاء ملف CSV يحتوي على 100,000 سجل طلب 30 ثانية، وخلال ذلك الوقت استمرت الصفحة في الدوران، كما عانت Alice أيضًا من بطء عند الوصول إلى لوحة المعلومات في نفس الوقت. لزيادة الطين بلة، أطلق Bob خمسة تصديرات تقارير في وقت واحد، مما استنزف تجمع عمليات PHP وتسبب في إرجاع الموقع بالكامل لخطأ 502.
(2) الحل باستخدام الطوابير
يرسل الطابور المهام المستهلكة للوقت إلى الخلفية — عندما ينقر المستخدم على "تصدير"، يرى فورًا رسالة "جاري إنشاء التقرير"، بينما تعمل المهمة ببطء في الخلفية؛ وبمجرد الانتهاء، يتم إرسال بريد إلكتروني يحتوي على رابط التحميل.
// متزامن — يحجب لمدة 30 ثانية
$csv = ReportService::generateMonthlyReport($tenant);
// غير متزامن — يعود فورًا، تعمل المهمة في الخلفية
GenerateReportJob::dispatch($tenant, 'monthly');
// يرى المستخدم: "جاري إنشاء التقرير. سنرسل لك بريدًا إلكترونيًا عندما يكون جاهزًا."
(3) النتيجة
بعد أن نفّذ Bob الطابور، أصبحت طلبات تصدير التقارير تعود خلال 100 مللي ثانية، وتعمل المهام في الخلفية بسلاسة، ولوحة معلومات Alice لم تعد تتجمد.
3. تهيئة مشغلات الطوابير
(1) مقارنة المشغلات
| المشغل | الاستمرارية | الأداء | الملاءمة | التكلفة |
|---|---|---|---|---|
sync |
❌ تنفيذ فوري | الأسرع | التطوير/الاختبار | مجاني |
database |
✅ جدول قاعدة بيانات | بطيء | المشاريع الصغيرة | مجاني |
redis |
✅ الذاكرة | سريع | بيئة الإنتاج | Redis |
sqs |
✅ AWS | عالي | المشاريع الكبيرة | الدفع حسب الاستخدام |
beanstalkd |
✅ مخصص | سريع | المشاريع المتوسطة | مجاني |
(2) التهيئة
# .env
QUEUE_CONNECTION=redis
# اتصال Redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
// config/queue.php
'connections' => [
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => '{default}',
'retry_after' => 90,
'block_for' => null,
],
],
(3) إنشاء جداول الطوابير
# لمشغل قاعدة البيانات
php artisan queue:table
php artisan queue:failed-table
php artisan migrate
# ينشئ: جدول jobs + جدول failed_jobs
(1) ▶ مثال: تهيئة طوابير ShopMetrics
# .env — بيئة التطوير
QUEUE_CONNECTION=database
# .env — بيئة الإنتاج
QUEUE_CONNECTION=redis
REDIS_HOST=redis.shopmetrics.internal
REDIS_PORT=6379
# إنشاء جداول الطوابير (مشغل قاعدة البيانات)
php artisan queue:table
php artisan queue:failed-table
php artisan queue:batches-table
php artisan migrate
الناتج:
# تم تنفيذ الأمر بنجاح
4. إنشاء المهام وتوزيعها
(1) إنشاء مهمة
php artisan make:job GenerateReportJob
# ينشئ: app/Jobs/GenerateReportJob.php
(2) تعريف المهمة
// app/Jobs/GenerateReportJob.php
class GenerateReportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public bool $deleteWhenMissingModels = true;
public function __construct(
public Tenant $tenant,
public string $reportType,
public string $format = 'csv',
) {}
public function handle(
ReportService $reportService,
Mailer $mailer,
): void {
$path = $reportService->generate(
$this->tenant,
$this->reportType,
$this->format,
);
$url = Storage::disk('s3')->temporaryUrl($path, now()->addDays(7));
$this->tenant->users->each(function ($user) use ($url) {
$mailer->to($user)->send(new ReportReadyNotification($url, $this->reportType));
});
}
public function failed(\Throwable $exception): void
{
Log::error('Report generation failed', [
'tenant_id' => $this->tenant->id,
'report_type' => $this->reportType,
'error' => $exception->getMessage(),
]);
}
}
(3) توزيع المهمة
// توزيع أساسي
GenerateReportJob::dispatch($tenant, 'monthly');
// توزيع مؤجل
GenerateReportJob::dispatch($tenant, 'monthly')
->delay(now()->addMinutes(5));
// توزيع على طابور محدد
GenerateReportJob::dispatch($tenant, 'monthly')
->onQueue('reports');
// توزيع شرطي
GenerateReportJob::dispatchIf($tenant->subscription?->isActive(), $tenant, 'monthly');
// توزيع بعد إرسال الاستجابة للمستخدم
GenerateReportJob::dispatchAfterResponse($tenant, 'monthly');
| طريقة التوزيع | الوصف | السيناريوهات المطبقة |
|---|---|---|
dispatch() |
إضافة إلى الطابور | المهام غير المتزامنة العامة |
dispatchSync() |
تنفيذ متزامن | يجب إكماله فورًا |
dispatchAfterResponse() |
تنفيذ بعد الاستجابة | المهام الخفيفة |
delay() |
تنفيذ مؤجل | المهام المجدولة |
onQueue() |
طابور محدد | حسب الأولوية |
(1) ▶ مثال: مهمة معالجة الطلبات في ShopMetrics
// app/Jobs/ProcessOrderJob.php
class ProcessOrderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = [30, 60, 120];
public function __construct(public Order $order) {}
public function handle(
OrderService $orderService,
PaymentGateway $payment,
): void {
// خصم الدفع
$payment->charge($this->order);
// تحديث المخزون
$orderService->deductInventory($this->order);
// بث الحدث
event(new OrderPlaced($this->order));
// إرسال بريد تأكيد
$this->order->user->notify(new OrderConfirmationNotification($this->order));
}
public function failed(\Throwable $exception): void
{
$this->order->update(['status' => 'failed']);
$this->order->user->notify(new OrderFailedNotification($this->order));
}
}
الناتج:
// تم التنفيذ بنجاح
5. آلية إعادة المحاولة عند الفشل
(1) تهيئة إعادة المحاولة
// في فئة المهمة
public int $tries = 3; // الحد الأقصى لمحاولات إعادة المحاولة
public int $backoff = 60; // ثوانٍ بين المحاولات
public int $timeout = 120; // الحد الأقصى للثواني لكل محاولة
// أو تراجع أسي
public array $backoff = [30, 60, 120]; // 30ث، 60ث، 120ث
// أو تراجع ديناميكي
public function backoff(): int
{
return 30 * $this->attempts();
}
(2) معالجة الأخطاء
# عرض المهام الفاشلة
php artisan queue:failed
# إعادة محاولة مهمة فاشلة محددة
php artisan queue:retry 5
# إعادة محاولة جميع المهام الفاشلة
php artisan queue:retry all
# حذف مهمة فاشلة
php artisan queue:forget 5
# مسح جميع المهام الفاشلة
php artisan queue:flush
(3) دورة حياة مهمة الطابور
flowchart LR
A[تم توزيع المهمة] --> B[الطابور]
B --> C[استلمها العامل]
C --> D{نجاح؟}
D -->|نعم| E[اكتملت المهمة]
D -->|لا| F{المحاولات < الحد؟}
F -->|نعم| G[تراجع + إعادة محاولة]
G --> B
F -->|لا| H[جدول failed_jobs]
H --> I[إعادة محاولة يدوية / مسح]
(1) ▶ مثال: تهيئة إعادة محاولة الفشل في ShopMetrics
// app/Jobs/SendWebhookJob.php
class SendWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public array $backoff = [10, 30, 60, 120, 300];
public int $timeout = 30;
public function __construct(
public Shop $shop,
public array $payload,
) {}
public function handle(): void
{
$response = Http::timeout($this->timeout)
->post($this->shop->webhook_url, $this->payload);
if (!$response->successful()) {
$this->release($this->backoff[$this->attempts() - 1] ?? 60);
}
}
public function failed(\Throwable $exception): void
{
$this->shop->tenant->users->each(function ($user) {
$user->notify(new WebhookFailedNotification($this->shop));
});
}
}
الناتج:
// تم التنفيذ بنجاح
6. المهام الدفعية
(1) إنشاء دفعة
php artisan queue:batches-table
php artisan migrate
// app/Jobs/ProcessTenantAnalyticsJob.php
class ProcessTenantAnalyticsJob implements ShouldQueue
{
use Batchable;
public function __construct(public Tenant $tenant) {}
public function handle(): void
{
if ($this->batch()->cancelled()) {
return;
}
AnalyticsService::computeForTenant($this->tenant);
}
}
(2) توزيع الدفعة
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
$batch = Bus::batch(
Tenant::active()->get()->map(
fn ($tenant) => new ProcessTenantAnalyticsJob($tenant)
)
)->then(function (Batch $batch) {
// اكتملت جميع المهام بنجاح
Log::info("Batch {$batch->id} completed: {$batch->totalJobs} tenants processed.");
})->catch(function (Batch $batch, \Throwable $e) {
// فشل أول مهمة
Log::error("Batch {$batch->id} failed: {$e->getMessage()}");
})->finally(function (Batch $batch) {
// يعمل دائمًا (نجاح أو فشل)
Cache::forget('analytics:computing');
})->name('Process Monthly Analytics')
->onQueue('analytics')
->dispatch();
(3) إدارة الدفعات
// التحقق من تقدم الدفعة
$batch = Bus::findBatch($batchId);
$batch->progress(); // 0-100
$batch->processedJobs();
$batch->totalJobs();
$batch->failedJobs();
$batch->finished();
// إلغاء الدفعة
$batch->cancel();
| الطريقة | الوصف |
|---|---|
then() |
جميع عمليات الاستدعاء ناجحة |
catch() |
استدعاء فشل المحاولة الأولى |
finally() |
استدعاء عند الانتهاء (بغض النظر عن النجاح أو الفشل) |
progress() |
النسبة المئوية المكتملة |
cancel() |
إلغاء المهام المتبقية |
(1) ▶ مثال: مهمة دفعية للتحليل الشهري في ShopMetrics
// app/Console/Commands/ProcessMonthlyAnalytics.php
class ProcessMonthlyAnalytics extends Command
{
protected $signature = 'analytics:process-monthly';
public function handle(): int
{
$tenants = Tenant::active()->get();
$this->info("Processing analytics for {$tenants->count()} tenants...");
Bus::batch(
$tenants->map(fn ($t) => new ProcessTenantAnalyticsJob($t))
)->then(function (Batch $batch) {
$this->info("All {$batch->totalJobs} tenants processed.");
})->catch(function (Batch $batch, \Throwable $e) {
$this->error("Batch failed: {$e->getMessage()}");
})->name('Monthly Analytics')
->onQueue('analytics')
->allowFailures()
->dispatch();
return self::SUCCESS;
}
}
الناتج:
// تم التنفيذ بنجاح
7. Supervisor كعملية خفية
(1) تثبيت Supervisor
# Ubuntu/Debian
sudo apt-get install supervisor
# إنشاء تهيئة
sudo nano /etc/supervisor/conf.d/shopmetrics-worker.conf
(2) تهيئة Supervisor
[program:shopmetrics-worker-default]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/shopmetrics/artisan queue:work redis --queue=default --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/shopmetrics/worker-default.log
stopwaitsecs=3600
[program:shopmetrics-worker-reports]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/shopmetrics/artisan queue:work redis --queue=reports --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=1
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/shopmetrics/worker-reports.log
(3) أوامر Supervisor
# قراءة التهيئة الجديدة
sudo supervisorctl reread
sudo supervisorctl update
# بدء/إيقاف/إعادة تشغيل العمال
sudo supervisorctl start shopmetrics-worker-default:*
sudo supervisorctl stop shopmetrics-worker-default:*
sudo supervisorctl restart shopmetrics-worker-default:*
# التحقق من الحالة
sudo supervisorctl status
| عدد العمليات | الطابور | المعالج | الذاكرة | الوصف |
|---|---|---|---|---|
| 2 | default | منخفض | متوسط | المهام العامة |
| 1 | reports | عالي | عالي | إنشاء التقارير |
| 1 | analytics | عالي | عالي | تحليل البيانات |
| 1 | notifications | منخفض | منخفض | البريد/الإشعارات |
(1) ▶ مثال: تشغيل عامل طوابير متعددة في ShopMetrics
# التطوير — عامل واحد، جميع الطوابير
php artisan queue:work --queue=default,reports,notifications
# الإنتاج — عمال منفصلون حسب أولوية كل طابور
# الأولوية: high > default > low
php artisan queue:work redis --queue=high,default
php artisan queue:work redis --queue=reports,low
# معالجة المهام بحد زمني (إعادة تشغيل تلقائية لتسرب الذاكرة)
php artisan queue:work --max-time=3600 --max-jobs=1000
# مراقبة الطابور
php artisan queue:monitor redis:default,redis:reports
الناتج:
# تم تنفيذ الأمر بنجاح
8. مثال شامل: نظام التقارير غير المتزامنة في ShopMetrics
// ============================================
// شامل: نظام التقارير غير المتزامنة في ShopMetrics
// يغطي: المهام، الدفعات، إعادة المحاولة، Supervisor، الإشعارات
// ============================================
// app/Jobs/GenerateReportJob.php
class GenerateReportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [60, 180, 600];
public int $timeout = 600;
public bool $deleteWhenMissingModels = true;
public function __construct(
public Tenant $tenant,
public string $reportType,
public string $format = 'csv',
public ?int $userId = null,
) {
$this->onQueue('reports');
}
public function handle(ReportService $reportService): void
{
$path = $reportService->generate(
$this->tenant,
$this->reportType,
$this->format,
);
$downloadUrl = Storage::disk('s3')->temporaryUrl($path, now()->addDays(7));
$user = $this->userId ? User::find($this->userId) : $this->tenant->users->first();
$user?->notify(new ReportReadyNotification(
downloadUrl: $downloadUrl,
reportType: $this->reportType,
expiresAt: now()->addDays(7),
));
}
public function failed(\Throwable $exception): void
{
$user = $this->userId ? User::find($this->userId) : $this->tenant->users->first();
$user?->notify(new ReportFailedNotification(
reportType: $this->reportType,
error: $exception->getMessage(),
));
}
}
// الاستخدام في المتحكم
class ReportController extends Controller
{
public function generate(Request $request): JsonResponse
{
$validated = $request->validate([
'report_type' => 'required|in:monthly,weekly,custom',
'format' => 'sometimes|in:csv,xlsx,pdf',
'date_from' => 'sometimes|date',
'date_to' => 'sometimes|date|after:date_from',
]);
GenerateReportJob::dispatch(
tenant(),
$validated['report_type'],
$validated['format'] ?? 'csv',
auth()->id(),
);
return response()->json([
'message' => 'Report generation started. You will receive an email when ready.',
'estimated_time' => '5-15 minutes',
], 202);
}
}
❓ أسئلة شائعة
queue:work و queue:listen؟queue:work في الذاكرة دون إعادة تشغيل إطار العمل (سريع، لكن يتطلب إعادة تشغيل يدوية بعد تغييرات الكود)؛ بينما يعيد queue:listen تشغيل إطار العمل لكل مهمة (بطيء، لكن يحمل الكود الجديد تلقائيًا). استخدم queue:work + Supervisor في الإنتاج، واستخدم queue:listen أو queue:work --once أثناء التطوير.SerializesModels تقوم تلقائيًا بتسلسل معرّفات النماذج واسترجاعها من قاعدة البيانات أثناء إلغاء التسلسل. الفائدة هي أن المهام لها بصمة بيانات أصغر؛ والمخاطرة هي أنه إذا تم حذف النموذج، ستفشل المهمة (يمكن تجاهل ذلك باستخدام deleteWhenMissingModels).allowFailures() للسماح للدفعة بالاستمرار في التنفيذ رغم الفشل الجزئي. استخدم Bus::findBatch() لعرض تفاصيل الفشل وإعادة محاولة المهام الفاشلة يدويًا.QUEUE_CONNECTION=sync لتشغيل المهمة بشكل متزامن (تظهر الأخطاء فورًا)؛ في الإنتاج، استخدم php artisan queue:failed لعرض رسائل الخطأ للمهام الفاشلة؛ يمكنك أيضًا تسجيل معلومات تفصيلية في طريقة failed() للمهمة.queue:work كعملية خفية.📖 ملخص
- الطوابير تجعل المهام المستهلكة للوقت غير متزامنة، مما يسمح بالاستجابة لطلبات المستخدم خلال ثوانٍ
- Redis هو أفضل مشغل طوابير لبيئات الإنتاج، حيث يقدم أداءً عاليًا واستمرارية
- تستخدم المهمة تهيئة
tries/backoffلضبط استراتيجية إعادة المحاولة؛ التراجع الأسي يمنع الانهيار المتسلسل - المعالجة الدفعية لعدد كبير من المهام المماثلة، مع دعم تتبع التقدم والفشل الجزئي
- يراقب Supervisor عمليات العامل ويعيد تشغيلها تلقائيًا إذا تعطلت
- طوابير متعددة بمستويات أولوية: high/default/reports/low
📝 تمارين
-
تمرين أساسي (⭐): أنشئ
GenerateReportJob، وأرسل طلبًا غير متزامن لإنشاء تقرير CSV في المتحكم، واختبر باستخدام مشغل قاعدة البيانات، وشغّلqueue:workللتحقق من تنفيذ المهمة. -
تمرين متقدم (⭐⭐): قم بتهيئة مشغل طابور Redis لتنفيذ 3 محاولات بإعادة محاولة أسيّة (30ث/60ث/120ث)، وأرسل إشعارات للمستخدمين عند فشل المهمة، واختبر إعادة المحاولة اليدوية (queue:retry).
-
تحدي (⭐⭐⭐): استخدم
Bus::batch()لتنفيذ مهمة دفعية للتحليل الشهري متعدد المستأجرين — مر عبر جميع المستأجرين النشطين، وأنشئ مهمة واحدة لكل مستأجر، وراقب النسبة المئوية للتقدم، وامسح ذاكرة التخزين المؤقت عند الانتهاء، وقم بتهيئة Supervisor لمراقبة العامل.



