حرب البيانات الأهلية: كيف أطفأ نمط CQRS النار في نظامنا؟

يا جماعة الخير، يسعد مساكم. معكم أخوكم أبو عمر.

بتذكر قبل كم سنة، كنا شغالين على نظام تجارة إلكترونية كبير، وكان عنا موسم تخفيضات ضخم، “الجمعة البيضاء” زي ما بحكوها. قبل الموسم بكم يوم، كل شي كان تمام، والنظام شغال زي الساعة. لكن لما بلّش اليوم الموعود، وانهالت علينا الطلبات والزوار بالآلاف في الدقيقة الواحدة، صارت الكارثة.

النظام صار أبطأ من سلحفاة مصابة بالروماتيزم. المستخدمون بضيفوا منتجات للسلة، ولما يجوا يدفعوا، الصفحة بتعلّق. بنفس الوقت، فريق إدارة المخزون بحاول يحدث كميات المنتجات اللي بتنباع بسرعة، بس التحديثات تبعتهم ما بتوصل. وفوق كل هاد، فريق التسويق بحاول يسحب تقارير لحظية عن أكثر المنتجات مبيعًا عشان يوجهوا حملاتهم الإعلانية، والتقارير بتطلع فاضية أو بتعطي خطأ.

باختصار، قاعدة البيانات تبعتنا كانت في حالة “طوشة” عنيفة، أو زي ما بحب أسميها “حرب أهلية”. عمليات الكتابة (إضافة طلب جديد، تحديث مخزون) كانت بتتخانق مع عمليات القراءة (عرض المنتجات، سحب التقارير، البحث). كل عملية كانت بتسكر الطريق على الثانية، وصار عنا ما يسمى بـ “Deadlocks” أو “الجمود”. يومها حسيت إنه كل شغل الشهور اللي فاتت رح يروح عالفاضي. ومن قلب هالجحيم، لقينا طوق النجاة في نمط معماري غيّر طريقة تفكيرنا تمامًا: نمط فصل مسؤوليات الأوامر والاستعلامات أو الـ CQRS.

المشكلة التقليدية: نموذج واحد ليحكمهم جميعًا

قبل ما نحكي عن الحل، خلينا نفهم أصل المشكلة. في معظم التطبيقات التقليدية، بنستخدم نفس نموذج البيانات (Data Model) لعمليات القراءة والكتابة. يعني، جدول Products في قاعدة البيانات بنستخدمه عشان:

  • نكتب عليه: لما نضيف منتج جديد أو نعدّل سعره (Create, Update).
  • نقرأ منه: لما نعرض قائمة المنتجات للمستخدم أو نبحث عن منتج معين (Read).

هذا الأسلوب، المعروف بـ CRUD (Create, Read, Update, Delete)، بكون ممتاز للتطبيقات البسيطة والمتوسطة. لكن لما يكبر النظام وتتعقد العمليات، بتبدأ المشاكل تظهر.

لماذا يفشل النموذج الموحد عند الضغط العالي؟

تخيل نموذج البيانات تبعك كأنه شارع باتجاه واحد. السيارات اللي بدها تدخل (Writes) والسيارات اللي بدها تطلع (Reads) لازم تستخدم نفس الشارع. لما تكون الحركة خفيفة، الأمور بتمشي. بس لما تصير أزمة سير، بتوقف الدنيا كلها.

  1. تعارض الأهداف (Conflicting Optimizations): عشان تكون عمليات الكتابة سريعة، بنحتاج نموذج بيانات مُطَبَّع (Normalized) يمنع تكرار البيانات ويضمن سلامتها. لكن عشان تكون عمليات القراءة سريعة، غالبًا ما نحتاج نموذج بيانات غير مُطَبَّع (Denormalized) يجمع كل البيانات اللي بنحتاجها في مكان واحد لتجنب عمليات الربط (JOINs) المعقدة. من المستحيل تحسين النموذج الواحد للهدفين المتعارضين هدول بنفس الكفاءة.
  2. قفل الجداول (Table Locking): لما عملية كتابة معقدة (مثل معالجة طلب شراء كامل) تبدأ، قاعدة البيانات ممكن تفرض قفل (Lock) على بعض الجداول عشان تضمن سلامة البيانات. خلال فترة القفل هاي، أي عملية قراءة بتحاول توصل لنفس الجداول بتضطر تستنى. وإذا كانت عمليات الكتابة والقراءة كثيرة، بصير النظام كله في حالة انتظار.
  3. التعقيد الزائد: النموذج الواحد بصير معقد جدًا مع الوقت. بصير مليان حقول وجداول علاقات بس عشان يخدم تقرير معين أو شاشة معينة، وهذا بعقّد عمليات الكتابة وبيبطئها.

