Get a quote

بناء جدولة مهام موزعة في Go باستخدام أقفال PostgreSQL الاستشارية

معظم فرق Go تلجأ إلى Redis لجدولة المهام الخلفية. هناك نهج أبسط وأقل تكلفة: أقفال PostgreSQL الاستشارية مع حلقة جدولة خفيفة مكتوبة بـ Go. هذا ما نستخدمه في Voxire لمهام التقارير اليومية ومزامنة البيانات.

لماذا نتجنب خدمة الجدولة المستقلة؟

معظم فرق التطوير تلجأ إلى Redis أو أدوات مثل Sidekiq عند الحاجة إلى جدولة المهام الخلفية. لكن هناك نهجاً أبسط وأقل تكلفة: أقفال PostgreSQL الاستشارية مع حلقة جدولة خفيفة مكتوبة بـ Go.

في Voxire، نستخدم هذا النهج في منصة RTYLR لجدولة التقارير اليومية لكل مطعم، ومزامنة بيانات المخزون مع أنظمة POS الخارجية، وتنظيف الجلسات القديمة.

إضافة خدمة جدولة مستقلة تعني:

  • عملية نشر إضافية تحتاج إلى مراقبة ومعالجة في حالات الفشل
  • تنسيق موزع لتجنب تشغيل المهمة نفسها مرتين
  • اعتماد على Redis كطبقة قفل موزع، مما يضيف تكلفة تشغيلية وإمكانية عطل جديدة
  • تعقيد إضافي في عمليات الإنتاج

إذا كانت متطلباتك معتدلة (عشرات المهام، دقة على مستوى الدقائق)، فـ PostgreSQL يحتوي على كل ما تحتاجه بالفعل.

كيف تعمل الأقفال الاستشارية في PostgreSQL؟

الأقفال الاستشارية هي أقفال على مستوى التطبيق تتبعها PostgreSQL دون تطبيقها تلقائياً. تستدعي التطبيق pg_try_advisory_lock(key bigint) وتعيد PostgreSQL القيمة true إذا تم الحصول على القفل، وfalse إذا كانت جلسة أخرى تحمله.

هناك نوعان مهمان:

  • الأقفال على مستوى الجلسة: تبقى محجوزة حتى إغلاق الاتصال أو استدعاء pg_advisory_unlock صراحةً. تبقى محجوزة حتى لو تم التراجع عن المعاملة.
  • الأقفال على مستوى المعاملة: تُحرر تلقائياً عند انتهاء المعاملة، عبر pg_try_advisory_xact_lock.

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

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

حلقة الجدولة في Go

الحلقة الأساسية تستخدم time.Ticker يُطلق كل دقيقة. عند كل إطلاق، يحاول كل عامل الحصول على القفل الاستشاري لكل مهمة مستحقة التنفيذ.

func (s *Scheduler) Run(ctx context.Context) {
    ticker := time.NewTicker(time.Minute)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            s.runDueJobs(ctx)
        }
    }
}

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

تعريف المهام في جدول قاعدة البيانات

جدول job_runs يخزن تعريفات المهام وتاريخ التنفيذ:

CREATE TABLE job_runs (
    id          BIGSERIAL PRIMARY KEY,
    job_name    TEXT NOT NULL,
    lock_key    BIGINT NOT NULL,
    last_run    TIMESTAMPTZ,
    next_run    TIMESTAMPTZ NOT NULL,
    status      TEXT NOT NULL DEFAULT 'pending',
    error_msg   TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

كل صف يمثل تنفيذاً واحداً مجدولاً. بعد اكتمال المهمة، يُدرج المجدول صفاً جديداً بالطابع الزمني المحدث لـ next_run.

التعامل مع المهام الفائتة

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

استعلام DueJobs يستخدم نافذة استرجاع للإمساك بالتنفيذات الفائتة:

SELECT * FROM job_runs
WHERE next_run <= now()
  AND next_run >= now() - INTERVAL '1 hour'
  AND status = 'pending'
ORDER BY next_run ASC;

حتى لو رأى عاملان المهمة الفائتة نفسها في نافذة الاسترجاع، فقط أحدهما يحصل على القفل وينفذها. القفل الاستشاري هو آلية الأمان هنا.

الإغلاق السلس وتطبيق المهل الزمنية

كل مهمة تعمل مع context.WithTimeout. بدونه، مهمة معلقة تحتجز القفل إلى الأبد وتحجب تشغيلات مستقبلية.

للإغلاق السلس، يستمع المجدول لإشارات SIGTERM وSIGINT. عند إلغاء السياق، يتوقف Ticker ولا تُرسل مهام جديدة. المهام التي بدأت بالفعل تكمل حتى انتهاء مهلتها أو اكتمالها الطبيعي.

دروس مستفادة من الإنتاج

في RTYLR عبر نشرات المستأجرين المتعددة:

  • تصادم مفاتيح القفل خطأ دقيق: تخزين مفتاح القفل صراحةً في الجدول يزيل الغموض بدلاً من الاعتماد على hashtext.
  • لا تشغّل المجدول في كل pod: في ECS، خصص مهمة واحدة لدور المجدول باستخدام متغير بيئة. هذا يتجنب تشغيل نسخ مكررة تتنافس على الأقفال في كل نبضة.
  • سجّل تنافس الأقفال: عندما يعيد pg_try_advisory_lock القيمة false، سجّل ذلك بمستوى debug مع اسم المهمة ومعرّف العامل.
  • حدد نافذة استرجاع قصوى: بدون حد أعلى للنافذة الزمنية، مهمة معلقة لأيام قد تُطلق عشرات التشغيلات الفائتة دفعة واحدة.

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

هل تحتاج إلى مساعدة في البداية؟

إذا كنت تبني منتج SaaS وتحتاج إلى بنية خلفية تتعامل مع الجدولة والمستأجرين المتعددين والبساطة التشغيلية، يمكن لـ Voxire مساعدتك في التصميم والبناء. تواصل معنا على https://voxire.com/get-a-quote/

العودة إلى المدونة
Chat on WhatsApp