جدولة المهام الخلفية بدون صداع cron: أنماط
تعلّم أنماطًا لجدولة المهام الخلفية باستخدام مجدول، جدول مهام، وعامل لتشغيل التذكيرات والملخصات والتنظيف بشكل موثوق.

لماذا يبدو cron بسيطًا حتى لا يكون كذلك
يبدو cron رائعًا في اليوم الأول: تكتب سطرًا، تختار وقتًا، وتنسى الموضوع. على خادم واحد ومهمة واحدة، غالبًا ما ينجح.
تظهر المشاكل عندما تعتمد على الجدولة في سلوك منتج حقيقي: تذكيرات، ملخصات يومية، تنظيف، أو مهام مزامنة. معظم قصص “المرّة التي فشل فيها التشغيل” ليست فشلًا من cron نفسه. بل هي كل ما حوله: إعادة تشغيل الخادم، نشر جديد جدَّل ملف crontab، مهمة استغرقت وقتًا أطول من المتوقع، أو اختلاف في الساعة أو المنطقة الزمنية. ومتى ما شغَّلت عدة نسخ من التطبيق، قد تحصل على الوضع العكسي: تشغيل مزدوج لأن جهازين يعتقدان أنهما يجب أن ينفذا نفس المهمة.
الاختبار نقطة ضعف أخرى. سطر cron لا يمنحك طريقة نظيفة لتشغيل “ماذا سيحدث في الساعة 9:00 صباحًا غدًا” في اختبار قابل للتكرار. فتتحول الجدولة إلى فحوصات يدوية، مفاجآت في الإنتاج، وصيد سجلات.
قبل أن تختار نهجًا، كن واضحًا بشأن ما تقوم بجدولته. معظم الأعمال الخلفية تقع في بضع فئات:
- تذكيرات (إرسال في وقت محدد، لمرة واحدة فقط)
- ملخصات يومية (تجميع البيانات ثم الإرسال)
- مهام تنظيف (حذف، أرشفة، انتهاء صلاحية)
- مزامنات دورية (جلب أو دفع تحديثات)
أحيانًا يمكنك تجنُّب الجدولة تمامًا. إذا كان بالإمكان القيام بشيء فور وقوع حدث (تسجيل مستخدم، نجاح دفع، تغيير حالة تذكرة)، فالعمل المدفوع بالأحداث عادةً أبسط وأكثر موثوقية من العمل المعتمد على الزمن.
عندما تحتاج إلى الوقت، فالموثوقية تعتمد بشكل أساسي على الرؤية والتحكم. تريد مكانًا لتسجيل ما يجب تشغيله، ما الذي شُغّل، وما الذي فشل، بالإضافة إلى وسيلة آمنة لإعادة المحاولة دون خلق تكرارات.
النمط الأساسي: مجدول، جدول مهام، عامل
طريق بسيط لتجنب صداع cron هو فصل المسؤوليات:
- يقرر المجدول ما يجب أن يُشغل ومتى.
- العامل (worker) يقوم بالمهمة.
يساعد فصل هذه الأدوار بطريقتين. يمكنك تغيير التوقيت دون لمس منطق العمل، ويمكنك تغيير منطق العمل دون كسر الجدول.
تصبح جدول المهام مصدر الحقيقة. بدلًا من إخفاء الحالة داخل عملية الخادم أو سطر crontab، كل وحدة عمل هي صف: ما الذي يجب فعله، لمن، متى يجب تشغيله، وماذا حدث في المرة السابقة. عندما يخطئ شيء ما، يمكنك فحصه أو إعادة محاولته أو إلغاؤه دون تخمين.
تدفق نموذجي يبدو هكذا:
- يفحص المجدول المهام المستحقة (على سبيل المثال
run_at <= nowوstatus = queued). - يطالب بالمهمة بحيث يأخذها عامل واحد فقط.
- يقرأ العامل تفاصيل المهمة وينفذ الإجراء.
- يسجل العامل النتيجة مرة أخرى في نفس الصف.
الفكرة الأساسية هي جعل العمل قابلًا للاستئناف، لا سحريًا. إذا تعطل العامل في منتصف العملية، يجب أن لا يزال صف المهمة يخبرك بما حدث وماذا تفعل بعد ذلك.
تصميم جدول مهام يبقى مفيدًا
يجب أن يجيب جدول المهام على سؤالين بسرعة: ما الذي يحتاج للتشغيل بعد ذلك، وماذا حدث في المرة السابقة.
ابدأ بمجموعة صغيرة من الحقول التي تغطي الهوية، التوقيت، والتقدّم:
- id, type: مُعرّف فريد مع نوع قصير مثل
send_reminderأوdaily_summary. - payload: JSON مُحقق يحتوي فقط ما يحتاجه العامل (مثل
user_id، وليس كائن المستخدم كله). - run_at: متى تصبح المهمة مؤهلة للتشغيل.
- status:
queued,running,succeeded,failed,canceled. - attempts: يزيد في كل محاولة.
ثم أضف بعض الأعمدة التشغيلية التي تجعل التزامن آمنًا والحوادث أسهل في المعالجة. locked_at, locked_by, و locked_until تتيح لعامل واحد أن يطالب بالمهمة حتى لا تُشغّل مرتين. last_error يجب أن يكون رسالة قصيرة (ورمز خطأ اختياري)، لا تفريغ تتبُّع كامل يدفع الصفوف للانتفاخ.
أخيرًا، احتفظ بالطوابع الزمنية التي تفيد الدعم والتقارير: created_at, updated_at, و finished_at. هذه تتيح الإجابة على أسئلة مثل “كم عدد التذكيرات التي فشلت اليوم؟” دون الحفر في السجلات.
الفهارس مهمة لأن النظام يسأل باستمرار “ما التالي؟” اثنان منها عادة ما يستحقان العناء:
(status, run_at)لجلب المهام المستحقة بسرعة(type, status)لفحص أو إيقاف فئة مهام أثناء المشاكل
بالنسبة للـ payloads، فضّل JSON صغير ومركّز وحققه قبل إدخال المهمة. خزّن المعرفات والمعاملات، لا لقطات من بيانات العمل. عامل شكل الـ payload كعقد واجهة برمجة تطبيقات بحيث تظل المهام القديمة قيد الانتظار تعمل بعد تغيّر التطبيق.
دورة حياة المهمة: الحالات، الأقفال، وعدم التكرار
يبقى مشغل المهام موثوقًا عندما تتبع كل مهمة دورة حياة صغيرة ومتوقعة. تلك الدورة هي شبكة الأمان عندما يبدأ عاملان في نفس الوقت، يعيد تشغيل الخادم في منتصف التشغيل، أو تحتاج إلى إعادة محاولة دون خلق تكرارات.
آلة حالات بسيطة عادةً كافية:
- queued: جاهز للتشغيل في أو بعد
run_at - running: مُطالب بواسطة عامل
- succeeded: انتهى ولا يجب تشغيله مجددًا
- failed: انتهى بخطأ ويحتاج انتباهاً
- canceled: أُوقف عمدًا (مثلاً المستخدم ألغى الاشتراك)
المطالبة بالمهمات دون عمل مزدوج
لمنع التكرارات، يجب أن تكون مطالبة المهمة ذرية. النهج الشائع هو قفل بمهلة (عهد): يطالب العامل بالمهمة عن طريق تعيين status=running وكتابة locked_by و locked_until. إذا تعطل العامل، تنتهي صلاحية القفل ويمكن لعامل آخر أن يستولي عليها.
مجموعة قواعد عملية للمطالبة:
- طالب فقط بالمهام الموقوفة والجاهزة حيث
run_at <= now - عيّن
status,locked_by, وlocked_untilفي نفس التحديث - أعد استيلاء المهام الجارية فقط عندما يكون
locked_until < now - اجعل العهد قصيرًا ووسّعه إذا كانت المهمة طويلة
عدم التكرار (العادة التي تنقذك)
عدم التكرار يعني: إذا نُفِّذت نفس المهمة مرتين، تكون النتيجة صحيحة على أي حال.
أبسط أداة هي مفتاح فريد. على سبيل المثال، لملخص يومي يمكنك فرض مهمة واحدة لكل مستخدم لكل يوم بمفتاح مثل summary:user123:2026-01-25. إذا حدث إدراج مكرر، فإنه يشير إلى نفس المهمة بدلاً من إنشاء مهمة ثانية.
علِّم بالنجاح فقط عند اكتمال التأثير الجانبي فعلاً (مثل إرسال البريد، تحديث السجل). إذا أعدت المحاولة، يجب ألا يخلق مسار المحاولة بريدًا ثانيًا أو كتابة مكررة.
إعادة المحاولات ومعالجة الإخفاقات بدون دراما
هنا تتحول أنظمة المهام إما إلى أنظمة جديرة بالثقة أو إلى ضوضاء. الهدف واضح: أعد المحاولة عندما يكون الفشل مؤقتًا مرجحًا، وتوقّف عندما لا يكون كذلك.
سياسة إعادة المحاولة الافتراضية عادةً تتضمن:
- الحد الأقصى للمحاولات (مثلاً 5 محاولات إجمالية)
- استراتيجية التأخير (تأخير ثابت أو تراجع أسّي)
- شروط الإيقاف (لا تُعيد المحاولة في أخطاء "مدخلات غير صالحة")
- التشتت (فرق عشوائي صغير لتجنّب ذروة المحاولات)
بدلًا من اختراع حالة جديدة لإعادة المحاولات، يمكنك غالبًا إعادة استخدام queued: ضع run_at للموعد التالي للمحاولة وضع المهمة مرة أخرى في الطابور. هذا يبقي آلة الحالات صغيرة.
عندما يمكن للمهمة أن تحقق تقدمًا جزئيًا، عامل ذلك كأمر طبيعي. خزّن نقطة تحقق حتى يمكن للمحاولة المتجددة أن تستمر بأمان، إما في payload المهمة (مثل last_processed_id) أو في جدول مرتبط.
مثال: مهمة ملخص يومي تولّد رسائل لـ 500 مستخدم. إذا فشلت عند المستخدم رقم 320، خزّن آخر معرف مستخدم ناجح وأعد المحاولة من 321. إذا خزنت أيضًا سجل summary_sent لكل مستخدم لكل يوم، يمكن لإعادة التشغيل أن تتخطى المستخدمين الذين أُرسل لهم بالفعل.
تسجيل يساعد فعلاً
سجّل ما يكفي لتتمكن من التصحيح في دقائق:
- معرف المهمة، النوع، ورقم المحاولة
- المدخلات الأساسية (معرّف المستخدم/الفريق، نطاق التاريخ)
- التوقيت (started_at, finished_at, next run time)
- ملخص خطأ قصير (بالإضافة لتتبع الاستثناء إذا توفر)
- عدد التأثيرات الجانبية (بريد مُرسل، صفوف محدثة)
خطوة بخطوة: بناء حلقة مجدول بسيطة
حلقة المجدول عملية صغيرة تستيقظ بنمط ثابت، تبحث عن العمل المستحق، وتسلمه. الهدف هو موثوقية مملة، ليس توقيتًا مثاليًا. بالنسبة للعديد من التطبيقات، "الاستيقاظ كل دقيقة" يكفي.
اختر تكرار الاستيقاظ اعتمادًا على مدى حساسية الزمن للمهام وكمية الحمل التي يتحمّلها قاعدة بياناتك. إذا كانت التذكيرات يجب أن تكون في الوقت الحقيقي تقريبًا، شغّل كل 30 إلى 60 ثانية. إذا كانت الملخصات اليومية يمكن أن تتحرك قليلًا، فكل 5 دقائق يكفي ويكون أرخص.
حلقة بسيطة:
- استيقظ واحصل على الوقت الحالي (استخدم UTC).
- اختر المهام المستحقة حيث
status = 'queued'وrun_at <= now. - اطلب المهام بأمان حتى يأخذها عامل واحد فقط.
- سلّم كل مهمة مُطالبة إلى عامل.
- نم حتى الضربة التالية.
خطوة المطالبة هي المكان الذي يكسر فيه العديد من الأنظمة. تريد وضع running (وتخزين locked_by و locked_until) في نفس المعاملة التي تُحدد بها. العديد من قواعد البيانات تدعم عمليات القراءة بـ “skip locked” بحيث يمكن لمُجَدِلات متعددة أن تعمل دون أن تتداخل.
-- concept example
BEGIN;
SELECT id FROM jobs
WHERE status='queued' AND run_at <= NOW()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE jobs
SET status='running', locked_until=NOW() + INTERVAL '5 minutes'
WHERE id IN (...);
COMMIT;
احتفظ بحجم الدُفعة صغيرًا (مثل 50 إلى 200). الدُّفعات الأكبر يمكن أن تُبطئ قاعدة البيانات وتجعل الأعطال أكثر إيلامًا.
إذا تعطل المجدول في منتصف الدفعة، ينقذك العهد. تصبح المهام العالقة في running مؤهلة مرة أخرى بعد locked_until. يجب أن يكون العامل معوَّمًا بحيث لا يخلق استيلاءً جديدًا يؤدي إلى رسائل مزدوجة أو رسوم مزدوجة.
أنماط للتذكيرات، الملخصات اليومية، والتنظيف
تنتهي معظم الفرق بنفس ثلاثة أنواع من الأعمال الخلفية: رسائل يجب أن تُرسل في الوقت المناسب، تقارير تعمل بجدول ثابت، وتنظيف يحافظ على صحة التخزين والأداء. نفس جدول المهام وحلقة العامل يمكن أن تتعامل مع جميعها.
التذكيرات
للتذكيرات، خزّن كل ما يلزم لإرسال الرسالة في صف المهمة: لمن تُوجَّه، أي قناة (بريد إلكتروني، SMS، Telegram، داخل التطبيق)، أي قالب، ووقت الإرسال الدقيق. يجب أن يتمكن العامل من تشغيل المهمة دون "البحث" عن سياق إضافي.
إذا كانت العديد من التذكيرات مستحقة في نفس الوقت، أضف تحديدًا للحدّ من المعدل. حدد سقف الرسائل في الدقيقة لكل قناة ودع المهام الزائدة تنتظر التشغيل التالي.
الملخصات اليومية
تفشل الملخصات اليومية عندما تكون نافذة الزمن غامضة. اختر وقت قطع ثابت (مثلاً 08:00 في وقت المستخدم المحلي)، وعرّف النافذة بوضوح (مثلاً "أمس 08:00 إلى اليوم 08:00"). خزّن وقت القطع والمنطقة الزمنية للمستخدم مع المهمة حتى تُنتج عمليات الإعادة نفس النتيجة.
اجعل كل مهمة ملخص صغيرة. إذا كانت بحاجة لمعالجة آلاف السجلات، قسمها إلى قطع (لكل فريق، لكل حساب، أو بنطاق معرف) وأدرج مهام متابعة.
مهام التنظيف
يكون التنظيف أكثر أمانًا عندما تفصل "الحذف" عن "الأرشفة." قرر ما يمكن إزالته نهائيًا (رموز مؤقتة، جلسات منتهية) وما يجب أرشفته (سجلات تدقيق، فواتير). نفّذ التنظيف في دفعات متوقعة لتجنّب الأقفال الطويلة وذروة الأحمال المفاجئة.
الوقت والمناطق الزمنية: المصدر الخفي للأخطاء
العديد من الإخفاقات هي أخطاء زمنية: تذهب تذكرة تذكير قبل ساعة، يفوت الملخص اليومي يوم الإثنين، أو يعمل التنظيف مرتين.
الإفتراضي الجيد هو تخزين الطوابع الزمنية للجدولة في UTC وتخزين منطقة المستخدم الزمنية بشكل منفصل. يجب أن يكون run_at لحظة UTC واحدة. عندما يقول المستخدم "9:00 صباحًا بتوقيته"، حوّله إلى UTC عند الجدولة.
توفّر التوقيت الصيفي حيث تفشل الإعدادات الساذجة. "كل يوم في 9:00" ليست نفسها "كل 24 ساعة". عند تغيّرات DST، يربط 9:00 صباحًا بزمن UTC مختلف، وبعض الأوقات المحلية قد لا توجد (الانتقال للأمام) أو تحدث مرتين (العودة للخلف). النهج الأكثر أمانًا هو حساب الحدوث المحلي التالي في كل مرة تعيد فيها الجدولة، ثم تحويله إلى UTC مرة أخرى.
لملخص يومي، قرر ما الذي يعنيه "يوم" قبل كتابة الشيفرة. اليوم التقويمي (منتصف الليل إلى منتصف الليل في المنطقة الزمنية للمستخدم) يطابق توقعات البشر. "آخر 24 ساعة" أبسط لكنه ينحرف ويُفاجئ الناس.
البيانات المتأخرة حتمية: حدث يصل بعد إعادة المحاولة، أو يُضاف ملاحظة بعد منتصف الليل بقليل. قرّر ما إذا كانت الأحداث المتأخرة تنتمي إلى "أمس" (بفترة سماح) أو إلى "اليوم"، وابقَ على هذا القاعدة متسقًا.
وسادة عملية يمكن أن تمنع الفقدان:
- افحص المهام المستحقة حتى قبل 2 إلى 5 دقائق
- اجعل المهمة غير متكررة بحيث تكون إعادة التشغيل آمنة
- سجّل نطاق الوقت المغطى في payload حتى تبقى الملخصات متسقة
أخطاء شائعة تسبب تشغيلًا فات أو مكرر
معظم المشاكل تأتي من افتراضات متوقعة.
الأكبر هو افتراض تنفيذ "مرة واحدة بالضبط". في أنظمة حقيقية، يعيد العامل تشغيل نفسه، وتتعطل الشبكات، وتنفد نداءات الشبكة، وقد تُفقد الأقفال. عادة ما تحصل على توصيل "مرَّة على الأقل"، ما يعني أن التكرارات طبيعية ويجب أن يتحملها كودك.
آخر خطأ هو تنفيذ الآثار أولًا (إرسال بريد، خصم بطاقة) دون فحص تمييز التكرار. غالبًا ما يحل حارس بسيط المشكلة: طابع sent_at، مفتاح فريد مثل (user_id, reminder_type, date), أو رمز تمييز مخزن.
الرؤية هي الفجوة التالية. إذا لم تستطع الإجابة على "ما المتوقف، منذ متى، ولماذا؟" ستنتهي بالتخمين. الحد الأدنى من البيانات التي يجب الاحتفاظ بها قريبًا هي الحالة، عدد المحاولات، الوقت المجدول التالي، آخر خطأ، ومعرف العامل.
الأخطاء التي تظهر غالبًا:
- تصميم المهام كما لو أنها تُشغّل مرة واحدة تمامًا، ثم تُفاجأ بالتكرارات
- كتابة تأثيرات جانبية دون فحص عدم التكرار
- تشغيل مهمة ضخمة تحاول فعل كل شيء وتصل إلى مهل زمنية في منتصف الطريق
- إعادة المحاولة إلى الأبد بدون حد
- تخطي رؤية الطابور الأساسية (لا رؤية واضحة للانتظار، الفشل، البنود طويلة التشغيل)
مثال ملموس: مهمة ملخص يومي تكرّر عبر 50,000 مستخدم وتنتهي مهلة التنفيذ عند المستخدم 20,000. عند إعادة المحاولة، تبدأ من البداية وترسل الملخصات مرة أخرى لأول 20,000 ما لم تتتبع إكمال كل مستخدم أو تقسم المهمة إلى مهام لكل مستخدم.
قائمة تحقق سريعة لنظام مهام موثوق
مشغل المهام "مكتمل" فقط عندما يمكنك الوثوق به في الساعة 2 صباحًا.
تأكد من أن لديك:
- رؤية الطابور: أعداد للمهام الموقوفة مقابل الجارية مقابل الفاشلة، بالإضافة إلى أقدم مهمة موقوفة.
- عدم التكرار بطبيعة الحال: افترض أن كل مهمة قد تُشغّل مرتين؛ استخدم مفاتيح فريدة أو علامات "تمت المعالجة بالفعل".
- سياسة إعادة المحاولة حسب نوع المهمة: محاولات، تراجع، وشروط إيقاف واضحة.
- تخزين وقت متسق: احتفظ بـ
run_atفي UTC؛ حوّل فقط عند الإدخال والعرض. - أقفال قابلة للاسترداد: عهد حتى لا تترك الأعطال مهامًا تعمل إلى الأبد.
كما حدِّد حجم الدُفعة (كم عدد المهام التي تطالب بها دفعة واحدة) وتزامن العاملين (كم عدد المهام التي تُشغَّل في نفس الوقت). بدون حدود، قد يحمّل ارتفاع مفاجئ قاعدة البيانات أو يحرِم أعمالًا أخرى من الموارد.
مثال واقعي: تذكيرات وملخصات لفريق صغير
أداة SaaS صغيرة لديها 30 حساب عميل. كل حساب يريد شيئين: تذكير الساعة 9:00 صباحًا لأي مهام مفتوحة، وملخص يومي الساعة 6:00 مساءً لما تغيّر اليوم. كما يحتاجون تنظيفًا أسبوعيًا حتى لا تمتلئ قاعدة البيانات بسجلات قديمة ورموز منتهية.
يستخدمون جدول مهام بالإضافة إلى عامل يفحص المهام المستحقة. عند تسجيل عميل جديد، يقوم الخادم بجدولة أول تشغيل للتذكير والملخص بناءً على المنطقة الزمنية للعميل.
تُنشأ المهام في لحظات شائعة: عند التسجيل (إنشاء جداول متكررة)، عند أحداث معينة (إدراج إشعارات لمرة واحدة)، عند نبضة الجدولة (إدراج التشغيل القادم)، وعند يوم الصيانة (إدراج التنظيف).
في أحد أيام الثلاثاء، يتعرض مزود البريد لعطل مؤقت الساعة 8:59 صباحًا. يحاول العامل إرسال التذكيرات، يحصل على مهلة، ويعيد جدولة تلك المهام بتعيين run_at باستخدام تراجع (مثلاً، 2 دقيقة، ثم 10، ثم 30)، مُزيدًا attempts في كل مرة. لأن كل مهمة تذكير لديها مفتاح عدم تكرار مثل account_id + date + job_type، لا تُولِّد المحاولات نتائج مُكررة إذا تعافى المزود منتصف العملية.
يعمل التنظيف أسبوعيًا في دفعات صغيرة، حتى لا يعيق الأعمال الأخرى. بدلًا من حذف مليون صف في مهمة واحدة، يحذف حتى N صفوف في كل تشغيل ويعيد جدولة نفسه حتى الاكتمال.
عندما يشتكي عميل "لم أستلم ملخصي أبداً"، يفحص الفريق جدول المهام لذلك الحساب واليوم: حالة المهمة، عدد المحاولات، حقول القفل الحالية، وآخر خطأ رجعه المزود. هذا يحوّل "كان يجب أن يُرسل" إلى "إليك ما حدث بالضبط."
الخطوات التالية: نفّذ، راقب، ثم قم بالتوسيع
اختر نوع مهمة واحد وبنِها من البداية للنهاية قبل إضافة المزيد. مهمة تذكير واحدة جيدة كبداية لأنها تلامس كل الجوانب: الجدولة، مطالبة العمل المستحق، إرسال رسالة، وتسجيل النتيجة.
ابدأ بإصدار يمكنك الوثوق به:
- أنشئ جدول المهام وعاملًا واحدًا يعالج نوع مهمة واحد
- أضف حلقة مجدول تطالب وتشغّل المهام المستحقة
- خزّن payload كافٍ لتشغيل المهمة دون تخمين إضافي
- سجّل كل محاولة ونتيجة بحيث يصبح سؤال "هل شُغل؟" سؤالًا يستغرق 10 ثوانٍ للإجابة
- أضف مسار إعادة تشغيل يدوي للمهام الفاشلة حتى لا يتطلب الاسترداد نشرًا جديدًا
بمجرّد تشغيله، اجعله قابلاً للملاحظة بالنسبة للبشر. حتى عرض إدارة أساسي يدفع ثمنه سريعًا: ابحث في المهام بحسب الحالة، صنّف بحسب الوقت، عاين payload، ألغِ مهمة عالقة، أعد تشغيل مهمة بمعرف محدد.
إذا فضلت بناء هذا النوع من التدفق باستخدام منطق خلفي بصري، AppMaster (appmaster.io) يمكنه نمذجة جدول المهام في PostgreSQL وتنفيذ حلقة المطالبة-المعالجة-التحديث كـ Business Process مع الاستمرار في توليد شيفرة مصدر حقيقية للنشر.


