البنية القائمة على الأحداث لأصحاب الأعمال: لماذا ينبغي أن يُبلغ الطلب المخزونَ والدفاترَ بنفسه
المزامنات الليلية والاستدعاءات المتشابكة تُفقد المخزون والحسابات تناغمها. تعرّف كيف تجعل الأحداث وoutbox والمستمعون idempotent طلبًا واحدًا يحدّث المخزون والدفاتر والولاء بموثوقية، مع مثال تطبيقي.
Author
Anichur Rahaman
منذ شهرين11 min read3 views
في عطلة نهاية أسبوع التخفيضات تلقّت سلسلة تجزئة من ستة فروع 1,900 طلب. وصباح الاثنين يفتح مديرها المالي التقارير فيجد أن كشف المخزون يقول إن 14 سترة ما زالت متاحة، لكن الفرع في المدينة المجاورة باع آخر سترة يوم السبت. وحال الدفاتر أسوأ: تُرحَّل الإيرادات عبر دفعة ليلية، وقد انتهت مهلة الدفعة عند الطلب 1,412، فصار 488 طلبًا خارج الدفاتر. ولن يعرف أحد بذلك قبل مطابقة يوم الثلاثاء.
لم يخطئ أحد في هذه القصة. النظام نفسه يطلب من كل قسم أن يعرف بالطلب بنفسه، إما بالاستعلام الدوري أو بالتصدير أو بمكالمة تصل في وقت غير مناسب. وهذا المقال يدافع عن العكس: عندما يحدث شيء، سجّله مرة واحدة كحقيقة، ودع كل جزء آخر من العمل يتفاعل معها بنفسه. هذه هي البنية القائمة على الأحداث (event-driven).
هذا الجزء من تصميم الأنظمة هو ما أمضيت فيه معظم عملي، فتوقّع آراءً حاسمة. الهدف أن تستطيع في النهاية التمييز بين نظام قائم على الأحداث حقًا وبين ادعاء تسويقي، وأن تعرف ماذا تطلب بالضبط.
تبدأ أغلب برمجيات الأعمال باستدعاء مباشر. ينتهي الدفع، فيستدعي كود الدفع كود المخزون، ثم كود المحاسبة، ثم كود البريد. ينجح هذا في العرض التجريبي، لكنه ينهار بثلاث طرق يمكن توقعها.
أبطأ خطوة تُبقي العميل منتظرًا. إذا استغرقت خدمة البريد ثماني ثوانٍ انتظر المشتري ثماني ثوانٍ، أو فشل الطلب لأن البريد كان متوقفًا.
كل حاجة جديدة تعدّل كودًا قديمًا. إضافة نقاط الولاء تعني فتح أخطر ملف في الشركة، أي إتمام الطلب، وإضافة استدعاء آخر.
العمل غير المكتمل بلا صاحب. انخفض المخزون ثم فشلت المحاسبة. ولا يوجد سجل يقول إن الخطوة الثانية ما زالت مستحقة.
الترقيع المعتاد هو المزامنة الليلية: صدّر الطلبات عند الثانية فجرًا واستوردها في مكان آخر. هذا ينقل الفشل إلى ساعات لا يراقب فيها أحد، ويجعل كل رقم متأخرًا حتى 24 ساعة. فتبني الفرق جداول مطابقة لملاحقة الفروقات، ثم يصبح الجدول هو النظام الحقيقي.
الأمر والحدث: المفردات التي تهم
كلمتان تحملان معظم العبء، والخلط بينهما هو الخطأ التصميمي الأول المعتاد.
الأمر (command) طلب: «سجّل هذا الطلب»، «أعد هذه الدفعة». يوجَّه إلى مالك واحد، بصيغة الحاضر، ويمكن رفضه. أما الحدث (event) فحقيقة وقعت: OrderPlaced وPaymentReceived وParcelDelivered. يُسمّى بصيغة الماضي لأنه حدث فعلًا، ولا يستطيع أحد رفضه. الجزء الذي يملك الحقيقة هو المنتِج (producer)، وكل من يهمه الأمر هو مستمع (listener)، ويُسمّى أيضًا consumer أو subscriber.
المنتِج لا يعرف من يستمع. وهذه الخاصية وحدها هي الفائدة كلها. إضافة نقاط الولاء تعني كتابة مستمع جديد واحد لـ ParcelDelivered. لا يُمَسّ إتمام الطلب، ولذلك لا يمكن أن ينكسر.
طلب واحد، ثلاثة أحداث، خمسة مستمعين
مثال توضيحي يجعل الصورة ملموسة. يشتري عميل سترتين بالرمز JKT-M بسعر 40.00 للواحدة (تكلفة الوحدة 22.00)، مع شحن 5.00 وضريبة 8.00. المجموع 93.00 مدفوعة بالبطاقة. خلال الأيام الثلاثة التالية ينتج الطلب ثلاثة أحداث.
حقيقة واحدة وخمسة ردود فعل مستقلة. إتمام الطلب لا يستدعي أيًّا منها.
يحمل كل حدث حمولة (payload) صغيرة تكفي ليتصرف المستمع دون أن يعود إلى المنتِج بأي سؤال.
stock_movements: type sale, qty -2، وفكّ الحجز. المتوفر 46
ParcelDelivered
دفتر الأستاذ
مدين Customer deposits 93.00؛ دائن Sales 80.00 وShipping income 5.00 وTax payable 8.00. مدين Cost of goods sold 44.00؛ دائن Inventory 44.00
ParcelDelivered
الولاء
loyalty_entries: العميل 77، +80 نقطة (نقطة واحدة لكل وحدة نقدية من قيمة البضاعة)، ref order 1042
ParcelDelivered
الإشعارات
notifications: رسالة التسليم ودعوة لتقييم الشراء، الحالة queued
راجع الحساب: قيد التسليم مدينه 93.00 + 44.00 = 137.00، ودائنه 80.00 + 5.00 + 8.00 + 44.00 = 137.00. يتوازن القيد لأن المستمع كُتب ليرحّل قيودًا متوازنة، لا لأن أحدًا دقّق في نهاية الشهر. نعترف بالإيراد هنا عند التسليم، وقد تختلف سياستك المحاسبية، وهذا قرار يخص مستمع دفتر الأستاذ وحده.
صندوق الصادر (outbox): كيف لا يضيع الحدث
في جانب المنتِج فخّ. تسجيل الطلب يعني كتابتين: حفظ الطلب في قاعدة البيانات، ونشر OrderPlaced إلى الطابور (queue). هما نظامان مختلفان، فلا تغطيهما معاملة واحدة. إذا اكتمل الحفظ ثم مات التطبيق قبل النشر، فالطلب موجود والحدث غير موجود، ولا يعلم المخزون بشيء. وإذا نشرت أولًا ثم تراجعت قاعدة البيانات، تفاعل الجميع مع طلب لم يُحفظ أصلًا. هذه مشكلة الكتابة المزدوجة (dual-write).
الحل هو transactional outbox، كما وصفه Chris Richardson في فهرس أنماط الخدمات المصغّرة لديه. يُكتب الطلب وحدثه في قاعدة البيانات نفسها وضمن المعاملة نفسها، ويذهب الحدث إلى جدول outbox. إما أن يوجد الصفّان معًا أو لا يوجد أي منهما. ثم تقرأ عملية منفصلة تُسمّى relay الصفوف التي لم تُرسَل، وتنشرها إلى الطابور، وتعلّمها «مُرسَلة».
معاملة واحدة وصفّان. الـ relay يحمل الصف الثاني إلى الطابور، مهما تطلّب الأمر من محاولات.
قد ينهار الـ relay أيضًا بين النشر وتعليم الصف كمُرسَل. وعند إعادة التشغيل ينشر الحدث مرة أخرى. فما يمنحك إياه outbox محدد بدقة: الحدث لا يضيع أبدًا، لكنه قد يصل أكثر من مرة. وهذا يقودنا إلى النقطة التالية.
«مرة واحدة بالضبط» تعني في الواقع مرة على الأقل مع مستمعين idempotent
يحب الموردون أن يعدوك بتسليم «مرة واحدة بالضبط». لكن بين أجهزة منفصلة تصل بينها شبكة، لا يمكن ذلك بوجه عام: المُرسِل الذي لا يتلقى إقرارًا لا يعرف هل وصلت الرسالة، فعليه أن يختار بين إعادة الإرسال ومخاطرة الضياع. والأنظمة الموثوقة تختار إعادة الإرسال. ومزوّدو الدفع يقولونها صراحة؛ فوثائق Stripe مثلًا تطلب منك أن تتوقع وصول حدث webhook نفسه أكثر من مرة، وأن تستبعد التكرار بالاعتماد على معرّف الحدث.
إذن الضمان العملي هو تسليم مرة واحدة على الأقل مع معالجة idempotent. يكون المستمع idempotent عندما تكون معالجة الحدث مرتين مثل معالجته مرة واحدة في الأثر. والآلية المعتادة صغيرة: جدول processed_events بمفتاح فريد على (listener, event_id).
ما يفعله المستمع مع كل حدث، ومنه الحدث الذي رآه من قبل.
تفصيلان يحسمان نجاح الأمر. الأول: يجب أن تُثبَّت خطوتا «تطبيق التغيير» و«تسجيل معرّف الحدث» في معاملة قاعدة البيانات نفسها. إذا طبّقت ثم سجّلت في خطوتين، فإن انهيارًا بينهما يعني ترحيلًا مزدوجًا عند إعادة المحاولة. والثاني: يجب أن يجعل المفتاح الفريد الفحص ذريًّا (atomic)، حتى لا تنجح نسختان من الحدث نفسه وصلتا في اللحظة ذاتها.
جرّب ذلك على المثال: إذا وصل ParcelDelivered مرتين، يجد مستمع دفتر الأستاذ في المرة الثانية أن evt_5003 مسجَّل سلفًا فيتخطاه. لا يُرحَّل القيد مرتين، ولا تصبح نقاط الولاء الـ 80 نقطة 160.
الترتيب وإعادة المحاولة والسجل الذي تحصل عليه مجانًا
الترتيب
أحداث الطلبات المختلفة يمكن معالجتها بأي ترتيب. أما أحداث الطلب الواحد فلا: معالجة RefundIssued قبل PaymentReceived لا معنى لها. ويعمل دفاعان معًا. وجّه الأحداث بحسب رقم الطلب كي تسير أحداث الطلب الواحد في مسار واحد بالتتابع، وامنح كل حدث رقم تسلسل خاصًّا بالطلب كي يتعرف المستمع على الحدث الأقدم من اللازم فيؤجله أو يتجاهله.
إعادة المحاولة وطابور الرسائل الميتة
عندما يفشل مستمع، لأن قاعدة البيانات كانت مشغولة أو لأن واجهة شركة الشحن متوقفة، يعود الحدث لمحاولة أخرى بفواصل متزايدة، مثل 10 ثوانٍ ثم دقيقة ثم 5 دقائق ثم 30 دقيقة ثم ساعتان. والتباطؤ التدريجي (backoff) مهم: فالمحاولات الفورية تحوّل انقطاعًا قصيرًا إلى ضغط زائد تصنعه بيديك. وبعد عدد ثابت من المحاولات، لنقل خمسًا، ينتقل الحدث إلى طابور الرسائل الميتة (dead-letter queue) ويصل تنبيه إلى شخص ما. الرسالة الميتة ظاهرة ويمكن استرجاعها. قارن ذلك بتصدير ليلي فاشل، فهو ببساطة غير موجود.
السجل
لأن كل تغيير في العمل حدث له وقت ومعرّف وحمولة، تحصل على مسار تدقيق دون أن تبنيه. سؤال «لماذا لهذا العميل 80 نقطة؟» له جواب: الحدث ParcelDelivered رقم evt_5003، عالجه مستمع الولاء في وقت محدد. احتفظ بالأحداث لمدة محددة، وإذا ظهر خلل في أحد المستمعين فأصلحه وأعد تشغيل الأحداث المتأثرة عليه (replay). وخاصية idempotent تجعل الإعادة آمنة.
متى لا تستخدمه
يضيف التصميم القائم على الأحداث أجزاء متحركة: طابورًا وrelay وعمّالًا ومراقبة وعادة التفكير بمنطق الاتساق النهائي (eventual consistency). في موقع تعريفي أو أداة لمستخدم واحد أو لوحة إدارة CRUD عادية يُحدَّث فيها جدول واحد وتقرؤه شاشة واحدة، يكون استدعاء الدالة المباشر أبسط وأفضل. وينطبق ذلك على الخطوة التي تحتاج جوابًا الآن، مثل «هل البطاقة صالحة؟». هذا أمر له رد، وليس حدثًا.
الحدّ الذي أعتمده: عندما يكون لإجراء واحد ثلاث نتائج مستقلة أو أكثر تملكها فرق أو وحدات مختلفة (المخزون والمال والرسائل والتوصيل)، تبدأ الأحداث في تسديد كلفتها.
وكن صريحًا بشأن الكلفة أيضًا. يعمل المستمعون بعد الطلب بلحظة، فقد تُظهر شاشة تقرأ المخزون فور إتمام الطلب الرقم القديم لوقت قصير. المنتجات الجيدة تعالج ذلك بإجراء الحجز داخل معاملة إتمام الطلب نفسها، وبعرض «قيد المعالجة» بدل التخمين.
كيف تصل إلى ذلك، وكيف تعرف أنه يعمل
الخطوات
اكتب قائمة بالوقائع التي يتحدث عنها عملك أصلًا: طلب سُجّل، دفعة وصلت، طرد سُلّم، مرتجع اعتُمد، مخزون جُرد. وسمِّ كل واقعة بصيغة الماضي.
حدّد حمولة كل حدث بحقول كافية كي لا يضطر المستمع إلى العودة بأي سؤال، وأعطِ كل حدث معرّفًا فريدًا وطابعًا زمنيًا.
اكتب الأحداث في جدول outbox ضمن معاملة التغيير الذي تصفه نفسها.
شغّل relay ينشر صفوف outbox إلى الطابور ويعلّمها كمُرسَلة.
ابنِ كل مستمع بحيث يكون idempotent، مع سجل الأحداث المعالَجة في معاملة كتاباته نفسها.
أضف إعادة المحاولة مع التباطؤ التدريجي وطابور الرسائل الميتة وتنبيهًا عليه قبل أول طلب حقيقي.
انقل المستهلكين واحدًا بعد الآخر: المخزون أولًا، ثم دفتر الأستاذ، ثم الرسائل والولاء. وأوقف المهمة الليلية في الآخر، بعد أسبوع من تطابق الأرقام.
أسئلة للمورّد
هل يُكتب الحدث في المعاملة نفسها مع التغيير التجاري، أم يُنشر بعد ذلك؟
ماذا يحدث إذا وصل الحدث نفسه مرتين؟ اطلب أن يُريك مفتاح إزالة التكرار.
أين تذهب الأحداث الفاشلة، ومن يُنبَّه، وهل أستطيع إعادة تشغيلها؟
هل أستطيع رؤية سجل أحداث طلب واحد بأوقاتها ومعرفة المعالج الذي عالج كل حدث؟
طريقة للتحقق من هذه الادعاءات على نظام حقيقي: منصة تعمل على خوادمك مثل StoreConsole تشغّل وحدات المخزون والمحاسبة والولاء كمستمعين منفصلين على أحداث الطلب نفسها.
ماذا تقيس
تأخر outbox: عمر أقدم صف لم يُرسَل. ثوانٍ معدودة تعني حالة سليمة.
عمق الطابور وزمن المعالجة لكل مستمع.
عدد الرسائل الميتة: يجب أن يبقى قريبًا من الصفر، ولا ينمو بصمت.
معدل التخطي بسبب التكرار: رقم أكبر من الصفر يثبت أن إزالة التكرار تعمل.
فحص الانحراف اليومي: وحدات دفتر المخزون مقابل الوحدات الناتجة عن الطلبات، وإيرادات الدفتر مقابل إيرادات الطلبات. ويجب أن يكون الفرقان صفرًا.
صباح الاثنين نفسه، بعد إعادة البناء
نعود إلى المدير المالي وطلباته الـ 1,900. كل طلب كتب حدثه في المعاملة نفسها مع البيع، فهناك 1,900 صف في outbox ولم يضع أي منها. عند الطلب 1,412 انتهت مهلة مستمع دفتر الأستاذ. انتظرت الطلبات من 1,412 إلى 1,900 في الطابور، وأعادت المحاولة بتباطؤ تدريجي، وتم ترحيلها خلال دقائق. وفشل طلبان خمس مرات بسبب رمز ضريبي خاطئ، فانتقلا إلى طابور الرسائل الميتة وأطلقا تنبيهًا بعد ظهر السبت، لا يوم الثلاثاء.
أما بيع السبت في الفرع المجاور فقد حجز المخزون لحظة حدوثه، فكانت السترات الـ 14 أربع عشرة فعلًا. وفحص الانحراف صباح الاثنين يُظهر صفرًا في العمودين. لم يُفتح جدول المطابقة.
أبرز الخلاصات
الأمر يطلب ويمكن رفضه، والحدث يقرّر واقعة حدثت. المنتِج يعلن الأحداث ولا يعرف من يستمع إليها.
اكتب الحدث في معاملة التغيير التجاري نفسها (outbox) كي لا يضيع أبدًا.
التسليم «مرة واحدة بالضبط» وعد غير واقعي. ابنِ تسليمًا مرة واحدة على الأقل مع مستمعين idempotent.
سجّل معرّف الحدث المعالَج في معاملة كتابات المستمع نفسها.
أعد المحاولة مع التباطؤ التدريجي، ثم انقل إلى طابور الرسائل الميتة ونبّه. فشل ظاهر خير من فجوة صامتة.
استخدمه عندما يكون للإجراء ثلاث نتائج مستقلة أو أكثر. وتجنّبه في تطبيقات CRUD البسيطة.
Anichur Rahaman مهندس برمجيات معماري ومؤسس StoreConsole، يصمّم أنظمة التجارة وERP للشركات النامية، ويهتم خصوصًا بالبنية القائمة على الأحداث وسلامة البيانات وتشغيل الأنظمة على خوادم الشركة نفسها.