استعلامات القراءة تقتل الكتابة: كيف أنقذنا نمط CQRS من جحيم الاختناقات؟

يا أهلاً وسهلاً فيكم جميعاً، معكم أبو عمر.

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

صارت الطلبات بطيئة، وعمليات إضافة المنتجات للسلة بتفشل، والدعم الفني بغرق بالشكاوي. قعدنا نحلل المشكلة على أعصابنا، والضغط مليون. اكتشفنا إشي غريب: اللي كان بخنق النظام مش عمليات الشراء (الكتابة)، بل عمليات التصفح والاستعلام (القراءة)! آلاف المستخدمين كانوا بفتحوا نفس صفحة المنتج، بيعملوا تحديث للأسعار، وبيقرأوا التعليقات. هاي العمليات كانت بتعمل قفل (Lock) على جداول قاعدة البيانات، فتيجي عملية “إضافة للسلة” أو “إتمام الشراء” المسكينة، تلاقي الباب مسكر وتستنى، لحد ما يمل منها النظام وتفشل. وقتها، واحد من الشباب صاح فينا: “يا جماعة الخير، الحمل كله على نفس النموذج… القراءة والكتابة بتخانقوا على نفس الموارد!”. كانت هاي هي اللحظة اللي قررنا فيها إنو الحل التقليدي ما عاد ينفع، وإنه لازم نلجأ لسلاح أكثر تطوراً: نمط CQRS.

ما قبل العاصفة: المعمارية التقليدية تحت الضغط

في معظم التطبيقات اللي بنبنيها، خصوصاً في البداية، بنعتمد على معمارية بسيطة ومباشرة. بنستخدم نمط CRUD (Create, Read, Update, Delete) مع نموذج بيانات واحد (Data Model) لكل شيء. يعني، نفس الكلاس أو الكيان (Entity) اللي بنستخدمه عشان نعرض بيانات المنتج للمستخدم، هو نفسه اللي بنستخدمه عشان نحدث سعره أو كميته في المخزن.

للتشبيه، تخيل شارع باتجاه واحد بتستخدمه الشاحنات الثقيلة البطيئة (عمليات الكتابة) والسيارات الرياضية السريعة (عمليات القراءة). لما يكتروا السيارات، الشاحنة ما بتعرف تمشي. ولما تمر شاحنة، كل السيارات بتوقف وراها. هاي هي مشكلتنا بالضبط!

النموذج الواحد لكل شيء (One Model to Rule Them All)

خلينا نشوف مثال بسيط باستخدام ORM مثل Entity Framework في لغة C#. هذا هو نموذج المنتج التقليدي:


// The "One Model" for everything
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }

    // Navigation properties for complex relations
    public ICollection<Review> Reviews { get; set; }
    public int CategoryId { get; set; }
    public Category Category { get; set; }

    // Methods for business logic (Writes)
    public void UpdateStock(int quantity)
    {
        if (quantity < 0) throw new Exception("Stock cannot be negative.");
        StockQuantity = quantity;
    }
}

هذا النموذج رائع للكتابة، لأنه بيحتوي على منطق العمل (Business Logic) والعلاقات المعقدة اللي بتحافظ على تكامل البيانات. لكن لما بدنا نعرض قائمة منتجات بسيطة في الواجهة الأمامية، إحنا ما بنحتاج كل هاي التفاصيل والعلاقات. تحميل كل هاي البيانات لكل منتج في قائمة طويلة هو هدر للموارد وبطء مضمون.

الحل السحري؟ لا، بل هندسة واعية: تقديم نمط CQRS

وهنا يأتي دور نمط “فصل مسؤولية الأوامر والاستعلامات” أو بالإنجليزية Command and Query Responsibility Segregation (CQRS). الفكرة، بكل بساطة، هي التوقف عن استخدام نموذج واحد لكل شيء، والبدء في فصل مسارات العمل.

ما هو الـ CQRS بالضبط؟ (شو القصة يعني؟)

الـ CQRS يقسم عمليات التطبيق إلى نوعين مختلفين تماماً:

  • الأوامر (Commands): هي أي عملية تهدف إلى تغيير حالة النظام. مثلاً: CreateProduct, UpdateUserAddress, PlaceOrder. الأوامر لا تُرجع بيانات، بل تنفذ مهمة. مهمتها هي “الفعل” وليس “السؤال”.
  • الاستعلامات (Queries): هي أي عملية تهدف إلى قراءة بيانات من النظام دون تغييرها. مثلاً: GetProductById, ListOrdersForUser. الاستعلامات لا تُغيّر أي شيء، هي فقط “تسأل” عن معلومات.

الجمال في هذا الفصل هو أنه يسمح لنا ببناء نموذجين (أو أكثر) مختلفين تماماً: نموذج مُحسَّن لعمليات الكتابة (الأوامر)، ونموذج آخر مُحسَّن لعمليات القراءة (الاستعلامات).

جانب الكتابة وجانب القراءة

مع تطبيق CQRS، يصبح لدينا عالمان متوازيان:

