حرب أهلية في قلب قاعدة البيانات
يا جماعة الخير، اسمحوا لي أرجع بالذاكرة كم سنة لورا. كنا شغالين على نظام إدارة موارد ضخم لشركة كبيرة، نظام فيه كل شي ممكن تتخيلوه: إدارة مخزون، فواتير، حسابات عملاء، تقارير تحليلية… يعني “من كل قطر أغنية”. في البداية، الأمور كانت “عال العال”. بنينا النظام بالطريقة التقليدية اللي تعلمناها كلنا: نموذج بيانات واحد (One Model to Rule Them All)، وخدمات (Services) بتتعامل مع هاد النموذج لكل شي: إضافة منتج جديد، تحديث سعره، عرض قائمة المنتجات في لوحة التحكم، استخراج تقرير مبيعات ربع سنوي.
لكن مع الوقت، ومع زيادة البيانات والمستخدمين، بلشت المشاكل تظهر. صارت عمليات القراءة (Queries) بطيئة جداً. تقرير المبيعات اللي كان ياخد ثواني، صار ياخد دقايق وممكن يعمل timeout. ليش؟ لأنه عشان يعرضلك ملخص بسيط، كان مضطر يعمل joins معقدة بين عشرات الجداول. وفي نفس الوقت، عمليات الكتابة (Commands) زي إضافة طلب جديد أو تحديث المخزون، كانت بتتأثر وبتصير بطيئة، وأحياناً تفشل بسبب الـ database locks اللي بتعملها التقارير الطويلة.
كنا حرفياً في حالة حرب أهلية داخل قاعدة البيانات. فريق “القراءة” بده بيانات مُجمّعة ومُبسّطة للعرض السريع، وفريق “الكتابة” بده بيانات مُفصّلة ودقيقة مع كل القيود والقواعد (constraints and business rules) عشان يضمن سلامة البيانات. والضحية؟ كان أداء النظام وتجربة المستخدم.
في اجتماع من الاجتماعات اللي كلها توتر، واحد من الشباب صاح: “يا عمي هدول الاثنين ما بنفع يعيشوا ببيت واحد!”. وقتها لمعت براسي فكرة نمط كنت قرأت عنه زمان وحسيته معقد زيادة عن اللزوم: CQRS. قلت لحالي، يمكن هلأ وقته. يمكن هاد هو الحل اللي بيفصل بين المتخاصمين وبيعمل صلح.
المشكلة الأساسية: نموذج “الكل في واحد”
قبل ما نحكي عن الحل، خلينا نفهم أصل المشكلة. معظمنا بيبدأ تطبيقاته بنمط CRUD (Create, Read, Update, Delete) الكلاسيكي. بيكون عنا نموذج (Model) واحد بيمثل كيان معين، مثلاً `Product`.
هذا النموذج بيكون غني ومليان تفاصيل:
- معلومات أساسية (ID, Name, SKU)
- معلومات تسويقية (Description, Images, SEO tags)
- معلومات تسعير (Price, Discount)
- معلومات مخزون (StockQuantity, WarehouseLocation)
- علاقات مع كيانات أخرى (Categories, Reviews, Supplier)
المشكلة إنه هاد النموذج الواحد بنستخدمه لكل شي:
- للكتابة (Write): لما بدنا ننشئ منتج جديد، لازم نعبّي كل الحقول الإجبارية ونتأكد من كل قواعد العمل (Business Logic).
- للقراءة (Read): لما بدنا نعرض قائمة منتجات في صفحة المتجر الرئيسية، إحنا بس بحاجة للاسم والصورة والسعر. لكننا بنحمّل النموذج الكامل من قاعدة البيانات (Over-fetching). ولما بدنا نعرض صفحة تفاصيل المنتج، بنحتاج معظم البيانات. ولما بدنا نعمل تقرير عن المنتجات الأكثر مبيعاً، بنحتاج نعمل حسابات معقدة على هاد النموذج.
هذا الخلط بين متطلبات القراءة والكتابة على نفس النموذج هو اللي بيخلق الجحيم اللي كنا فيه. عمليات القراءة بتصير معقدة وبطيئة لأن النموذج مصمم ليكون دقيقاً ومتوافقاً مع قواعد الكتابة (Normalized for writes). وعمليات الكتابة ممكن تتعطل بسبب الضغط اللي بتسببه عمليات القراءة.
الحل السحري: نمط فصل مسؤوليات الأوامر والاستعلامات (CQRS)
CQRS هو اختصار لـ Command Query Responsibility Segregation. الاسم طويل ومعقد، بس الفكرة بسيطة بشكل عبقري: “أي عملية بتغيّر حالة النظام هي ‘أمر’ (Command)، وأي عملية بتسترجع بيانات بدون ما تغيّرها هي ‘استعلام’ (Query). وهدول الشغلتين لازم يكونوا منفصلين تماماً”.
بدل ما يكون عنا نموذج واحد، بصير عنا نموذجين (أو أكثر):
- نموذج الكتابة (Write Model): هذا هو النموذج الغني والمعقد اللي فيه كل قواعد العمل والـ validation. مهمته الوحيدة هي تنفيذ الأوامر وتغيير حالة النظام. ما برجع بيانات، بالكثير برجع تأكيد (Success/Fail) أو ID للكيان الجديد.
- نموذج القراءة (Read Model): هذا نموذج بسيط، “غبي” ومسطح (flat). هو عبارة عن DTOs (Data Transfer Objects) مصممة خصيصاً لكل شاشة أو واجهة عرض. مهمته الوحيدة هي توفير البيانات للاستعلامات بأسرع شكل ممكن.
كيف يبدو هذا على أرض الواقع؟
خلونا ناخد مثال عملي. تخيل عنا أمر لتغيير سعر منتج. بالطريقة القديمة، كان ممكن يكون عنا هيك شي (مثال بـ C#):
// الطريقة القديمة: خدمة واحدة لكل شيء
public class ProductService
{
private readonly DbContext _context;
public Product UpdateProductPrice(int productId, decimal newPrice)
{
var product = _context.Products.Find(productId);
if (product == null) throw new Exception("Product not found");
// ... منطق معقد للتحقق من صلاحيات وقواعد العمل ...
product.Price = newPrice;
_context.SaveChanges();
return product; // نرجع النموذج الكامل مع كل بياناته
}
public ProductViewModel GetProductForDisplay(int productId)
{
// ... تحميل النموذج الكامل ثم تحويله لنموذج عرض ...
}
}
مع CQRS، بنفصل هاي المسؤوليات:
1. جانب الأوامر (The Command Side)
الأمر هو مجرد كائن بسيط بحمل النية (Intent) والبيانات اللازمة.
// الأمر: كائن بسيط يحمل البيانات
public class UpdateProductPriceCommand
{
public int ProductId { get; set; }
public decimal NewPrice { get; set; }
}
// معالج الأمر: هو اللي فيه الشغل الحقيقي
public class UpdateProductPriceCommandHandler
{
private readonly WriteDbContext _context; // لاحظ استخدام سياق بيانات خاص بالكتابة
public void Handle(UpdateProductPriceCommand command)
{
// 1. تحميل النموذج الكامل (Domain Model)
var product = _context.Products.Find(command.ProductId);
if (product == null) throw new Exception("Product not found");
// 2. تطبيق قواعد العمل المعقدة
product.ChangePrice(command.NewPrice); // قد يحتوي هذا الـ method على business logic
// 3. حفظ التغييرات
_context.SaveChanges();
// لا نرجع أي بيانات! المهمة انتهت.
}
}
2. جانب الاستعلامات (The Query Side)
الاستعلام هو طلب للبيانات، ونموذج القراءة مصمم خصيصاً له.
// نموذج القراءة: بسيط ومسطح ومخصص لواجهة العرض
public class ProductDetailsViewModel
{
public string Name { get; set; }
public decimal Price { get; set; }
public string Description { get; set; }
public double AverageRating { get; set; }
}
// معالج الاستعلام: مهمته جلب البيانات بأسرع طريقة
public class GetProductDetailsQueryHandler
{
private readonly ReadDbContext _context; // سياق بيانات خاص بالقراءة
public ProductDetailsViewModel Handle(int productId)
{
// استعلام مباشر ومُحسّن للحصول على البيانات المطلوبة فقط
// ممكن يكون هذا الاستعلام على View في قاعدة البيانات أو جدول Denormalized
var productView = _context.ProductDetailsViews
.FirstOrDefault(p => p.ProductId == productId);
return productView;
}
}
لاحظوا الفصل التام. معالج الأمر يتعامل مع النموذج الكامل والمعقد. معالج الاستعلام يتعامل مع نموذج عرض بسيط ومُحسّن للقراءة، وممكن يقرأ من مصدر بيانات مختلف تماماً.
القوة الحقيقية: عندما تنفصل قواعد البيانات
في البداية، يمكنك تطبيق CQRS باستخدام نفس قاعدة البيانات. مجرد فصل المنطق في الكود بحد ذاته بيعطي فوائد تنظيمية كبيرة. لكن القوة الحقيقية لـ CQRS بتظهر لما تفصل قواعد البيانات نفسها:
- قاعدة بيانات الكتابة (Write DB): بتكون قاعدة بيانات علائقية (Relational) مثل PostgreSQL أو SQL Server. تصميمها بيكون Normalized (النموذج الثالث الطبيعي 3NF) لضمان سلامة البيانات وتجنب التكرار. هي مُحسّنة لعمليات الـ Transactions السريعة (OLTP).
- قاعدة بيانات القراءة (Read DB): ممكن تكون أي شي! ممكن تكون نسخة Denormalized من البيانات في قاعدة بيانات علائقية (Materialized Views)، أو قاعدة بيانات NoSQL مثل Elasticsearch للبحث السريع، أو MongoDB للبيانات المرنة، أو حتى Redis لتخزين البيانات المؤقتة (Caching). هي مُحسّنة لعمليات القراءة الكثيفة والسريعة (OLAP).
“بس يا أبو عمر، كيف بتضل قاعدة بيانات القراءة محدّثة؟”
سؤال ممتاز. هنا يأتي دور المزامنة. بعد أن يقوم جانب الكتابة (Command) بتحديث قاعدة بياناته، يقوم بنشر “حدث” (Event) يقول “لقد تم تحديث سعر المنتج X”. هذا الحدث يتم إرساله عبر ناقل رسائل (Message Bus) مثل RabbitMQ أو Kafka. هناك “مستمع” (Listener) يستقبل هذا الحدث ويقوم بتحديث قاعدة بيانات القراءة (Read DB) بالمعلومات الجديدة.
هذا يقودنا لمفهوم “الاتساق النهائي” (Eventual Consistency). يعني أن قاعدة بيانات القراءة قد لا تكون محدّثة 100% في نفس اللحظة، قد يكون هناك تأخير بسيط (أجزاء من الثانية). وهذا مقبول في 99% من الحالات. عرض سعر منتج قديم لثانية واحدة على واجهة المتجر ليس كارثة.
نصائح من خبرة “الختيار” 🧔
بعد ما طبقنا هاد النمط وحلينا الحرب الأهلية في نظامنا، تعلمت كم شغلة بحب أشارككم فيها:
1. ليس لكل داء دواء
نمط CQRS ليس حلاً لكل المشاكل. إذا كنت تبني مدونة بسيطة أو تطبيق CRUD مباشر، استخدامه سيكون تعقيداً لا مبرر له (Over-engineering). “مش كل طبخة بدها بهارات كتير”. استخدمه في الأنظمة المعقدة التي يوجد فيها فرق واضح بين متطلبات القراءة والكتابة، أو عندما يكون الأداء عاملاً حاسماً.
2. ابدأ بسيطاً
لست مضطراً للبدء بقاعدتي بيانات منفصلتين وناقل رسائل من اليوم الأول. يمكنك البدء بتطبيق الفصل المنطقي داخل نفس التطبيق ونفس قاعدة البيانات. قسّم الكود إلى Commands و Queries. هذا بحد ذاته سيحسن من تنظيم الكود وقابليته للصيانة. وعندما تحتاج للأداء، ستكون خطوة الانتقال إلى قواعد بيانات منفصلة أسهل بكثير.
3. فكّر بـ “الاتساق النهائي”
إذا قررت استخدام قواعد بيانات منفصلة، يجب أن تتقبل أنت وفريقك وفهم العميل لفكرة “Eventual Consistency”. يجب تحديد أجزاء النظام التي تتطلب اتساقاً فورياً (Transactional Consistency) – مثل عمليات الدفع – والأجزاء التي يمكن أن تتحمل تأخيراً بسيطاً.
4. استعن بالأدوات
لا تعيد اختراع العجلة. هناك مكتبات ممتازة تساعد في تطبيق CQRS بسهولة، مثل مكتبة MediatR في عالم الـ .NET. هذه المكتبات توفر لك آلية بسيطة لإرسال الأوامر والاستعلامات إلى معالجاتها المناسبة بدون الحاجة لكتابة كود توصيل (boilerplate) معقد.
الخلاصة: السلام خير من ألف نموذج معقد
في النهاية، كان نمط CQRS هو “الدبلوماسي” الذي أنهى الحرب الأهلية في نظامنا. أعطى لكل طرف ما يحتاجه: جانب الكتابة حصل على نموذجه الدقيق والآمن، وجانب القراءة حصل على نماذجه السريعة والمُحسّنة للعرض. النتيجة كانت نظاماً أسرع، أكثر استقراراً، وأسهل بكثير في الصيانة والتطوير.
إذا وجدت نفسك يوماً في موقف مشابه، حيث تتعارض متطلبات القراءة والكتابة وتخنق أداء نظامك، تذكر قصة أبو عمر. لا تخف من فصل المتخاصمين. امنح كل مسؤولية مساحتها الخاصة، وشاهد كيف يعم السلام والوئام في معمارية برمجياتك. 🕊️