فلسفة الكود
معمارية المهام الخلفية القوية: طوابير العمل وإعادة المحاولة
١٥ أكتوبر ٢٠٢٦ · 8 دقائق قراءة
الوهم الشائع: «أرسل المهمة إلى الخلفية وافترض أنها نجحت»
في بداية تطوير تطبيقات الويب، يكون الحل الأسهل لتسريع استجابة الخادم هو نقل العمليات الثقيلة (إرسال البريد، معالجة الدفع، توليد ملفات PDF) إلى الخلفية باستخدام:
# خطير جداً: fire-and-forget بلا حماية
asyncio.create_task(process_billing(user_id))
هذا النمط الساذج («أطلق وانسَ» - Fire and Forget) يحتوي على عيوب مدمرة:
- إذا أعيد تشغيل خادم التطبيق (Restart) أو تعرض لأزمة ذاكرة (OOM)، تفقد المهمة فوراً دون أي أثر.
- إذا تعطلت الخدمة الخارجية (مثل بوابة الدفع)، تفشل المهمة للأبد لعدم وجود آلية إعادة محاولة منضبطة.
- لا توجد وسيلة لمراقبة حجم الطابور أو معرفة كم عملية فشلت أو نجحت.
1. نموذج التسليم الموثوق: At-Least-Once
في الأنظمة الموزعة، من المستحيل عملياً ضمان التسليم «مرة واحدة بالضبط» (Exactly-Once Delivery) عند حدوث انقطاعات في الشبكة أو انهيار الخوادم. لذلك، تعتمد الأنظمة القوية على نموذج: التسليم على الأقل مرة واحدة (At-Least-Once Delivery).
كيف يضمن الطابور عدم ضياع المهام؟
- الحجز الذري (Atomic Leasing): عندما يلتقط العامل (Worker) مهمة، لا يتم حذفها من الوسيط (Redis أو RabbitMQ)، بل تُنقل ذرياً إلى قائمة قيد المعالجة (
in-flight). - فحص النبض وحصاد المهام العالقة (Reaper): إذا توقف العامل فجأة أو ماتت الحاوية قبل تأكيد انتهاء المهمة (
ACK)، يكتشف مراقب الطابور انتهاء مهلة الحجز، ويعيد إدراج المهمة تلقائياً في طابور العمل ليلتقطها عامل آخر سليم.
2. مبدأ الحياد التكراري (Idempotency): الحل الحتمي ضد التكرار
طالما أن المهمة قد تُسلَّم أكثر من مرة (بسبب إعادة المحاولة أو تعطل العامل)، يجب أن يكون كود المهمة محايداً تكرارياً (Idempotent): أي أن تنفيذ العملية 10 مرات يجب أن يعطي نفس النتيجة تماماً كما لو نُفذت مرة واحدة فقط.
كيف تطبق Idempotency عملياً؟
استخدم مفتاحاً فريداً لكل عملية (idempotency_key):
async def process_payment(order_id: int, amount_cents: int, idempotency_key: str):
# 1. التحقق الذري من أن هذا المفتاح لم يعالج مسبقاً
async with db.transaction():
existing = await db.fetch_payment(idempotency_key)
if existing:
logger.info("Payment already processed, skipping duplicate execution")
return existing
# 2. حجز سجل الدفع بحالة pending
await db.create_pending_payment(idempotency_key, order_id)
# 3. التواصل مع بوابة الدفع الخارجية باستخدام نفس المفتاح
charge_result = await stripe_gateway.charge(amount_cents, idempotency_key=idempotency_key)
# 4. تحديث الحالة إلى مكتملة
await db.mark_completed(idempotency_key, charge_result.id)
إذا أعيدت المهمة بعد نجاح المعاملة، سيكتشف الكود المفتاح المسجل ويتجنب خصم المبلغ من العميل مرة ثانية.
3. التراجع التدريجي وطابور الرسائل الميتة (DLQ)
عندما تفشل مهمة بسبب عطل مؤقت في شبكة خارجية، فإن إعادة المحاولة الفورية 10 مرات متتالية يؤدي إلى هجوم حجب الخدمة الذاتي (Self-Inflicted DDoS).
الاستراتيجية الصحيحة:
- تراجع أُسّي مع اهتزاز عشوائي (Exponential Backoff with Full Jitter): تأخير المحاولة الأولى ثانية واحدة، ثم ثانيتين، ثم 4، ثم 8، مع إضافة زمن عشوائي يمنع هجوم جميع العمال المتزامنين في نفس اللحظة.
- طابور الرسائل الميتة (Dead-Letter Queue - DLQ): إذا استنفدت المهمة الحد الأقصى للمحاولات (مثلاً 5 مرات)، تُنقل المهمة إلى طابور منفصل للمراجعة. هذا يمنع المهام المعطوبة من سد الطابور الرئيسي، ويسمح للمهندسين بفحص سبب الفشل وإصلاحه، ثم إعادة تشغيل المهام الميتة بنقرة واحدة.
الخلاصة
بناء طوابير مهام خلفية موثوقة لا يعتمد على التمني؛ بل يعتمد على آليات تسليم مثبتة تمنع الفقدان، وكود يطبق مبدأ Idempotency لمنع التكرار، وسياسات واضحة لإعادة المحاولة والعزل عبر DLQ.