المستوى المطلوب: متوسط
لو تطبيق ميكروسرفسز عندك بيكتب على PostgreSQL وبعدها بيبعت event على Kafka، فيه احتمال خطير: الكتابة في الداتابيز نجحت، الـ event ضاع. النتيجة: Order Service شايف الطلب موجود، لكن Payment Service ما سمعش عنه. ده اسمه dual write problem، وحله الكلاسيكي اسمه Outbox Pattern.
Outbox Pattern بالعربي: ليه الأحداث بتضيع وإزاي تمنعها
المشكلة باختصار
تخيل تطبيق e-commerce بسيط. لما المستخدم يعمل طلب جديد، السيرفر بيعمل خطوتين:
- INSERT في جدول
ordersعلى PostgreSQL. - publish event
OrderCreatedعلى Kafka عشان Payment Service وShipping Service يسمعوا.
المشكلة: الخطوتين دول مش transaction واحدة. لو الـ DB نجحت والـ Kafka publish فشل، Payment Service مش هيعرف بالطلب أبدًا. لو حاولت العكس وتعمل publish الأول وبعدين DB، وفشلت الـ DB، يبقى عملت event لطلب مش موجود فعليًا.
في إنتاج بـ 10K طلب/ثانية، نسبة فشل 0.1% بتعني 600 طلب ضائع كل دقيقة. ده مش رفاهية، ده bug ينزّل ثقة العملاء بسرعة.
المثال البسيط: التحويل البنكي والإيصال
تخيل بنك تقليدي بيحوّل راتب لموظف. السيناريو السيء: البنك حوّل الفلوس لكن ما طبعش إيصال. الموظف وصلته الفلوس، لكن مفيش ورقة تثبت العملية، فلو حصلت مشكلة لاحقًا، مفيش دليل.
الحل البنكي: ما يحوّلش الفلوس إلا لما يطبع الإيصال في نفس الـ transaction. أي خطأ في الطباعة معناه إلغاء التحويل بالكامل. التحويل والإيصال كيان واحد، لا ينفصلوا.
Outbox Pattern بيعمل نفس الفكرة بالظبط: يحط الـ event في نفس transaction الكتابة الأصلية، عشان لا يفصلوا أبدًا.
التعريف العلمي الدقيق
Outbox Pattern: نمط معماري يضمن atomicity بين تعديل الـ state في قاعدة بيانات محلية ونشر event في message broker خارجي. يعتمد على فكرة بسيطة: بدل ما تنشر الـ event مباشرة على Kafka، اكتبه في جدول outbox داخل نفس الـ transaction. عملية منفصلة اسمها relay تقرأ الجدول وتنشر على Kafka.
الـ guarantees المضمونة:
- at-least-once delivery: الـ event مش هيضيع، لكن ممكن يتنشر أكتر من مرة.
- eventual consistency: التطبيقات الأخرى هتسمع الـ event خلال ميلي ثواني.
التنفيذ خطوة بخطوة
1) أنشئ جدول outbox
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unpublished
ON outbox (created_at)
WHERE published_at IS NULL;