الحل السحري: فصل السلطات مع CQRS

نمط CQRS (Command Query Responsibility Segregation) هو مبدأ بسيط لكن عبقري. الفكرة الأساسية هي: “يجب أن تفصل النموذج الذي تستخدمه لتغيير حالة النظام عن النموذج الذي تستخدمه لقراءة حالة النظام.”

ببساطة، بدل ما يكون عنا شارع واحد، بنعمل شارعين منفصلين ومستقلين:

  • مسار الأوامر (Command Side): مسؤول فقط عن تغيير البيانات (Create, Update, Delete).
  • مسار الاستعلامات (Query Side): مسؤول فقط عن قراءة البيانات (Read).

كيف يعمل هذا على أرض الواقع؟

لما نطبق CQRS، بنقسم نظامنا لجزئين واضحين.

1. جانب الأوامر (The Write Side)

هذا الجانب بهتم بتنفيذ الأوامر اللي بتغير حالة النظام. الأمر (Command) هو كائن بحمل النية لتغيير شيء ما، مثل PlaceOrderCommand أو UpdateProductStockCommand. ما برجع بيانات، بالكثير برجع تأكيد إنه العملية نجحت أو فشلت.

النموذج في هذا الجانب (Write Model) بكون مُحسَّن لعمليات الكتابة السريعة والتحقق من صحة البيانات (Validation). غالبًا بكون نموذج مُطَبَّع (Normalized) جدًا في قاعدة بيانات علائقية (Relational Database).

نصيحة من أبو عمر: فكر في الأوامر كأنها طلبات فعلية. أنت لا تقول للنظام “حدث حقل الكمية في جدول المنتجات إلى 5″، بل تقول “نفذ أمر بيع 3 قطع من المنتج X”. الأمر يحمل المعنى التجاري (Business Intent)، وهذا أعمق بكثير من مجرد تحديث قاعدة بيانات.

مثال بسيط لكود C# يمثل الأمر والمعالج تبعه:


// الأمر نفسه - مجرد حاوية بيانات
public class AddProductToInventoryCommand
{
    public Guid ProductId { get; set; }
    public string Name { get; set; }
    public int Quantity { get; set; }
}

// معالج الأمر - هنا يكمن منطق العمل الحقيقي
public class AddProductToInventoryCommandHandler
{
    private readonly IProductRepository _repository;

    public AddProductToInventoryCommandHandler(IProductRepository repository)
    {
        _repository = repository;
    }

    public void Handle(AddProductToInventoryCommand command)
    {
        // 1. التحقق من صحة البيانات
        if (command.Quantity <= 0)
        {
            throw new InvalidOperationException("Quantity must be positive.");
        }

        // 2. إنشاء الكيان (Entity) وحفظه
        var product = new Product(command.ProductId, command.Name, command.Quantity);
        _repository.Save(product);

        // 3. (مهم جداً) نشر حدث لإعلام باقي أجزاء النظام
        // More on this later...
    }
}

2. جانب الاستعلامات (The Read Side)

هذا الجانب هو المسؤول الوحيد عن تزويد واجهات المستخدم بالبيانات اللي بتحتاجها للعرض. الاستعلام (Query) هو طلب مباشر للبيانات، مثل GetProductDetailsQuery أو GetAllOrdersForUserQuery.

النموذج في هذا الجانب (Read Model) هو نجم العرض الحقيقي. هو عبارة عن نموذج بيانات مُحسَّن خصيصًا لعمليات القراءة السريعة. غالبًا ما يكون غير مُطَبَّع (Denormalized) تمامًا. ممكن يكون:

  • جدول بسيط في نفس قاعدة البيانات.
  • مستند JSON في قاعدة بيانات NoSQL مثل MongoDB.
  • بيانات مفهرسة في محرك بحث مثل Elasticsearch.
  • كائنات محفوظة في ذاكرة مؤقتة (Cache) مثل Redis.

الفكرة هي أنك تجهز البيانات مسبقًا بالشكل اللي بتحتاجه الشاشة بالضبط، عشان لما يجي طلب القراءة، تكون الإجابة جاهزة بدون أي معالجة.


// الاستعلام
public class GetProductDetailsQuery
{
    public Guid ProductId { get; set; }
}

// نموذج القراءة - كائن بسيط ومسطح (Flat DTO)
public class ProductDetailsViewModel
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }
    public string CategoryName { get; set; } // بيانات مجمعة من جداول أخرى
}

