Get a quote

تنسيق المعاملات الموزعة في Go: نمط Saga في الإنتاج

حين تتطلب عملية واحدة الكتابة إلى قاعدة البيانات وشحن بطاقة الدفع وإرسال إشعار وتحديث كاش في آن واحد، لا يمكنك تغليف كل ذلك في معاملة قاعدة بيانات واحدة. نمط Saga هو الحل الاحترافي.

لماذا Two-Phase Commit لا يصلح لأغلب بنى SaaS

Two-Phase Commit يعمل في مجموعات قواعد البيانات المبنية له. لكن في نظام SaaS يعتمد على خدمات خارجية متنوعة كـ Stripe وTwilio وSendGrid، 2PC غير عملي. Stripe لا يُطبّق بروتوكول two-phase commit. لا يمكنك تنفيذ شحن Stripe وكتابة PostgreSQL في معاملة 2PC واحدة.

نمط Saga يتبع نهجاً مختلفاً: قسّم العملية الموزعة إلى سلسلة من المعاملات المحلية، كل منها يمكن التراجع عنها بمعاملة تعويضية إذا فشل شيء في المراحل اللاحقة.

التنسيق المركزي أم الكوريوغرافيا

هناك طريقتان لتطبيق Sagas:

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

التنسيق المركزي: منسق مركزي يستدعي كل خدمة بالتسلسل ويتتبع الحالة الكاملة. إذا فشلت خطوة، ينفذ المنسق عمليات التعويض بالترتيب العكسي.

لأغلب فرق Go SaaS في الإنتاج، التنسيق المركزي أسهل للتشخيص. حين يفشل Saga، تستعلم عن جدول الحالة وترى بدقة أي خطوة فشلت وأي تعويضات نُفِّذت.

مخطط قاعدة البيانات لحالة Saga

CREATE TABLE saga_runs (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    saga_type      TEXT NOT NULL,
    tenant_id      UUID,
    state          JSONB NOT NULL DEFAULT '{}',
    status         TEXT NOT NULL DEFAULT 'running',
    current_step   INT NOT NULL DEFAULT 0,
    error_message  TEXT,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

عمود state بنوع JSONB يخزن البيانات المتراكمة التي تمررها الخطوات لبعضها. current_step وstatus يتيحان للمنسق استئناف تنفيذ Saga بعد تعطل العملية.

تعريف Saga في Go

var ProcessOrderSaga = SagaDef{
    Type: "process_order",
    Steps: []SagaStep{
        {
            Name: "deduct_inventory",
            Execute: func(ctx context.Context, state map[string]any) error {
                return inventoryService.Deduct(ctx, mustDecodeItems(state["items"]))
            },
            Compensate: func(ctx context.Context, state map[string]any) error {
                return inventoryService.Restore(ctx, mustDecodeItems(state["items"]))
            },
        },
        {
            Name: "record_order",
            Execute: func(ctx context.Context, state map[string]any) error {
                orderID, err := orderService.Create(ctx, mustDecodeOrderData(state))
                if err != nil {
                    return err
                }
                state["order_id"] = orderID.String()
                return nil
            },
            Compensate: func(ctx context.Context, state map[string]any) error {
                return orderService.Cancel(ctx, uuid.MustParse(state["order_id"].(string)))
            },
        },
    },
}

حلقة التنفيذ مع الاسترداد بعد التعطل

func (o *Orchestrator) Execute(ctx context.Context, saga SagaDef, initialState map[string]any) error {
    run, err := o.db.LoadOrCreateRun(ctx, saga.Type, initialState)
    if err != nil {
        return err
    }

    for i := run.CurrentStep; i < len(saga.Steps); i++ {
        step := saga.Steps[i]

        if err := step.Execute(ctx, run.State); err != nil {
            o.compensate(ctx, saga.Steps, run.State, i)
            return o.db.MarkFailed(ctx, run.ID, i, err)
        }

        // حفظ الحالة بعد كل خطوة ناجحة
        // إذا تعطلت العملية هنا، تستأنف من i+1
        o.db.PersistProgress(ctx, run.ID, i+1, run.State)
    }

    return o.db.MarkComplete(ctx, run.ID)
}

PersistProgress هو آلية الاسترداد. كل خطوة تحفظ نتيجتها وتُقدّم العداد atomically. عند إعادة التشغيل، مهمة خلفية تُحمّل جميع Sagas بـ status = 'running' وتستأنف التنفيذ.

الـ Idempotency: الشرط غير القابل للتفاوض

كل خطوة في Saga يجب أن تكون idempotent. إذا تعطلت العملية بعد تطبيق تأثير الخطوة لكن قبل حفظ التقدم، ستُعاد الخطوة عند الاسترداد. خطوة تُشحن بطاقة الدفع مرتين أسوأ بكثير من Saga يفشل.

النمط المعياري: مفتاح idempotency ثابت مشتق من معرف Saga ورقم الخطوة:

func stepKey(sagaID uuid.UUID, stepIndex int, stepName string) string {
    return fmt.Sprintf("%s:%d:%s", sagaID, stepIndex, stepName)
}

مرّر هذا المفتاح لاستدعاءات الخدمات الخارجية. رأس Idempotency-Key في Stripe يضمن أن تكرار طلب الشحن بنفس المفتاح يُعيد النتيجة المحفوظة بدلاً من شحن جديد.

التعامل مع فشل عمليات التعويض

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

النهج العملي:

  1. إعادة محاولة التعويضات مع تأخير متزايد عبر قائمة المهام الخلفية
  2. تنبيه فريق العمليات إذا استنفدت التعويضات المحاولات
  3. كتابة التعويضات الفاشلة في جدول saga_compensation_failures للحل اليدوي

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

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

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

كل خطوة يجب أن تكون idempotent قبل نشر Saga في الإنتاج.

التعويضات ستفشل في الإنتاج في الظروف التشغيلية الحقيقية. جهّز قائمة إعادة المحاولة ومسار التجاوز اليدوي قبل النشر.

استخدم PostgreSQL كمخزن حالة Saga. يصمد أمام تعطل العمليات ويُتيح استعلامات SQL عادية لفحص الحالة خلال الحوادث.


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

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