نمط Transactional Outbox في Postgres و NestJS: كيف أنهينا الكتابة المزدوجة بين قاعدة البيانات وقائمة الانتظار
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗أبرز النقاط
• الكتابة المزدوجة (dual write) هي عملية واحدة تكتب في نظامين دون معاملة (transaction) مشتركة، مثل صف في Postgres ورسالة في Redis أو RabbitMQ أو Kafka؛ وأي انهيار بين الكتابتين يترك النظامين في حالة غير متطابقة.
• نمط Transactional Outbox يخزّن كل حدث كصف داخل معاملة Postgres نفسها التي تُجري التغيير، فيضمن الـ commit حفظ الاثنين معًا أو عدم حفظ أيٍّ منهما.
• عملية ناقلة (relay) تحجز صفوف الـ outbox غير المنشورة عبر SELECT … FOR UPDATE SKIP LOCKED، ثم تنشرها وتعلّمها كمنشورة؛ ويمكن تشغيل عدة نسخ منها دون أن تحجز نسختان الصف نفسه.
• يضمن الـ outbox التسليم مرة واحدة على الأقل (at-least-once) وليس مرة واحدة بالضبط، لذلك يسجّل كل مستهلك معرّفات الأحداث المعالَجة في جدول بقيد تفرّد ويتجاهل التكرار.
• الاستطلاع الدوري (polling) هو أبسط ناقل؛ أما التقاط تغييرات البيانات (CDC) عبر Debezium فيلغي زمن انتظار الاستطلاع لكنه يضيف Kafka Connect و replication slot يجب تشغيلهما ومراقبتهما.
ما مشكلة الكتابة المزدوجة، ولماذا لا تحلّها إعادة المحاولة؟
مشكلة الكتابة المزدوجة أن كتابتين مستقلتين لا يمكن جعلهما ذريتين (atomic) من كود التطبيق، فأي فشل بينهما يُنتج عدم اتساق لا تراه أي إعادة محاولة. كثير من خدمات NestJS، ومنها نسخ مبكرة من خدماتنا، تُنشئ الطلب في Postgres ثم تضيف مهمة order.created إلى قائمة الانتظار في السطر التالي. تنجح الاختبارات لأنها لا توقف العملية بين await وآخر، أما بيئة الإنتاج فتفعل ذلك: عمليات النشر ترسل SIGTERM في منتصف الطلب، والحاويات تُقتل بسبب نفاد الذاكرة، والوسيط (broker) يتجاوز المهلة.
تفشل الكتابة المزدوجة في اتجاهين. الحدث المفقود يقع عندما يتم الـ commit في Postgres ثم تتوقف العملية قبل النشر: الطلب موجود، لكن بريد التأكيد والفاتورة وحجز المخزون لا تحدث أبدًا. والحدث الوهمي يقع عند نشر الرسالة أولًا، أو نشرها داخل المعاملة قبل COMMIT، ثم تُلغى المعاملة: فيتصرف مستهلك بناءً على طلب غير موجود.
لا تستطيع حلقة إعادة المحاولة إصلاح الكتابة المزدوجة، لأن العملية التي كانت ستعيد المحاولة هي نفسها التي انهارت. ونقل النشر إلى داخل المعاملة لا يفعل سوى استبدال الأحداث المفقودة بأحداث وهمية، مع إبقاء معاملة قاعدة البيانات مفتوحة أثناء اتصال شبكي. والتثبيت على مرحلتين (two-phase commit أو XA) هو الحل النظري، لكن Redis و Kafka ومعظم الوسطاء المُدارين لا يمكنهم الانضمام إلى معاملة Postgres بهذه الطريقة.
كيف يعمل نمط Transactional Outbox؟
يحوّل نمط Transactional Outbox عملية نشر رسالة إلى عملية إدراج صف، وهو ما يستطيع Postgres تثبيته ذريًا مع التغيير الأساسي. ثم يقرأ ناقل منفصل الصفوف المثبّتة لاحقًا وينفّذ النشر الفعلي.
يحتوي جدول الـ outbox لدينا على مفتاح أساسي من نوع uuid، ونوع الكيان ومعرّفه (مثل order ومعرّف الطلب)، ونوع الحدث، وحمولة jsonb، و created_at، و published_at، وعدّاد للمحاولات، وطابع زمني next_attempt_at لتأخير إعادة المحاولة، وآخر خطأ. ويُبقي فهرس جزئي (partial index) على الصفوف التي تكون فيها published_at بقيمة NULL استعلامَ الناقل سريعًا مع نمو الجدول.
في جانب الكتابة، تفتح خدمة NestJS معاملة Drizzle واحدة، وتُدرج الطلب، ثم تُدرج صف الـ outbox عبر عميل المعاملة نفسه. وتوفّر المعاملات التفاعلية في Prisma عبر prisma.$transaction، و dataSource.transaction في TypeORM، عميلًا مماثلًا مرتبطًا بالمعاملة. ونضع في كل حمولة حقل version ولقطة من الحقول التي يحتاجها المستهلكون، لأن صف الـ outbox يعيش أطول من الكود الذي كتبه.
كيف ينشر الناقل صفوف الـ outbox بأمان مع عدة عمّال؟
يحجز الناقل دفعة من الصفوف المعلّقة عبر SELECT … FOR UPDATE SKIP LOCKED، وينشر كل صف مرفقًا بمعرّف الصف كمعرّف للحدث، ثم يعلّم الصفوف الناجحة كمنشورة قبل الـ commit. تقفل FOR UPDATE الصفوف المحجوزة، وتجعل SKIP LOCKED أي نسخة ثانية من الناقل تتخطى الصفوف التي تحجزها الأولى بدل انتظارها، لذلك يمكن تشغيل الناقل في كل نسخة من الـ API. في NestJS نبدأ الحلقة في onApplicationBootstrap ونوقفها في onApplicationShutdown، مع تفعيل app.enableShutdownHooks().
معالجة الأخطاء لكل صف على حدة هي الأهم. إذا رمى نشرٌ فاشل واحد استثناءً، تُلغى الدفعة كاملة ويُعاد الصف المسموم نفسه إلى ما لا نهاية، فيعطّل كل ما خلفه. ناقلنا يلتقط كل فشل، ويزيد عدّاد المحاولات، ويؤخّر next_attempt_at بتراجع أُسّي (exponential backoff) بحد أقصى خمس دقائق، فينتظر الصف السيئ دوره بينما تمرّ بقية الصفوف، ويؤدي تعطل الوسيط إلى تأخير التسليم دون فقدان أي شيء.
تبقى معاملة الحجز مفتوحة أثناء تواصل الناقل مع الوسيط، لذلك يحتاج النشر إلى مهلة قصيرة. ومع الوجهات البطيئة مثل webhooks لأطراف خارجية نستخدم عقد إيجار (lease) بدلًا من ذلك: نحجز الصفوف بتعيين طابع زمني locked_until، ثم نثبّت المعاملة، وننشر خارجها، ثم نعلّم الصفوف كمنشورة.
الترتيب يحتاج إلى تصميم صريح. الدالة now() في Postgres تُرجع وقت بدء المعاملة، فقد تبدأ معاملة أولًا وتُثبَّت أخيرًا، فيُنشر حدثها بعد حدث أحدث منه. لا يتخطى الناقل ذلك الصف المتأخر، لأنه يعيد الاستعلام عن كل الصفوف المعلّقة بدل القراءة بعد علامة مائية عليا (high-water mark)، لكن تشغيل عدة نواقل يُلغي الترتيب العام تمامًا. وعندما يحتاج المستهلكون إلى ترتيب لكل كيان، نشغّل ناقلًا واحدًا أو نوزّع النواقل حسب معرّف الكيان، ويستخدم المستهلكون رقم إصدار لتجاهل الأحداث القديمة.
لماذا يكون تسليم الـ outbox مرة واحدة على الأقل، وكيف يزيل المستهلكون التكرار؟
يكون تسليم الـ outbox مرة واحدة على الأقل لأن الناقل قد ينهار بعد أن يقبل الوسيط الرسالة وقبل تثبيت تحديث published_at؛ فيبقى الصف معلّقًا وتنشره الدورة التالية مرة أخرى. لذلك تقع إزالة التكرار على عاتق المستهلك.
يحتفظ كل مستهلك بجدول processed_events مفتاحه الأساسي هو معرّف الحدث. وداخل المعاملة نفسها التي ينفّذ فيها عمله، يشغّل المستهلك INSERT … ON CONFLICT DO NOTHING RETURNING على هذا الجدول؛ فإذا لم يُرجع أي صف، فالحدث عولج من قبل ويعود المعالج دون تكرار الأثر الجانبي.
وللآثار الجانبية خارج قاعدة بيانات المستهلك، مثل تحصيل مبلغ من بطاقة، نمرّر معرّف الحدث كمفتاح منع التكرار (idempotency key) لدى المزوّد عندما تدعمه واجهته البرمجية؛ وترويسة Idempotency-Key في Stripe هي المثال المعروف.
هل يعتمد الناقل على الاستطلاع، أم LISTEN/NOTIFY، أم التقاط تغييرات البيانات؟
الاستطلاع هو الخيار الافتراضي المناسب لمعظم الفرق؛ أما التقاط تغييرات البيانات فيستحق بنيته التحتية عندما يشغّل الفريق Kafka أصلًا أو يحتاج إلى زمن انتقال أقل من ثانية مع حجم كبير. هكذا نقارن بين الاستراتيجيات الثلاث:
• الاستطلاع مع SKIP LOCKED: زمن انتقال يصل إلى فترة استطلاع واحدة، ولا بنية إضافية سوى عامل (worker)، مع حمل استعلامات صغير وثابت حتى في وقت الخمول.
• الاستطلاع مع إيقاظ عبر LISTEN/NOTIFY: زمن انتقال شبه فوري واتصال جلسة (session) مخصص واحد. الإشعارات غير دائمة، لذا يبقى الاستطلاع كخيار احتياطي، كما أن PgBouncer في وضع transaction لا يدعم LISTEN.
• التقاط تغييرات البيانات عبر outbox event router في Debezium: تسليم شبه فوري بترتيب الـ commit، لكنه يحتاج إلى Kafka Connect أو Debezium Server، و wal_level=logical، و replication slot. والـ replication slot المهجور يحتفظ بملفات WAL حتى يمتلئ القرص.
التقاط تغييرات البيانات (CDC) يعني قراءة تغييرات الصفوف من سجل الكتابة المسبقة (write-ahead log) في Postgres عبر النسخ المنطقي (logical replication) بدل الاستعلام عن الجدول. والأمر NOTIFY معاملاتي: الإشعار المُرسل داخل معاملة لا يُسلَّم إلا إذا ثُبّتت تلك المعاملة، ما يجعله إشارة إيقاظ آمنة للناقل.
كيف نحافظ على صحة جدول الـ outbox في الإنتاج؟
نُبقي الـ outbox صغيرًا ونضع تنبيهًا على تأخره. تحذف مهمة مجدولة الصفوف التي مضى على قيمة published_at فيها أكثر من بضعة أيام، على دفعات لتجنّب الأقفال الطويلة. ومع الأحجام الكبيرة، يكون تقسيم الجدول (partitioning) حسب created_at وحذف الأقسام القديمة أرخص من حذف الصفوف.
التنبيه الذي لا نُطلق أي نظام بدونه هو عمر أقدم صف معلّق، مع أعلى قيمة لعدّاد المحاولات. ازدياد عمر أقدم صف معلّق يعني أن الناقل متوقف، أو أن الوسيط معطّل، أو أن صفًا مسمومًا يفشل باستمرار. ولا يظهر أيٌّ من ذلك من خلال الـ API، الذي يواصل إرجاع 201 لأن الطلب نفسه حُفظ بشكل صحيح.
الأسئلة الشائعة
س: هل ما زلنا نحتاج إلى outbox إذا كانت قائمة الانتظار BullMQ على Redis؟
ج: نعم. Postgres و Redis نظامان منفصلان، لذا فكتابة صف وإضافة مهمة BullMQ كتابة مزدوجة. البديل هو قائمة انتظار مبنية على Postgres مثل Graphile Worker، التي توفّر الدالة graphile_worker.add_job() في SQL لإضافة مهمة داخل معاملتك، فيصبح جدول المهام نفسه هو الـ outbox.
س: هل يضمن Transactional Outbox التسليم مرة واحدة بالضبط؟
ج: لا. يضمن Transactional Outbox التسليم مرة واحدة على الأقل؛ أما المعالجة مرة واحدة بالضبط فتأتي من مستهلكين يزيلون التكرار بمعرّف الحدث.
س: هل يمكن للخدمة أن تكتب إلى الوسيط أولًا ثم إلى قاعدة البيانات؟
ج: لا. عكس الترتيب يُبقي كتابتين مستقلتين، ويحوّل الأحداث المفقودة إلى أحداث وهمية لصفوف لم تُثبَّت أبدًا.
س: هل تحتوي حمولة الـ outbox على الكيان كاملًا أم على معرّفه فقط؟
ج: على لقطة ذات رقم إصدار من الحقول التي يحتاجها المستهلكون. الحدث الذي يحمل المعرّف فقط يجبر كل مستهلك على الرجوع إلى الخدمة المنتجة، وقد يكون الكيان حينها قد انتقل إلى حالة أحدث.
س: هل يعمل نمط outbox مع Prisma أو TypeORM بدل Drizzle؟
ج: نعم. يحتاج نمط outbox فقط إلى معاملة قاعدة بيانات تشمل عمليتي إدراج، والمعاملات التفاعلية في Prisma و dataSource.transaction() في TypeORM توفّر ذلك.
قراءات إضافية
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.
هندسة الواجهات
صور Open Graph الديناميكية في Next.js 16: كيف نبني opengraph-image.tsx ونتحقق من إصدار Satori الذي تضمّنه next/og ونحافظ على صحة معاينات الروابط
واجهت بطاقات المشاركة المولّدة لدينا ثلاث مفاجآت: خط WOFF2 لم يستطع المحرّك قراءته، وملف README لـ Satori يصف محرّكًا أحدث من المضمّن في Next.js، وصفحة ظلّت تعرض صورة الـ layout. هذه ملاحظاتنا الميدانية حول opengraph-image.tsx في Next.js 16 وأولوية البيانات الوصفية وفحص og:image قبل مشاركة أي رابط.