1. نموذج الكتابة (The Write Model / Command Side)

  • الهدف: الحفاظ على تكامل البيانات (Data Integrity) وتطبيق قواعد العمل المعقدة.
  • الخصائص: يستخدم نماذج غنية (Rich Domain Models)، وقواعد بيانات مُفهرسة بشكل جيد (Normalized)، ويركز على المعاملات (Transactions). هذا هو الحارس الأمين على بياناتك.
  • مثال: هنا نستخدم كلاس Product الكامل الذي رأيناه سابقاً بكل تفاصيله ومنطق العمل الخاص به.

2. نموذج القراءة (The Read Model / Query Side)

  • الهدف: السرعة القصوى في جلب البيانات لعرضها.
  • الخصائص: يستخدم نماذج بيانات بسيطة ومسطحة (DTOs – Data Transfer Objects)، وغالباً ما تكون البيانات غير مفهرسة (Denormalized) وجاهزة للعرض مباشرة. يمكن أن يستخدم قاعدة بيانات مختلفة تماماً (مثل Elasticsearch للبحث، أو Redis للكاش، أو قاعدة بيانات NoSQL).
  • مثال: بدلاً من كلاس Product المعقد، نستخدم كلاس بسيط جداً:

// A simple DTO for the read model
public class ProductListViewDto
{
    public int ProductId { get; set; }
    public string ProductName { get; set; }
    public decimal ProductPrice { get; set; }
    public string ImageUrl { get; set; }
    // No complex objects, no business logic. Just data.
}

يلا نبرمج: تطبيق CQRS خطوة بخطوة

الكلام النظري جميل، لكن خلينا نشوف كيف ممكن نطبق هذا الحكي عملياً. سأستخدم أمثلة بلغة C# مع مكتبة MediatR الشهيرة التي تسهل تطبيق هذا النمط.

أولاً: الأوامر (Commands)

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


// 1. The Command object: A simple data carrier
public class UpdateStockCommand : IRequest
{
    public int ProductId { get; set; }
    public int NewQuantity { get; set; }
}

// 2. The Command Handler: Where the magic happens
public class UpdateStockCommandHandler : IRequestHandler<UpdateStockCommand>
{
    private readonly IProductRepository _productRepository; // Interacts with the WRITE database

    public UpdateStockCommandHandler(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public async Task<Unit> Handle(UpdateStockCommand request, CancellationToken cancellationToken)
    {
        // Get the full, rich domain object from the write model
        var product = await _productRepository.GetByIdAsync(request.ProductId);

        if (product == null)
        {
            throw new Exception("Product not found!");
        }

        // Apply business logic using methods on the domain object
        product.UpdateStock(request.NewQuantity);

        // Save the changes back to the write database
        await _productRepository.UpdateAsync(product);

        // Note: We return "Unit" which is like void. We don't return data.
        return Unit.Value;
    }
}

لاحظ أن معالج الأمر (Handler) يركز فقط على منطق العمل الصحيح. إنه لا يهتم بكيفية عرض البيانات لاحقاً.

ثانياً: الاستعلامات (Queries)

الآن، نريد عرض تفاصيل منتج. سنقوم بإنشاء وإرسال استعلام.


// 1. The Query object
public class GetProductDetailsQuery : IRequest<ProductDetailsDto>
{
    public int ProductId { get; set; }
}

// 2. The Read Model DTO (Data Transfer Object)
public class ProductDetailsDto
{
    public string Name { get; set; }
    public string Description { get; set; }
    public decimal Price { get; set; }
    public string CategoryName { get; set; } // Denormalized data!
}

// 3. The Query Handler: Simple, fast, and direct
public class GetProductDetailsQueryHandler : IRequestHandler<GetProductDetailsQuery, ProductDetailsDto>
{
    // We can inject a connection to a different, optimized READ database
    private readonly IDbConnection _readDbConnection;

    public GetProductDetailsQueryHandler(IDbConnection readDbConnection)
    {
        _readDbConnection = readDbConnection;
    }

    public async Task<ProductDetailsDto> Handle(GetProductDetailsQuery request, CancellationToken cancellationToken)
    {
        // Use a lightweight tool like Dapper for raw performance
        // This query hits a simple, denormalized view or table
        var sql = @"SELECT p.Name, p.Description, p.Price, c.Name as CategoryName
                    FROM ProductReadModels p 
                    JOIN CategoryReadModels c ON p.CategoryId = c.Id
                    WHERE p.ProductId = @ProductId";
        
        var product = await _readDbConnection.QuerySingleOrDefaultAsync<ProductDetailsDto>(sql, new { request.ProductId });

        return product;
    }
}

هل رأيت الفرق؟ معالج الاستعلام يتجاوز كل نماذج العمل المعقدة ويذهب مباشرة إلى البيانات الجاهزة للعرض. إنه سريع جداً ولا يؤثر إطلاقاً على عمليات الكتابة.

ثالثاً: المزامنة بين العالمين

السؤال الذي يطرح نفسه: عندما نقوم بتحديث منتج في نموذج الكتابة، كيف يتم تحديث البيانات في نموذج القراءة؟

هنا يأتي مفهوم “الاتساق النهائي” (Eventual Consistency). الحل الأكثر شيوعاً وفعالية هو استخدام نظام الأحداث (Events).

