أسرع طريقة لإبطاء نقطة نهاية هي أداء عمل حقيقي داخلها. إرسال بريد، نداء واجهة طرف ثالث، توليد PDF، تصغير صورة — كلٌّ يضيف زمنه إلى استجابة ينتظرها المستخدم. الحلّ دائماً نفسه تقريباً: أخرج ذلك العمل من الطلب إلى طابور.
لكن "ضعه في طابور" يخفي السؤالين المهمّين فعلاً في الإنتاج: ما الذي ينتمي لطابور، وماذا يحدث حين تفشل المهمة في منتصفها.
ما أنقله خارج الطلب
قاعدتي بسيطة. إن لم يكن العمل لازماً لإنتاج الاستجابة فهو لا ينتمي للطلب. المستخدم الذي يضع طلباً يحتاج أن يعرف أنه قُبل — لا أن ينتظر بينما أرسل بريد التأكيد، وأخطر المستودع، وأزامن الفاتورة مع المحاسبة.
public function place(array $data): Order
{
$order = Order::create($data);
// الاستجابة تعود الآن؛ كل ما تحت في طابور.
SendOrderConfirmation::dispatch($order);
NotifyWarehouse::dispatch($order);
SyncInvoiceToAccounting::dispatch($order);
return $order;
}
تحوّلت النقطة من "بطيئة كأبطأ طرف ثالث" إلى "بطيئة كإدراج واحد". هذا هو المقصد كله.
القاعدة التي لا يخبرك بها أحد: المهام تعمل أكثر من مرة
هذا الدرس يفرّق بين من شغّل الطوابير في الإنتاج ومن أعدّها فقط: المهمة قد تعمل مرتين. يموت عامل في منتصف التنفيذ، تنتهي مهلة المهمة فتُعاد، يعيد نشرٌ تشغيل الطابور — وفجأة مهمة "أرسل بريداً واحداً" أرسلت اثنين، أو مهمة "اخصم من البطاقة" خصمت مرتين.
الدفاع هو الـidempotency. يجب أن تُنتج المهمة النتيجة نفسها سواء عملت مرة أو خمساً.
public function handle(): void
{
// صفّ فريد هو مفتاح الـidempotency — التشغيل الثاني بلا أثر.
$sent = NotificationLog::firstOrCreate(
['order_id' => $this->order->id, 'type' => 'confirmation'],
);
if ($sent->wasRecentlyCreated) {
Mail::to($this->order->email)->send(new OrderConfirmation($this->order));
}
}
كل ما يمسّ المال أو يرسل رسالة لإنسان يأخذ هذه المعاملة. سطر سجلّ مكرّر مزعج؛ لكن خصم مكرّر يعني استرداداً واعتذاراً.
إعادة المحاولة، والتراجع، وفشل تستطيع رؤيته
المهمة التي قد تفشل تحتاج أن تحدّد كيف تُعاد. أضع حدوداً صريحة بدل الاعتماد على الافتراضات، وأتراجع كي يأخذ طرف ثالث متعثّر مجالاً للتعافي بدل عاصفة إعادة محاولة.
public int $tries = 3;
public array $backoff = [10, 60, 300]; // ثوانٍ بين المحاولات
public function failed(\Throwable $e): void
{
// بعد المحاولة الأخيرة، اجعل الفشل مسموعاً — لا رسالة ميتة صامتة.
Log::error('Invoice sync failed', ['order' => $this->order->id, 'error' => $e->getMessage()]);
}
دالة failed() أهمّ مما يظنّ الناس. مهمة تستنفد محاولاتها وتختفي في جدول failed_jobs دون أن يلاحظ أحد هي كيف تبقى فواتير دون إرسال أسبوعاً. أريد للأعطال أن تنبّه أحداً، أو على الأقل تظهر حيث ينظر إنسان.
الخطأ الذي لا يظهر إلا تحت الحِمل
أبشع خطأ طابور طاردته كان مهمة تعمل كل مرة محلياً وتفشل عشوائياً في الإنتاج. السبب كان إطلاقها من داخل معاملة قاعدة بيانات:
DB::transaction(function () use ($data) {
$order = Order::create($data);
ProcessOrder::dispatch($order); // أُطلقت قبل أن تُثبَّت المعاملة
});
مع عامل Redis سريع، قد تبدأ المهمة قبل تثبيت المعاملة — فيجد Order::find($id) داخلها لا شيء وتفشل لصفٍّ "لم يوجد بعد". محلياً يكون العامل أبطأ فتُثبَّت المعاملة أولاً دائماً فلا تراه؛ وتحت الحِمل يخسر ذلك السباق باستمرار. إنه heisenbug حقيقي.
الحلّ دالة واحدة — أخبر الإطلاق أن ينتظر التثبيت:
ProcessOrder::dispatch($order)->afterCommit();
صرت أجعل afterCommit() (أو إعداد after_commit لاتصال الطابور) الافتراضي لأي شيء يُطلَق داخل معاملة. هذا بالضبط صنف الأخطاء الذي لا يظهر في درس ويكلّفك بهدوء بعد ظهرٍ أول مرة يلسعك.
على ماذا أشغّلها
في معظم المشاريع هذا Redis مع عمليتَي queue:work تحت مشرف، مع Laravel Horizon فوقها بمجرد وجود أكثر من طابور. يستحق Horizon مكانه لحظة أن تريد رؤية الإنتاجية وأزمنة الانتظار والأعطال دون تتبّع السجلّات — ولحظة أن تريد أولويات مختلفة، فلا يجلس بريد إعادة تعيين كلمة سرّ خلف عشرة آلاف مهمة تقارير ليلية.
النموذج الذهني الذي أحتفظ به: الطلب وعدٌ بأن العمل قُبل، والطابور هو الآلية التي تضمن أنه يحدث فعلاً — مرة واحدة بالضبط، حتى حين يسوء شيء. اضبط هاتين الفكرتين وتتوقّف الطوابير عن كونها حيلة أداء وتصبح العمود الفقري لباك-إند موثوق.