Get a quote

نمط Outbox المعاملاتي في Go: نشر الأحداث المضمون مع PostgreSQL

أكثر خلل موثوقية شائع في خلفيات Go المبنية على الأحداث هو الفجوة بين الكتابة في قاعدة البيانات ونشر الرسالة في قائمة الانتظار. نمط Outbox يلغي هذه الفجوة تماماً بجعل نشر الأحداث جزءاً من نفس المعاملة في قاعدة البيانات.

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

هكذا تضمن منصة RTYLR التابعة لـ Voxire والعاملة في لبنان ودول الخليج، أن أحداث الدفع وتغييرات المخزون وانتقالات حالة الطلبات لا تضيع أبداً.

كيف تبدو المشكلة في الإنتاج

التطبيق البسيط يبدو هكذا في كل كود Go تقريباً:

func (s *OrderService) ConfirmOrder(ctx context.Context, orderID uuid.UUID) error {
    if err := s.db.UpdateOrderStatus(ctx, orderID, "confirmed"); err != nil {
        return err
    }
    // إذا تعطل البرنامج هنا، يضيع الحدث
    return s.queue.Publish(ctx, OrderConfirmedEvent{OrderID: orderID})
}

هذا الكود لديه نافذة عدم اتساق. الكتابة في قاعدة البيانات تنجح. ثم يُعاد تشغيل الخدمة قبل تنفيذ Publish. الطلب مؤكَّد في PostgreSQL لكن الأنظمة المشتركة في order.confirmed لن تتلقى الحدث أبداً. تستمر في العمل كأن الطلب لا يزال معلقاً.

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

كيف يعمل نمط Outbox

الفكرة الأساسية: PostgreSQL قاعدة بيانات معاملاتية. إذا حدثت كتابتان في نفس المعاملة، إما تنجحان كلتاهما أو تفشلان كلتاهما. نمط Outbox يستفيد من ذلك بكتابة الحدث في نفس المعاملة التي تعدِّل حالة التطبيق.

تضيف جدول outbox:

CREATE TABLE outbox_events (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  event_type   TEXT NOT NULL,
  payload      JSONB NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  processed_at TIMESTAMPTZ,
  attempts     INT NOT NULL DEFAULT 0
);

خدمتك تكتب في كلا الجدولين في معاملة واحدة:

func (s *OrderService) ConfirmOrder(ctx context.Context, orderID uuid.UUID) error {
    return s.db.InTransaction(ctx, func(tx *sql.Tx) error {
        if err := updateOrderStatus(ctx, tx, orderID, "confirmed"); err != nil {
            return err
        }
        payload, _ := json.Marshal(OrderConfirmedPayload{OrderID: orderID})
        _, err := tx.ExecContext(ctx, `
            INSERT INTO outbox_events (event_type, payload)
            VALUES ($1, $2)
        `, "order.confirmed", payload)
        return err
    })
}

لم تعد هناك نافذة عدم اتساق. إما يُثبَّت تحديث الحالة وحدث outbox معاً، أو لا يُثبَّت شيء منهما.

عامل الترحيل

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

func (w *OutboxWorker) processOneBatch(ctx context.Context) error {
    rows, err := w.db.QueryContext(ctx, `
        SELECT id, event_type, payload
        FROM outbox_events
        WHERE processed_at IS NULL
        ORDER BY created_at
        LIMIT 50
        FOR UPDATE SKIP LOCKED
    `)
    // معالجة كل صف ونشره
    // ...
}

جملة FOR UPDATE SKIP LOCKED حيوية عند تشغيل عمال ترحيل متعددين. بدونها، قد يلتقط عاملان نفس الحدث وينشرانه مرتين.

التسليم مرة واحدة مقابل مرة على الأقل

nمط Outbox يوفر تسليماً بمرة على الأقل: الأحداث مضمونة النشر لكن قد تُنشر أكثر من مرة في سيناريوهات الفشل. المستهلكون المتعاملون مع الأحداث يجب أن يكونوا قادرين على التعامل مع نفس الحدث مرات متعددة دون آثار جانبية.

في RTYLR، كل حدث منشور يحمل UUID حدث outbox كمعرف للرسالة. المستهلكون يتحققون من جدول الأحداث المرئية قبل المعالجة.

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

ثلاثة أشياء برزت عند تشغيل نمط Outbox في إنتاج RTYLR عبر دول متعددة:

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

ثانياً، فترة استطلاع عامل الترحيل هي مقياس بين الكمون وحمل قاعدة البيانات. 500 ميلي ثانية معقولة كإعداد افتراضي.

ثالثاً، عمود attempts هو أكثر مقياس تشغيلي مفيد على الجدول. ارتفاع مفاجئ في المحاولات لنوع حدث محدد يعني دائماً تقريباً أن نشر خدمة مستهلكة أدخل خللاً. التنبيه على هذا وفَّر ساعات من التشخيص خلال إصدار RTYLR قدَّم تغييراً كسر مخطط مستهلك webhook.

هل تريد البناء الصحيح منذ البداية؟

إذا كنت تبني أنظمة مبنية على الأحداث في Go وتريد نشراً موثوقاً دون تنسيق معاملات موزعة، يمكن لـ Voxire مساعدتك في تصميم البنية الصحيحة لمتطلبات نظامك.

https://voxire.com/get-a-quote/

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