// معالج الاستعلام
public class GetProductDetailsQueryHandler
{
    private readonly IReadDatabase _readDb;

    public GetProductDetailsQueryHandler(IReadDatabase readDb)
    {
        _readDb = readDb;
    }

    public ProductDetailsViewModel Handle(GetProductDetailsQuery query)
    {
        // قراءة مباشرة من نموذج القراءة المحسّن
        // لا يوجد أي JOINs أو منطق معقد هنا
        return _readDb.GetProductDetails(query.ProductId);
    }
}

طيب، كيف بتتزامن البيانات بين الجهتين؟

هذا هو السؤال الأهم. بما أنه صار عنا نموذجين منفصلين، كيف بنضمن إنه لما أضيف منتج جديد في جانب الكتابة (Write Side)، يظهر في جانب القراءة (Read Side)؟

الجواب هو من خلال آلية نشر الأحداث (Events). بعد أن يقوم معالج الأمر (Command Handler) بتغيير الحالة بنجاح في قاعدة بيانات الكتابة، يقوم بنشر حدث يصف ما حدث، مثل ProductWasAdded أو OrderWasPlaced.

هناك مكون آخر في النظام، اسمه “مُحدِّث نموذج القراءة” (Read Model Projector/Updater)، وظيفته الاستماع لهذه الأحداث. عندما يسمع حدث ProductWasAdded، يقوم بأخذ البيانات من الحدث وتحديث نماذج القراءة الخاصة به. ممكن يكون في أكثر من مُحدِّث؛ واحد يحدث قاعدة بيانات البحث، وآخر يحدث الكاش، وثالث يحدث جدول التقارير.

هذا يقودنا لمفهوم مهم: الاتساق النهائي (Eventual Consistency). هذا يعني أنه قد يكون هناك تأخير بسيط جدًا (أجزاء من الثانية غالبًا) بين لحظة تنفيذ الكتابة ولحظة تحديث بيانات القراءة. وهذا مقبول في معظم سيناريوهات العمل. لما تعمل “لايك” على بوست في فيسبوك، العدد لا يتحدث لكل مستخدمي العالم في نفس اللحظة، وهذا طبيعي.

CQRS مع صديقه الوفي: مصادر الأحداث (Event Sourcing)

صحيح أن CQRS يعمل بشكل جيد لوحده، لكنه يصبح خارقًا عندما يقترن بنمط آخر اسمه “مصادر الأحداث” (Event Sourcing).

شو قصة الـ Event Sourcing؟

ببساطة شديدة: بدلًا من تخزين الحالة النهائية للبيانات، قم بتخزين سلسلة الأحداث التي أدت إلى هذه الحالة.

فكر في حسابك البنكي. البنك لا يخزن رصيدك الحالي فقط. المصدر الحقيقي للمعلومة هو كشف الحساب (statement) الذي يحتوي على كل عمليات الإيداع والسحب. رصيدك الحالي هو مجرد نتيجة لتطبيق كل هذه العمليات (الأحداث) بالترتيب.

في الـ Event Sourcing، قاعدة بيانات الكتابة تبعتنا ما بتكون جداول عادية، بل بتكون سجل أحداث (Event Log) لا يمكن التعديل عليه، فقط الإضافة إليه. كل تغيير في النظام هو حدث جديد يتم تسجيله.

لماذا هذا الاقتران قوي؟

عندما نستخدم Event Sourcing مع CQRS، يصبح سجل الأحداث هو الجسر الطبيعي بين جانب الكتابة وجانب القراءة. العملية تصير كالتالي:

  1. الأمر (Command) يصل.
  2. معالج الأمر يتحقق منه، وإذا كان صالحًا، ينتج حدثًا أو أكثر (e.g., OrderPlaced).
  3. هذه الأحداث يتم حفظها في سجل الأحداث (Event Store). هذه هي عملية الكتابة الوحيدة.
  4. مُحدِّثات نماذج القراءة (Read Model Projectors) تشترك في سجل الأحداث هذا.
  5. كلما تم إضافة حدث جديد، تستلمه هذه المحدثات وتستخدمه لتحديث نماذج القراءة الخاصة بها.

هذا التصميم يعطينا قوة هائلة: القدرة على إعادة بناء أي نموذج قراءة من الصفر بمجرد إعادة تشغيل الأحداث من البداية، والقدرة على إضافة نماذج قراءة جديدة في المستقبل بدون لمس الكود القديم، وسجل تدقيق كامل لكل ما حدث في النظام.

نصائح أبو عمر الذهبية 📝

