تعرّف على نمط cron + قاعدة البيانات لتشغيل مهام مجدولة بخلفية مع إعادة محاولات، أقفال، وقابلية التكرار — دون نشر نظام صف كامل.

معظم التطبيقات تحتاج إلى تنفيذ عمل لاحقًا أو وفق جدول: إرسال رسائل متابعة، إجراء فحص فوترة ليلي، تنظيف سجلات قديمة، إعادة بناء تقرير، أو تحديث ذاكرة مؤقتة.
في البداية، قد تميل إلى إضافة نظام صف كامل لأنه يبدو "الطريقة الصحيحة" لتنفيذ المهام الخلفية. لكن أنظمة الطوابير تضيف أجزاء متحركة: خدمة إضافية لتشغيلها، مراقبتها، نشرها وتصحيح أخطائها. لفريق صغير (أو مؤسس وحيد)، هذا الوزن الزائد قد يبطئك.
إذًا السؤال الحقيقي هو: كيف تشغّل عملًا مجدولًا بشكل موثوق دون إعداد بنية تحتية إضافية؟
محاولة أولى شائعة بسيطة: أضف إدخال cron يستدعي نقطة نهاية، ودع تلك النقطة تقوم بالعمل. هذا ينجح حتى لا ينجح. عندما يكون لديك أكثر من خادم، أو نشر في توقيت غير مناسب، أو مهمة تستغرق وقتًا أطول من المتوقع، تبدأ برؤية أخطاء مربكة.
العمل المجدول عادةً ينهار بعدة طرق متوقعة:
نمط cron + قاعدة البيانات هو طريق وسط. لا تزال تستخدم cron لـ"إيقاظ" العامل وفق جدول، لكنك تخزن نية المهمة وحالتها في قاعدة البيانات حتى يستطيع النظام التنسيق، إعادة المحاولة، وتسجيل ما حدث.
يناسب هذا عندما يكون لديك قاعدة بيانات واحدة (غالبًا PostgreSQL)، وعدد صغير من أنواع المهام، وتريد سلوكًا متوقعًا مع أقل عمل تشغيلي. كما أنه خيار طبيعي للتطبيقات المبنية بسرعة على أُطُر حديثة (على سبيل المثال، إعداد React + Go + PostgreSQL).
ليس مناسبًا عندما تحتاج إلى معدل تمرير عالٍ جدًا، مهام طويلة المدى تتطلب بث التقدم، ترتيب صارم عبر أنواع مهام عديدة، أو توزيع واسع (آلاف المهام الفرعية في الدقيقة). في تلك الحالات، عادةً ما يدفعك نظام طابور حقيقي وعاملين مخصصين إلى تحسين الأداء.
نمط cron + قاعدة البيانات يشغّل العمل الخلفي مجدولًا دون تشغيل نظام طوابير كامل. لا تزال تستخدم cron (أو أي مجدول)، لكن cron لا يقرر ما الذي يُشغل. هو فقط يوقظ العامل كثيرًا (مرة في الدقيقة شائعة). قاعدة البيانات هي التي تقرر أي عمل مستحق وتتأكد من أن عاملًا واحدًا فقط يأخذ كل مهمة.
فكر فيه كقائمة مشتركة على سبورة بيضاء. cron هو الشخص الذي يدخل الغرفة كل دقيقة ويقول: "هل هناك شيء يجب القيام به الآن؟" وقاعدة البيانات هي السبورة التي تُظهر ما هو مستحق، وما الذي أُخذ بالفعل، وما الذي انتهى.
المكونات بسيطة:
مثال: تريد إرسال تذكيرات الفواتير كل صباح، تحديث ذاكرة مؤقتة كل 10 دقائق، وتنظيف الجلسات القديمة ليلاً. بدلًا من ثلاثة أوامر cron منفصلة، تخزن مدخلات المهام في مكان واحد. يقوم cron بتشغيل نفس عملية العامل. العامل يسأل Postgres: "ما المستحق الآن؟" وPostgres يجيب بترك العامل يستولي بأمان على مهمة واحدة في كل مرة.
يمكن أن يتوسع هذا تدريجيًا. يمكنك البدء بعامل واحد على خادم واحد. لاحقًا يمكنك تشغيل خمسة عمال عبر خوادم متعددة. يبقى العقد نفسه: الجدول هو العقد.
التحول العقلي بسيط: cron هو مجرد ناقوس إيقاظ. قاعدة البيانات هي الشرطي التي تقرر ما المسموح تشغيله، تسجل ما حدث، وتعطيك سجلًا واضحًا عند حدوث خطأ.
هذا النمط يعمل بشكل أفضل عندما تصبح قاعدة بياناتك مصدرًا للحقيقة حول ما يجب أن يُشغل، ومتى، وماذا حدث آخر مرة. المخطط ليس معقّدًا، لكن تفاصيل صغيرة (حقول القفل والفهارس الصحيحة) تصنع فرقًا كبيرًا مع نمو الحمل.
نهجان شائعان:
إذا كنت تتوقع أن تصحح الأخطاء كثيرًا، احتفظ بالتاريخ. إذا أردت أبسط إعداد ممكن، ابدأ بجدول واحد وأضف التاريخ لاحقًا.
فيما يلي تخطيط مناسب لـ PostgreSQL. إذا كنت تبني بـ Go وPostgres، هذه الأعمدة تربط جيدًا بهياكل.
-- What should exist (the definition)
create table job_definitions (
id bigserial primary key,
job_type text not null,
payload jsonb not null default '{}'::jsonb,
schedule text, -- optional: cron-like text if you store it
max_attempts int not null default 5,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- What should run (each run / attempt group)
create table job_runs (
id bigserial primary key,
definition_id bigint references job_definitions(id),
job_type text not null,
payload jsonb not null default '{}'::jsonb,
run_at timestamptz not null,
status text not null, -- queued | running | succeeded | failed | dead
attempts int not null default 0,
max_attempts int not null default 5,
locked_by text,
locked_until timestamptz,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
بعض التفاصيل التي تُجنبك المتاعب لاحقًا:
send_invoice_emails).jsonb حتى تتمكن من تطويره دون هجرات.بدون فهارس، سيقوم العمال بمسح الكثير من الصفوف. ابدأ بـ:
(status, run_at)(locked_until)queued و failed)هذه تحافظ على أن استعلام "إيجاد المهمة التالية القابلة للتشغيل" سريعًا حتى مع نمو الجدول.
الهدف بسيط: يمكن لعديد من العمال العمل، لكن عاملًا واحدًا فقط يجب أن يستحوذ على مهمة معينة. إذا عالج عاملان نفس الصف، ستحصل على رسائل مزدوجة، رسوم مزدوجة، أو بيانات فوضوية.
نهج آمن هو معاملة مطالبة المهمة كـ "إيجار". العامل يعلِم المهمة بأنها مقفولة لفترة قصيرة. إذا تعطل العامل، تنتهي مدة الإيجار ويستطيع عامل آخر أخذها. هذا ما يهدف إليه locked_until.
بدون إيجار، قد يقفل عامل مهمة ولا يفتحها أبدًا (عملية قُتلت، إعادة تشغيل الخادم، نشر فاشل). مع locked_until تصبح المهمة متاحة مجددًا عند مرور الوقت.
قاعدة نموذجية: يمكن المطالبة بالمهمة عندما يكون locked_until NULL أو locked_until <= now().
التفصيل الرئيس هو مطالبة المهمة في بيان واحد ذري (أو في معاملة واحدة). تريد أن تكون قاعدة البيانات هي الحكم.
فيما يلي نمط شائع في PostgreSQL: اختر مهمة مستحقة، اقفلها، وارجعها للعامل. (هذا المثال يستخدم جدول jobs واحد؛ نفس الفكرة تنطبق إن كنت تطالب من job_runs.)
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at <= now()
AND (locked_until IS NULL OR locked_until <= now())
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
locked_until = now() + interval '2 minutes',
locked_by = $1,
attempts = attempts + 1,
updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
لماذا ينجح:
FOR UPDATE SKIP LOCKED يسمح لعدة عمال بالتنافس دون حجب بعضهم البعض.RETURNING تعطي الصف للعامل الذي فاز بالسباق.اضبط الإيجار أطول من مدة التشغيل العادية، لكن قصيرًا بما يكفي لتعافي من تعطل بسرعة. إذا انتهت معظم المهام في 10 ثوانٍ، فإيجار مدته دقيقتان كافٍ.
للمهام الطويلة، جدد الإيجار أثناء العمل (نبض قلب). نهج بسيط: كل 30 ثانية مدد locked_until إذا كنت لا تزال تملك المهمة.
WHERE id = $job_id AND locked_by = $worker_idهذا الشرط الأخير مهم. يمنع العامل من تمديد إيجار مهمة لم يعد يملكها.
إعادة المحاولات هي النقطة التي يشعر فيها هذا النمط بالهدوء أو يتحول إلى فوضى صاخبة. الهدف بسيط: عندما تفشل مهمة، حاول مرة أخرى لاحقًا بطريقة يمكنك شرحها وقياسها وإيقافها.
ابدأ بجعل حالة المهمة صريحة ومحدودة: queued, running, succeeded, failed, dead. عمليًا، معظم الفرق تستخدم failed بمعنى "فشلت ولكن ستعاد المحاولة" وdead بمعنى "فشلت وتوقفنا عن المحاولة". هذا التمييز يمنع الحلقات اللانهائية.
عد المحاولات هو الحاجز الثاني. خزّن attempts (كم مرة حاولت) وmax_attempts (عدد المرات المسموح بها). عندما يلتقط العامل خطأً، يجب أن:
attemptsfailed إذا كان attempts < max_attempts وإلا deadrun_at للمحاولة التالية (فقط للحالة failed)التراجع (backoff) هو القاعدة التي تقرر run_at التالي. اختر واحدة ووثقها وحافظ على اتساقها:
الجِتر مهم عندما يعود اعتماد خارجي للعمل بعد انقطاع. بدونه، قد تعيد مئات المهام المحاولة معًا وتفشل مرة أخرى.
خزن تفاصيل خطأ كافية لتجعل الفشل ظاهرًا وسهل التصحيح. لا تحتاج نظام سجلات كامل، لكنك تحتاج الأساسيات:
last_error (رسالة قصيرة، آمنة للعرض في شاشة الإدارة)error_code أو error_type (يساعد في التجميع)failed_at و next_run_atlast_stack (إن تحكم في الحجم)قاعدة عملية تعمل جيدًا: اجعل المهام dead بعد 10 محاولات، واستخدم تراجعًا أُسيًا مع jitter. هذا يبقي الأخطاء العابرة تحاول، ويمنع المهام المكسورة من استهلاك وحدة المعالجة للأبد.
قابلية التكرار (idempotency) تعني أن تشغيل المهمة مرتين يجب أن يُنتج نفس النتيجة النهائية. في هذا النمط، تهم لأن نفس الصف قد يُنتخب مرة أخرى بعد تعطل، نفاد مهلة، أو إعادة محاولة. إذا كانت مهمتك "إرسال بريد فاتورة"، فالتشغيل مرتين ليس آمنًا افتراضيًا.
طريقة عملية للتفكير: قسّم كل مهمة إلى (1) إجراء العمل، و(2) تطبيق التأثير. تريد أن يحدث التأثير مرة واحدة فقط، حتى لو حاولت العمل عدة مرات.
ينبغي أن يأتي مفتاح قابلية التكرار من ما تمثله المهمة، لا من محاولة العامل. المفاتيح الجيدة ثابتة وسهلة التفسير، مثل invoice_id, user_id + day, أو report_name + report_date. إذا أشارت محاولتا مهمة لنفس الحدث الواقعي، يجب أن تشاركا نفس المفتاح.
مثال: "توليد تقرير مبيعات يومي ل2026-01-14" يمكن أن يستخدم sales_report:2026-01-14. "تحصيل فاتورة 812" يمكن أن يستخدم invoice_charge:812.
أبسط حاجز هو ترك PostgreSQL يرفض التكرارات. خزّن مفتاح قابلية التكرار في مكان يمكن فهرسته، ثم أضف قيد فريد.
-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;
CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;
هذا يمنع وجود صفين بنفس المفتاح في نفس الوقت. إذا سمحت تصميماتك بعدة صفوف للتاريخ، ضع الفريدة على جدول "تأثيرات" بدلًا من ذلك، مثل sent_emails(idempotency_key) أو payments(idempotency_key).
آثار جانبية شائعة يجب حمايتها:
sent_emails بمفتاح فريد قبل الإرسال، أو سجِّل معرف رسالة المزود بعد الإرسال.delivered_webhooks(event_id) وتخطَّ إذا كان موجودًا.file_generated المفهرس بـ (type, date).إذا بنتَ على Stack مدعوم بـ Postgres (على سبيل المثال، واجهة خلفية Go + PostgreSQL)، فهذه الفحوصات الفريدة سريعة وسهلة الإبقاء قرب البيانات. الفكرة الأساسية بسيطة: إعادة المحاولات طبيعية، التكرارات اختيارية.
اختر بيئة تنفيذ مملة وابقَ عليها. الهدف من هذا النمط هو عدد أقل من الأجزاء المتحركة، لذا عملية صغيرة بـ Go أو Node أو Python تتكلم إلى PostgreSQL عادةً تكفي.
أنشئ الجداول والفهارس. أضف جدول jobs (وزد أي جداول مرجعية تريد لاحقًا)، ثم فهرس run_at، وأضف فهرسًا يساعد العامل على إيجاد المهام المتاحة سريعًا (مثلاً على (status, run_at)).
اكتب دالة إدراج بسيطة. يجب أن يُدرج تطبيقك صفًا مع run_at مضبوطًا على "الآن" أو زمن مستقبلي. اجعل الحمولة صغيرة ومتوقعة (معرّفات ونوع المهمة، ليس كتل كبيرة).
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running في نفس المعاملة.WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
عالج وأنهِ المهمة. لكل مهمة مُطالَب بها، قم بالعمل ثم حدّث إلى succeeded مع finished_at. إذا فشلت، سجّل رسالة خطأ وأعدها إلى queued مع run_at جديد (تراجع). اجعل تحديثات إنهاء المهمة صغيرة ودائمًا نفّذها، حتى لو كانت عمليتك تغلق.
أضف قواعد إعادة المحاولة القابلة للشرح. استخدم صيغة بسيطة مثل run_at = now() + (attempts^2) * interval '10 seconds'، وتوقف بعد max_attempts بتعيين status = 'dead'.
لا تحتاج لوحة تحكم كاملة في اليوم الأول، لكن تحتاج ما يكفي لملاحظة المشاكل.
إذا كنت بالفعل على ستاك Go + PostgreSQL، فإن هذا يترجم بسلاسة إلى ثنائي عامل واحد زائد cron.
تخيل تطبيق SaaS صغير بعملين مجدولين:
اجعلها بسيطة: جدول PostgreSQL واحد للاحتفاظ بالمهام، وعامل واحد يعمل كل دقيقة (مُشغّل عبر cron). العامل يطالب بالمهام المستحقة، ينفذها، ويسجل النجاح أو الفشل.
يمكنك إدراج المهام من عدة أماكن:
cleanup_nightly لليوم.send_weekly_report لموعد الإثنين التالي للمستخدم.send_weekly_report ليعمل فورًا لنطاق تاريخ معين.الحمولة هي الحد الأدنى الذي يحتاجه العامل. اجعلها صغيرة لتكون سهلة الإعادة.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
قد ينهار العامل في أسوأ لحظة: فورًا بعد إرسال البريد، لكن قبل وسم المهمة على أنها "منتهية". عند إعادة التشغيل قد يلتقط نفس المهمة مرة أخرى.
لمنع الإرسال المزدوج، امنح العمل مفتاح إلغاء التكرار الطبيعي وخزّنه حيث يمكن لقاعدة البيانات فرضه. لتقارير أسبوعية، مفتاح جيد هو (user_id, week_start_date). قبل الإرسال، يسجل العامل "سأرسل التقرير X". إذا كان هذا السجل موجودًا بالفعل، يتخطى الإرسال.
يمكن أن يكون هذا بسيطًا كجدول sent_reports مع قيد فريد على (user_id, week_start_date), أو مفتاح idempotency_key فريد على المهمة نفسها.
افترض أن مزود البريد يتأخر بالاستجابة. تفشل المهمة، فيقوم العامل بـ:
attemptsإذا استمرت الفشل بعد الحد المسموح (مثل 10 محاولات)، علّمها كـ "dead" وتوقف عن المحاولة. إما أن تنجح المهمة مرة واحدة، أو تعيد المحاولة وفق جدول واضح، وقابلية التكرار تجعل إعادة المحاولة آمنة.
النمط بسيط، لكن الأخطاء الصغيرة قد تحولها إلى تكرارات، مهام معلقة، أو حمل مفاجئ. معظم المشكلات تظهر بعد أول تعطل، نشر، أو ارتفاع مفاجئ في الحمل.
معظم الحوادث الحقيقية تأتي من فخاخ قليلة:
locked_until. إذا تعطل عامل بعد المطالبة، قد يبقى الصف "قيد التنفيذ" إلى الأبد. طابع الإيجار يسمح لعامل آخر بأخذه لاحقًا.user_id, invoice_id, أو مفتاح ملف) واسترجع الباقي عند التشغيل.مثال: ترسل بريد فاتورة أسبوعي. إذا انتهت مهلة العامل بعد الإرسال وقبل وسم المهمة منتهية، قد تُعاد المحاولة وتُرسل نسخة مكررة. هذا طبيعي في هذا النمط ما لم تضف حاجزًا (مثلاً، سجل "البريد المرسل" الفريد المفهرس بمعرّف الفاتورة).
تجنب مزج الجدولة والتنفيذ في نفس معاملة طويلة. إذا أبقيت معاملة مفتوحة أثناء طلبات الشبكة، فإنك تحتفظ بالأقفال لفترة أطول مما يلزم وتمنع العمال الآخرين.
انتبه لفروق الساعة بين الآلات. استخدم وقت قاعدة البيانات (NOW() في PostgreSQL) كمصدر للحقيقة لـ run_at وlocked_until، وليس ساعة خادم التطبيق.
حدد وقت تشغيل أقصى واضح. إذا كانت المهمة قد تستغرق 30 دقيقة، اجعل الإيجار أطول من ذلك وجدد الإيجار إذا لزم الأمر. وإلا قد يلتقطها عامل آخر منتصف التشغيل.
حافظ على جدول المهام صحيًا. إذا تراكمت المهام المكتملة إلى الأبد، تبطئ الاستعلامات ويزيد التنافس على الأقفال. اختر قاعدة احتفاظ بسيطة (أرشفة أو حذف السجلات القديمة) قبل أن يصبح الجدول ضخمًا.
قبل الشحن، تحقق من الأساسيات. سهو صغير هنا عادة ما يتحول إلى مهام معلقة، تكرارات مفاجئة، أو عامل يضغط على قاعدة البيانات.
run_at, status, attempts, locked_until, و max_attempts (بالإضافة إلى last_error أو ما شابه لتتمكن من رؤية ما حدث).invoice_id).max_attempts.إذا كانت هذه النقاط صحيحة، فعادةً ما يكون نمط cron + قاعدة البيانات مستقرًا بما يكفي لأحمال حقيقية.
بعد أن تبدو قائمة التحقق جيدة، ركز على التشغيل اليومي.
run_at = now() ويمسح القفل) و"إلغاء" (ينقل إلى حالة نهائية). هاتان الحركتان توفران وقتًا أثناء الحوادث.status, run_at).إذا أردت بناء هذا الإعداد بسرعة، فإن Koder.ai يمكن أن تساعدك في الانتقال من المخطط إلى تطبيق Go + PostgreSQL مُنشأ مع توصيل أقل يدوي، بينما تركز أنت على قواعد الأقفال، إعادة المحاولة، وقابلية التكرار.
إذا كبرت لاحقًا عن هذا الإعداد، فستكون قد تعلمت دورة حياة المهمة بوضوح، وتلك الأفكار نفسها تُترجم جيدًا إلى نظام طوابير كامل.