  1. بعد أن يقوم معالج الأمر (UpdateStockCommandHandler) بحفظ التغييرات بنجاح في قاعدة بيانات الكتابة، يقوم بنشر حدث، مثل ProductStockUpdatedEvent.
  2. هناك “معالج حدث” (Event Handler) آخر يستمع لهذه الأحداث.
  3. عندما يستقبل هذا المعالج الحدث، تكون مهمته الوحيدة هي تحديث نموذج القراءة (Read Model) بالمعلومات الجديدة.

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

للمحترفين فقط: الارتقاء بـ CQRS مع نمط تخزين الأحداث (Event Sourcing)

إذا أردت أن تأخذ CQRS إلى المستوى التالي، يمكنك دمجه مع نمط “تخزين الأحداث” (Event Sourcing).
بدلاً من تخزين الحالة النهائية للبيانات (مثلاً، كمية المخزون هي 50)، نقوم بتخزين كل الأحداث التي أدت إلى هذه الحالة بالتسلسل: ProductCreated, StockIncreased by 100, ItemsSold (20), ItemsSold (30).
هذه السلسلة من الأحداث هي المصدر الوحيد للحقيقة (Single Source of Truth).
نموذج الكتابة في هذه الحالة هو مجرد آلية للتحقق من صحة الأوامر وإنتاج أحداث جديدة. أما نماذج القراءة (Read Models) فهي مجرد “إسقاطات” (Projections) يتم بناؤها من خلال قراءة تيار الأحداث. هذا يمنحك مرونة خرافية: يمكنك بناء نماذج قراءة جديدة في أي وقت بمجرد إعادة تشغيل الأحداث من البداية!

نصائح من أبو عمر (من الآخر)

  • “مش كل إشي بده CQRS”: هذا النمط يضيف تعقيداً للنظام. لا تستخدمه في تطبيق بسيط لإدارة المدونات. استخدمه عندما يكون لديك متطلبات أداء وتوسع عالية، وعندما تختلف نماذج القراءة والكتابة بشكل كبير.
  • ابدأ بسيطاً: لست مضطراً لاستخدام قاعدتي بيانات منفصلتين من اليوم الأول. يمكنك البدء باستخدام نفس قاعدة البيانات، لكن مع فصل نماذجك منطقياً (استخدم ORM للكتابة، وأداة خفيفة مثل Dapper للقراءة).
  • احتضن الاتساق النهائي: هذا المفهوم قد يكون صعباً على بعض فرق العمل أو العملاء. اشرح لهم الفوائد. في 99% من الحالات، لا يلاحظ المستخدم أبداً تأخيراً مدته 100 ميلي ثانية، لكنه بالتأكيد سيلاحظ إذا كان الموقع بطيئاً أو معطلاً.
  • الأدوات مهمة: استثمر في تعلم أدوات تساعدك على تطبيق هذا النمط، مثل مكتبات الوسيط (MediatR)، ووسطاء الرسائل (RabbitMQ, Kafka)، وقواعد بيانات القراءة المحسنة.

الخلاصة: متى تفصل بين الجيشين؟ ⚔️

في النهاية، CQRS ليس حلاً سحرياً، بل هو أداة قوية في صندوق أدوات مهندس البرمجيات. إنه قرار استراتيجي يتخذ عندما تصل المعمارية التقليدية إلى حدودها.

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

أبو عمر

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

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

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

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

آخر المدونات

نصائح برمجية

كانت بياناتنا تتغير من تحت أقدامنا: كيف أنقذتنا ‘اللامتغيرية’ (Immutability) من جحيم الأعطال الجانبية؟

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

1 يونيو، 2026 قراءة المزيد
ذكاء اصطناعي

نماذجنا اللغوية كانت تهلوس: كيف أنقذنا ‘التوليد المعزز بالاسترجاع’ (RAG) من جحيم المعلومات الخاطئة؟

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

1 يونيو، 2026 قراءة المزيد
خوارزميات

كان البحث عن أقرب سائق يستغرق دقيقة: كيف أنقذتنا ‘الأشجار الرباعية’ (Quadtrees) من جحيم الاستعلامات المكانية؟

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

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

كانت واجهاتنا تتصرف بعشوائية: كيف أنقذتنا ‘آلات الحالة المحدودة’ (State Machines) من جحيم الفوضى؟

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

1 يونيو، 2026 قراءة المزيد
برمجة وقواعد بيانات

كانت صفحاتنا تطلب مئات الاستعلامات: كيف أنقذنا ‘التحميل الشغوف’ (Eager Loading) من جحيم مشكلة N+1؟

أشارككم قصة حقيقية من الميدان، يوم كادت إحدى صفحات موقعنا أن تنهار تحت وطأة مئات استعلامات قاعدة البيانات. سأشرح لكم بالتفصيل مشكلة N+1 وكيف كان...

1 يونيو، 2026 قراءة المزيد
الحوسبة السحابية

كانت أسرارنا مكشوفة في الكود: كيف أنقذنا “مدير الأسرار” من جحيم التسريبات؟

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

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