بعد ما خضنا هالتجربة، تعلمت كم درس حابب أشارككم إياها:

  • مش كل طير بتّاكل لحمه: نمط CQRS ليس حلًا لكل المشاكل. هو يضيف درجة من التعقيد على النظام. لا تستخدمه في تطبيق CRUD بسيط. استخدمه فقط عندما تكون المشكلة حقيقية: لديك نماذج قراءة وكتابة مختلفة جدًا، أو تعاني من مشاكل أداء بسبب تعارض العمليات.
  • تعامل مع الاتساق النهائي بحكمة: يجب أن يكون فريق العمل وواجهات المستخدم على دراية بهذا المفهوم. أحيانًا، تحتاج إلى إعطاء المستخدم شعورًا بأن العملية تمت فورًا حتى لو كان نموذج القراءة لم يتحدث بعد. مثلاً، بعد أن يضغط المستخدم على “إتمام الشراء”، يمكنك إعادة توجيهه لصفحة “شكرًا لك، طلبك قيد المعالجة” بدلًا من الانتظار حتى يتم تحديث كل شيء.
  • ابدأ بسيط، يا خال: ليس عليك استخدام قاعدتي بيانات منفصلتين من اليوم الأول. يمكنك البدء بتطبيق CQRS منطقيًا داخل نفس التطبيق ونفس قاعدة البيانات (باستخدام جداول مختلفة أو مخططات مختلفة). وعندما تحتاج، يمكنك فصلهم فيزيائيًا.
  • الأدوات بتساعد: هناك العديد من المكتبات والأطر التي تسهل تطبيق CQRS و Event Sourcing، مثل MediatR في عالم .NET، و Axon Framework في عالم Java. هذه الأدوات توفر عليك كتابة الكثير من الكود المتكرر.

الخلاصة

في قصتنا، تطبيق نمط CQRS كان بمثابة استدعاء قوات حفظ السلام لإنهاء الحرب الأهلية في قاعدة بياناتنا. فصلنا مسار الأوامر السريع والمركز عن مسارات الاستعلامات المتعددة والمُحسَّنة للقراءة. كل طرف صار يعمل في بيئته الخاصة بدون ما يزعج الآخر. النتيجة كانت نظامًا أسرع، أكثر استقرارًا، وقابلًا للتطوير بشكل لا يصدق.

تذكر دائمًا، الهندسة المعمارية للبرمجيات تشبه بناء المباني. لا تستخدم أساسات ناطحة سحاب لبناء بيت من طابق واحد. لكن عندما تقرر بناء ناطحة سحاب، تأكد من أن أساساتك قوية وقادرة على تحمل الضغط. نمط CQRS هو واحد من تلك الأساسات القوية التي يجب أن تكون في صندوق أدواتك كمطور برمجيات محترف. 🚀

افهم مشكلتك أولاً، ثم اختر الدواء المناسب. ويعطيكم ألف عافية.

أبو عمر

سجل دخولك لعمل نقاش تفاعلي

كافة المحادثات خاصة ولا يتم عرضها على الموقع نهائياً

آراء من النقاشات

لا توجد آراء منشورة بعد. كن أول من يشارك رأيه!

آخر المدونات

أتمتة العمليات

بيئتنا التجريبية كانت شبحاً: كيف أنقذتنا ‘البنية التحتية كشيفرة’ (IaC) من جحيم ‘لكنها تعمل على جهازي’؟

أتذكر جيداً تلك الليالي الطويلة التي قضيناها في تصحيح أخطاء لا تظهر إلا في البيئة التجريبية، وكيف كانت جملة "لكنها تعمل على جهازي!" تتردد كالكابوس....

11 أبريل، 2026 قراءة المزيد
نصائح برمجية

بياناتي كانت تتغير بشكل غامض: كيف أنقذتنا ‘اللامتغيرية’ (Immutability) من جحيم الآثار الجانبية الخفية؟

في أحد المشاريع، كنا نعاني من أخطاء غامضة حيث تتغير البيانات دون سابق إنذار. أشارككم قصتي وكيف كان مفهوم "اللامتغيرية" (Immutability) هو طوق النجاة الذي...

11 أبريل، 2026 قراءة المزيد
تجربة المستخدم والابداع البصري

تصاميمنا كانت جزرًا معزولة: كيف أنقذتنا ‘رموز التصميم’ (Design Tokens) من جحيم عدم الاتساق

في هذا المقال، أشارككم قصة حقيقية من مطبخ البرمجة عن الفوضى التي عشناها بسبب عدم الاتساق في التصاميم بين المنصات المختلفة. سأشرح لكم كيف كانت...

11 أبريل، 2026 قراءة المزيد
البودكاست