هندسة الأنظمة الخلفيةPostgreSQLالأنظمة الموزعةالموثوقيةطوابير المهام

تحتاج طوابير المهام في PostgreSQL إلى إيجارات، لا إلى SKIP LOCKED وحدها

صمّم طابور مهام موثوقًا في PostgreSQL باستخدام مطالبات ذرية، وإيجارات محدودة الصلاحية، ورموز تسييج، وإعادات محاولة محدودة، وقابلية للمراقبة، واختبارات لحقن حالات الفشل.

بقلم Ghassan Aldarwishآخر تحديث 4 أغسطس 20268 دقائق للقراءة
طابور مهام في PostgreSQL يغذي عمّالًا متوازيين عبر بوابة مطالبة، ويعيد فيه إيجار منتهي الصلاحية العمل المتروك إلى الطابور

يُعد FOR UPDATE SKIP LOCKED أداة مفيدة للتحكم في التزامن، لكنه ليس طابور مهام كاملًا. فهو يستطيع منع عاملين من المطالبة بالصف نفسه في معاملة واحدة، لكنه لا يستطيع استعادة العمل بعد تعطل عامل، أو منع عامل قديم من التثبيت متأخرًا، أو تحديد وقت إعادة المحاولة، أو إثبات وقوع أثر جانبي خارجي مرة واحدة.

يحتاج طابور موثوق في PostgreSQL إلى آلة حالات تحيط بهذه الأداة. وينبغي للعامل المطالبة بالمهام ذريًا، والاحتفاظ بإيجار محدود الصلاحية بدلًا من علامة ملكية دائمة، وحمل رمز تسييج في كل عملية إكمال، وترك حالة دائمة تكفي للتعافي والتشغيل.

تطوّر هذه المقالة ذلك التصميم بوصفه معمارية مرجعية. وهي لا تدّعي وجود نشر إنتاجي مقيس.

ينتهي القفل قبل انتهاء العمل#

تصف وثائق PostgreSQL SKIP LOCKED بأنه يقدم رؤية غير متسقة لا تصلح للقراءات العامة، لكنها مفيدة حين يصل عدة مستهلكين إلى جدول يشبه الطابور. وهذا هو الاستخدام المقصود تمامًا: يستطيع العمّال المتزامنون تجاوز الصفوف التي قفلها أقرانهم بدلًا من الانتظار خلفها.

الخطأ المعتاد هو إبقاء معاملة قاعدة البيانات مفتوحة أثناء تنفيذ المهمة:

BEGIN;

SELECT *
FROM jobs
WHERE status = 'pending'
ORDER BY available_at, id
FOR UPDATE SKIP LOCKED
LIMIT 1;

-- call a remote API, render a file, or run a model

DELETE FROM jobs WHERE id = $1;
COMMIT;

يعطي هذا قفل الصف عمر العمل نفسه. وتعني خدمة تابعة بطيئة الآن معاملة طويلة. تحتفظ المعاملات الطويلة بالأقفال، وتؤخر التنظيف، وتستهلك الاتصالات، وتجعل التعافي من الفشل معتمدًا على جلسة في قاعدة البيانات. كما لا تستطيع جعل استدعاء واجهة API بعيدة جزءًا من معاملة PostgreSQL.

الحد الأكثر أمانًا قصير: طالب بالصف وثبّت المعاملة فورًا، ثم يعالج العامل المهمة خارجها. ينشئ ذلك متطلبًا جديدًا؛ فبعد تحرير قفل الصف، يجب أن توضح حالة الطابور الدائمة من يملك المهمة ومتى تنتهي هذه الملكية.

مثّل إيجارًا، لا مطالبة دائمة#

يستطيع مخطط موجز التعبير عن الحالات الأساسية:

CREATE TYPE job_status AS ENUM (
  'pending', 'running', 'succeeded', 'dead'
);

CREATE TABLE jobs (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  queue_name text NOT NULL,
  payload jsonb NOT NULL,
  status job_status NOT NULL DEFAULT 'pending',
  priority integer NOT NULL DEFAULT 0,
  available_at timestamptz NOT NULL DEFAULT now(),
  attempt_count integer NOT NULL DEFAULT 0,
  max_attempts integer NOT NULL DEFAULT 8,
  lease_owner text,
  lease_token bigint NOT NULL DEFAULT 0,
  lease_expires_at timestamptz,
  last_error_code text,
  created_at timestamptz NOT NULL DEFAULT now(),
  finished_at timestamptz
);

