مستوى متوسط
لو بتعدّل صف في PostgreSQL وبتبعت event لـ Kafka في نفس الـ HTTP request، عندك مشكلة صامتة بتنفجر مرة واحدة كل أسبوعين. الـ DB بتتحدّث، والـ produce بيفشل لأن الشبكة وقعت 800ms، والـ user بيرجعله 200 OK. النتيجة: الرصيد اتخصم لكن الإيميل ما اتبعتش، نظام الـ analytics ما عرفش، والـ search index بقى متأخر. هنا هتفهم Outbox Pattern وازاي بيحل ده بترانزاكشن واحد على الـ DB بدل اتنين منفصلتين.
Outbox Pattern: حلّ مشكلة Dual Write بأبسط فكرة معمارية ممكنة
المشكلة باختصار: Dual Write Problem
السيناريو: تطبيق fintech بيعمل خصم من حساب وبيبعت event باسم balance_changed لـ Kafka، علشان نظام الإشعارات والـ analytics والـ fraud detection كلهم يعرفوا.
# الكود الهش (الطريقة الشائعة الغلط)
def withdraw(user_id, amount):
db.execute(
"UPDATE accounts SET balance = balance - %s WHERE id = %s",
amount, user_id,
)
db.commit() # نجح
kafka.produce( # ممكن يفشل
"balance_changed",
{"user_id": user_id, "amount": amount},
)
اللي بيحصل لو الـ kafka.produce فشل: الـ DB اتحدّثت، والـ event ضاع. أنت بقيت في حالة inconsistent. مفيش retry هيرجّع الـ event ده، لأن الـ caller خلص ورجع response. النتيجة: الرصيد بيقول 1500، والإيميل بيقول 2000، والـ analytics dashboard بيقول لسه 2000.
مثال للمبتدئ: مكتب البريد قبل ما يخترع حد Outbox
تخيّل إنك في مكتب بريد قديم، وعندك مهمتين كل ما يجي جواب: (1) تكتب في دفتر السجل إن الجواب اتسلّم، و(2) تحط الجواب في صندوق البريد الخارجي علشان السائق ياخده. لو كتبت في الدفتر، وقبل ما تحط الجواب في الصندوق فيه قطع كهربا، الجواب ما اتبعتش رغم إن السجل بيقول اتبعت. ده بالظبط الـ Dual Write Problem في صورة فيزيقية.
الحل الذكي: بدل ما تحط الجواب في الصندوق الخارجي مباشرة، حطه في صندوق داخلي جنب دفتر السجل. الكتابة في الدفتر وحط الجواب في الصندوق الداخلي بتحصل في نفس اللحظة (نفس الـ "transaction"). بعدين موظف تاني يجي كل شوية يفرّغ الصندوق الداخلي ويبعت الجوابات للصندوق الخارجي. لو الكهربا قطعت، الجواب لسه في الصندوق الداخلي، والموظف هيلاقيه ويبعته بعدين.
الصندوق الداخلي ده اسمه Outbox، والموظف اللي بيفرّغه اسمه Relay.
التعريف العلمي الدقيق
Outbox Pattern: بدل ما تكتب في قاعدة البيانات وتبعت event للـ message broker في عمليتين منفصلتين على شبكتين مختلفتين، تكتب الاتنين في نفس الـ ACID transaction. الـ event بيتسجّل كصف في جدول داخلي اسمه داخل نفس الـ DB. بعدين عملية مستقلة (relay/dispatcher) بتقرأ الجدول ده وبتبعت الـ events لـ Kafka أو RabbitMQ، وبتعلّمها كـ . الفكرة الأساسية: الـ DB transaction هي الـ source of truth الوحيد، والـ broker بقى استهلاك تالي مش جزء من الـ atomicity guarantee.