المستوى المطلوب: متوسط
لو تطبيق ميكروسرفسز عندك بيكتب على 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;
الـ partial index بيخلي query الـ relay سريع جدًا حتى لو الجدول وصل لمليون صف، لأنه بيفهرس الصفوف اللي لسه ما اتنشرتش بس.
2) اكتب الـ event داخل transaction البزنس
await db.transaction(async (trx) => {
const [order] = await trx('orders').insert({
user_id: userId,
total: 250,
status: 'pending'
}).returning('*');
await trx('outbox').insert({
aggregate_type: 'order',
aggregate_id: order.id,
event_type: 'OrderCreated',
payload: { orderId: order.id, userId, total: 250 }
});
});
النقطة الحرجة: الـ INSERT في orders والـ INSERT في outbox داخل transaction واحدة. إما يتنفذوا الاتنين أو يفشلوا الاتنين. مفيش حالة وسط.
3) شغّل relay process
Relay منفصل بيعمل polling أو CDC (Change Data Capture):
async function relayLoop() {
while (true) {
const events = await db('outbox')
.whereNull('published_at')
.orderBy('created_at')
.limit(100);
for (const event of events) {
await kafka.publish(event.event_type, {
key: event.aggregate_id,
value: JSON.stringify(event.payload)
});
await db('outbox')
.where({ id: event.id })
.update({ published_at: db.fn.now() });
}
if (events.length === 0) {
await sleep(500);
}
}
}
في الإنتاج، استخدم Debezium بدل polling يدوي. Debezium بيقرأ من PostgreSQL WAL مباشرة وبيوصلك latency أقل من 50ms مع zero impact تقريبًا على الـ DB.
الأرقام الفعلية من إنتاج
قياس من نظام شغّال على EKS بـ 4 services و~3K طلب/دقيقة:
- فقدان أحداث/يوم قبل التطبيق: ~1,200 event ضائع.
- فقدان أحداث/يوم بعد Outbox + Debezium: 0.
- Latency بين كتابة DB ووصول Kafka: 30 إلى 80ms.
- تكلفة DB IOPS الإضافية: زيادة ~12%.
- استهلاك CPU للـ relay: 0.2 vCPU لكل instance.
الـ trade-off هنا واضح: بتكسب zero data loss، بتخسر 30-80ms latency و12% IOPS وservice جديد للصيانة.
مفاهيم لازم تعرفها مع Outbox
Idempotency في الـ consumers
كل consumer للـ Kafka events لازم يكون idempotent. ليه؟ at-least-once بتعني ممكن نفس الـ event يوصل مرتين. الحل البسيط:
async function handleOrderCreated(event) {
const exists = await db('processed_events')
.where({ event_id: event.id })
.first();
if (exists) return;
await processOrder(event.payload);
await db('processed_events').insert({ event_id: event.id });
}
الفرضية هنا: عندك جدول processed_events بيخزن IDs الأحداث اللي اتعالجت قبل كده. لو الـ event وصل مرتين، الثانية بتترفض بدون تنفيذ.
Event Ordering
استخدم aggregate_id كـ Kafka key. Kafka بيضمن ترتيب الرسائل اللي ليها نفس الـ key داخل partition واحدة. بس ده بيحدد الـ throughput بعدد الـ partitions اللي عندك. لو عندك 10 partitions، أقصى parallelism هو 10 consumers.
متى لا تستخدم هذه الطريقة
- لو نظامك monolith: ما تحتاجش outbox أصلًا. transaction واحدة في DB واحدة كافية.
- لو الـ events غير حرجة: زي analytics events. خسارة 0.1% مش مشكلة، والـ overhead مش له معنى.
- لو عندك أقل من 100 رسالة/ثانية: استخدم Saga Pattern أو retry queue. Outbox + Debezium overkill.
- لو الـ DB ما بتدعمش CDC: مثل بعض نسخ MongoDB القديمة. هتضطر تعمل polling، وده بياخد من IOPS.
Trade-offs قبل التبني
- Storage: جدول outbox بيكبر بسرعة. لازم cleanup job يحذف الصفوف اللي اتنشرت من فوق ساعة مثلًا.
- Operational complexity: Debezium بيحتاج Kafka Connect cluster. خدمة جديدة، monitoring جديد، backup جديد.
- Hot rows: لو aggregate واحد عليه آلاف events في الثانية، الـ partition بتاعه في Kafka هتبقى bottleneck.
الخطوة التالية
افتح الـ codebase بتاعك ودوّر على أي مكان فيه الـ pattern ده:
await db.save();
await kafka.publish();
ده dual write خطر. أعد كتابته يستخدم outbox table في نفس transaction. لو في 5 أماكن في الكود، ابدأ بأكتر event حرج (payments عادة)، وبعدها كمّل الباقي.
مصادر
- Microsoft Learn — Transactional Outbox Pattern: learn.microsoft.com/azure/architecture/patterns/transactional-outbox
- Debezium Documentation — Outbox Event Router: debezium.io/documentation/reference/transformations/outbox-event-router
- Chris Richardson — Pattern: Transactional outbox: microservices.io/patterns/data/transactional-outbox
- Confluent — Reliable Microservices Data Exchange With the Outbox Pattern: confluent.io/blog/event-streaming-patterns-the-outbox-pattern