CREATE INDEX jobs_claimable_idx
  ON jobs (queue_name, priority DESC, available_at, id)
  WHERE status = 'pending';

CREATE INDEX jobs_expired_lease_idx
  ON jobs (lease_expires_at)
  WHERE status = 'running';

تحوّل lease_expires_at الملكية إلى مطالبة محدودة زمنيًا. ويمثل lease_token رقم الجيل. تزيد كل مطالبة ناجحة قيمته، كي يستطيع العامل الحالي تمييز إيجاره من إيجار أقدم حتى حين تعتقد العمليتان أنهما تملكان المهمة نفسها.

ينبغي أن تحتوي الحمولة على البيانات اللازمة لتنفيذ المهمة، لا بيانات اعتماد غير مقيدة. أبقِ الأسرار خلف مراجع على جانب الخادم ذات ضوابط وصول وتدوير.

طالب بالمهمة ونفّذ الانتقال في تعليمة واحدة#

لا تختر معرّفًا في معاملة ثم تحدّثه في أخرى؛ إذ يستطيع عامل آخر المطالبة بالصف نفسه بين العمليتين. استخدم استعلامًا فرعيًا بقفل وتحديثًا في تعليمة واحدة:

WITH candidate AS (
  SELECT id
  FROM jobs
  WHERE queue_name = $1
    AND status = 'pending'
    AND available_at <= now()
  ORDER BY priority DESC, available_at, id
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
UPDATE jobs AS j
SET status = 'running',
    attempt_count = attempt_count + 1,
    lease_owner = $2,
    lease_token = lease_token + 1,
    lease_expires_at = now() + interval '60 seconds'
FROM candidate
WHERE j.id = candidate.id
RETURNING j.*;

تعمل التعليمة داخل معاملة قصيرة. في مستوى العزل Read Committed الافتراضي في PostgreSQL، يرى كل أمر لقطة مأخوذة عند بدء الأمر، بينما تحل عبارة القفل تحديثات الصفوف المتزامنة وفق سلوك قفل الصفوف الموثق. والخاصية المهمة هنا مصدرها قفل الصف والتحديث الذري، لا افتراض لقطة قابلة للتكرار.

المطالبة بمهمة واحدة في كل مرة بسيطة. وقد تقلل المطالبة بدفعة عدد الرحلات، لكنها تزيد أيضًا مقدار العمل غير المرئي الذي يحتفظ به عامل واحد. اجعل الدفعات محدودة وأصغر من مقدار العمل الذي يستطيع العامل إكماله ضمن نافذة الإيجار.

استخدم رمز الإيجار بوصفه سياجًا#

ينشئ الإيجار محدود الصلاحية حالة سباق:

  1. يطالب العامل A بالمهمة 42 باستخدام الرمز 7.
  2. يتوقف A مؤقتًا مدة تكفي لانتهاء الإيجار.
  3. يعيد التعافي المهمة إلى pending.
  4. يطالب العامل B بها باستخدام الرمز 8 وينهيها.
  5. يستأنف A ويحاول الإبلاغ عن النجاح.

سيسمح فحص job_id وحده للعامل A باستبدال حالة B. ويجب أن يشمل تحديث الإكمال الرمز:

UPDATE jobs
SET status = 'succeeded',
    finished_at = now(),
    lease_owner = NULL,
    lease_expires_at = NULL
WHERE id = $1
  AND status = 'running'
  AND lease_owner = $2
  AND lease_token = $3
  AND lease_expires_at > now();

يعني تحديث صفر صفوف أن العامل لم يعد يملك الإيجار. ويجب ألا يضع علامة اكتمال على المهمة.

يحمي فحص التسييج هذا حالة الطابور، لكنه لا يسيّج نظامًا خارجيًا تلقائيًا. فإذا أرسلت المهمة طلب دفع أو بريدًا إلكترونيًا أو خطاف ويب أو كتبت في مخزن كائنات، فمرّر مفتاحًا ثابتًا لعدم التكرار حين تدعم الوجهة ذلك. وإلا فمثّل الإجراء الخارجي بوصفه حالة دائمة ذات نتيجة uncertain وتسوية. لا يستطيع رمز قاعدة بيانات عكس أثر جانبي خرج بالفعل من حدود معاملتها.

لا تجدّد الإيجار إلا مع إحراز تقدم#

تستغرق بعض المهام بصورة مشروعة وقتًا أطول من الإيجار الأولي. ويمكن للعامل إرسال نبضة حياة بتمديد الصلاحية، مع الحراسة بالمالك والرمز أيضًا:

UPDATE jobs
SET lease_expires_at = now() + interval '60 seconds'
WHERE id = $1
  AND status = 'running'
  AND lease_owner = $2
  AND lease_token = $3
  AND lease_expires_at > now();

لا تجدّد إلى الأبد لمجرد أن العملية حية. اربط التجديد بدليل على التقدم حيثما أمكن: جزء مكتمل، أو مؤشر متقدم، أو إقرار حديث من نظام لاحق. واضبط أقصى وقت تشغيل بصورة منفصلة عن الإيجار المتجدد. يمنع ذلك عاملًا عالقًا منطقيًا من احتكار مهمة إلى ما لا نهاية.

استخدم وقت قاعدة البيانات في مقارنات الإيجار. فمزج ساعات العمّال يدخل انحراف الساعة في قرارات الملكية.

استعد العمل المتروك بسياسة محدودة#

ينبغي لمهمة التعافي فحص المهام الجارية ذات الإيجارات المنتهية واختيار إعادة المحاولة أو الفشل النهائي:

UPDATE jobs
SET status = CASE
      WHEN attempt_count >= max_attempts THEN 'dead'::job_status
      ELSE 'pending'::job_status
    END,
    available_at = CASE
      WHEN attempt_count >= max_attempts THEN available_at
      ELSE now() + interval '30 seconds'
    END,
    lease_owner = NULL,
    lease_expires_at = NULL,
    last_error_code = 'LEASE_EXPIRED'
WHERE status = 'running'
  AND lease_expires_at <= now();

التأخير المعروض توضيحي. وينبغي أن يتضمن التراجع الفعلي تشويشًا وحدًا أقصى للتأخير وميزانية مبنية على سلوك تعافي الخدمة التابعة وهدف زمن الاستجابة لسير العمل.

صنّف حالات الفشل قبل إعادة المحاولة. فمن غير المرجح أن تتحسن الحمولات غير الصالحة ونسخ المخطط غير المدعومة، بينما قد تتحسن المهل والانقطاعات القصيرة للخدمات التابعة. أما الاستثناءات المجهولة فتستحق ميزانية صغيرة وتشخيصات كاملة، لا محاولات لا نهائية.

ليست المهمة dead بيانات محذوفة، بل حالة تشغيلية لها مالك وسياسة احتفاظ ومسار فحص وإجراء مضبوط لإعادة المعالجة. وينبغي ألا تنشئ إعادة المعالجة العمل أو تعيده إلى حالته السابقة إلا بعد معالجة سبب الفشل، مع الحفاظ على هوية المهمة الأصلية ومسار التدقيق.

الترتيب والإنصاف متطلبان منفصلان#

تمنح ORDER BY priority DESC, available_at, id العمّال تفضيلًا حتميًا، لا ترتيبًا شاملًا صارمًا. يسمح SKIP LOCKED عمدًا للعامل بتجاوز صف أسبق مقفل. كما قد تؤدي سلسلة من المهام عالية الأولوية إلى حرمان العمل منخفض الأولوية.

إذا كان الترتيب لكل كيان مجمّع مهمًا، فمثّله صراحةً. من الأساليب السماح فقط لأدنى رقم تسلسلي لكيان مجمّع بأن يصبح قابلًا للمطالبة. ومن الأساليب الأخرى توجيه كل كيان مجمّع إلى منفّذ تسلسلي. كلاهما يقلل التزامن ويحتاج إلى اختبارات للفجوات والمهام المعيبة.

يمكن تحقيق الإنصاف بزيادة الأولوية مع العمر، أو مجمعات عمّال مخصصة لكل طابور، أو الجدولة الموزونة. لا تعد بترتيب «الوارد أولًا يخرج أولًا» لمجرد أن استعلام المطالبة يحتوي على ORDER BY.

LISTEN وNOTIFY إشارتا إيقاظ، لا وسيلة تخزين#

الاستطلاع دائم لأن الجدول هو مصدر الحقيقة، لكن فاصل الاستطلاع القصير يهدر عمل قاعدة البيانات حين يكون الطابور فارغًا. يستطيع NOTIFY في PostgreSQL إيقاظ العمّال المستمعين بعد تثبيت معاملة إدراج المهمة. وتذكر الوثائق أن الإشعارات الصادرة داخل معاملة لا تُسلّم إلا بعد التثبيت.

استخدم ذلك لتحسين زمن الاستجابة:

  1. أدرج صف المهمة الدائم.
  2. استدعِ pg_notify في المعاملة نفسها.
  3. أيقظ العمّال بعد التثبيت.
  4. استعلم دائمًا من جدول المهام عن العمل الفعلي.
  5. أبقِ استطلاعًا دوريًا لاستعادة الإشعارات الفائتة أو المستمعين المنقطعين.

حمولة الإشعار ليست الطابور. ويجب أن يظل العامل الذي كان غير متصل قادرًا على إيجاد كل مهمة دائمة في الجدول.

شغّل آلة الحالات#

تصف المقاييس المفيدة العمر والانتقالات، لا أعداد الصفوف وحدها:

  • عمر أقدم مهمة قابلة للمطالبة؛
  • زمن المطالبة وزمن الإكمال لكل طابور؛
  • المهام الجارية التي تقترب إيجاراتها من الانتهاء؛
  • حالات انتهاء الإيجار ومحاولات الإكمال التي فقدت حق التسييج؛
  • إعادات المحاولة حسب رمز الخطأ ورقم المحاولة؛
  • المهام الفاشلة نهائيًا وعمر أقدم فشل لم يُحل؛
  • إخفاقات نبضات الحياة؛
  • مدة استعلام المطالبة، وانتظار الأقفال، واستخدام اتصالات قاعدة البيانات؛
  • إنتاجية العمّال وتشبعهم.

أطلق التنبيه بناءً على العمل القديم. فقد يكون طابور صغير معطلًا إذا لم تتحرك أقدم مهمة فيه منذ ساعة. وأبقِ الحمولات وحقول الخطأ محدودة ومراعية للخصوصية؛ فلا تتطلب الرؤية التشغيلية نسخ الإدخال الحساس إلى السجلات.

اختبر التعطل عند كل حد#

تتعمد أكثر الاختبارات فائدةً مقاطعة دورة الحياة:

  1. شغّل مطالبين متزامنين وتحقق من تفرد كل إيجار ذي رمز.
  2. عطّل العملية بعد تثبيت المطالبة وقبل المعالجة، ثم تحقق من انتهاء الصلاحية وإعادة المطالبة.
  3. أوقف العامل A مؤقتًا، ودع العامل B يعيد المطالبة، وتحقق من أن إكمال A القديم يحدّث صفر صفوف.
  4. عطّل العملية بعد وقوع أثر جانبي خارجي وقبل إكمال الطابور، ثم تحقق من سياسة عدم التكرار أو التسوية.
  5. افقد نبضات الحياة أثناء العمل النشط وتحقق من أقصى نافذة للتنفيذ المكرر.
  6. استنفد إعادات المحاولة وتحقق من وجود حالة نهائية ظاهرة بدلًا من الحذف.
  7. أبقِ مهمة واحدة مقفلة وتحقق من استمرار المهام غير المرتبطة عبر SKIP LOCKED.
  8. أغرق فئة عالية الأولوية واختبر سياسة الإنصاف المختارة.
  9. افصل كل المستمعين وتحقق من أن الاستطلاع الدوري لا يزال يصرّف المهام المثبتة.
  10. أنشئ أعمالًا متراكمة بحجم يكفي لفحص خطط الاستعلام وسلوك الفهرس وإنتاجية التعافي.

تحدد هذه الاختبارات معنى «الموثوقية». ولا تكفي آلية SQL وحدها.

خلاصة عملية#

يمكن أن يشكل PostgreSQL أساسًا قويًا لطابور مهام حين يستفيد حمل العمل من الإدراج المعاملي، وعبء تشغيلي معتدل، والفحص المباشر باستخدام SQL. والمهم هو إبقاء ضماناته محدودة.

استخدم FOR UPDATE SKIP LOCKED لتنسيق مطالبات ذرية قصيرة. وثبّت المعاملة قبل تنفيذ العمل البطيء. ومثّل الملكية بوصفها إيجارًا محدود الصلاحية، وزِد رمز تسييج عند كل مطالبة، واشترط ذلك الرمز في نبضات الحياة والإكمال. وقيّد إعادات المحاولة، واحتفظ بحالات الفشل النهائية، وعامل NOTIFY بوصفه مسار إيقاظ اختياريًا، واجعل الآثار الخارجية آمنة عند التكرار أو قابلة للتسوية.

لا يصبح الطابور موثوقًا لمجرد أن عاملين يتجنبان الصف نفسه، بل حين يترك كل تعطل حالة دائمة تكفي لعملية أخرى كي تقرر ما يحدث بعد ذلك.

تحتاج طوابير المهام في PostgreSQL إلى إيجارات، لا إلى SKIP LOCKED وحدها | Ghassan