أنماط المعمارية

CQRS: فصل نموذجَي القراءة والكتابة

18 دقيقة الدرس 4 من 10

CQRS: فصل نموذجَي القراءة والكتابة

فصل مسؤولية الأوامر عن الاستعلامات (CQRS) نمطٌ معماري يُقسّم التطبيق إلى مسارَين مستقلَّين: مسار الكتابة (الأوامر التي تُغيّر الحالة) ومسار القراءة (الاستعلامات التي تُعيد البيانات). الفكرة بسيطة لكنها قوية — شكل البيانات الذي تحتاجه لتخزينه بموثوقية نادراً ما يتطابق مع الشكل الذي تحتاجه لعرضه بكفاءة. إجبار المسارَين على نموذج واحد يُولّد احتكاكاً يتضاعف مع كل ميزة تُضيفها.

مشكلة النموذج الموحَّد

تخيّل خدمة طلبات في منصة تجارة إلكترونية. كتابة طلب تستلزم تحققاً صارماً وقواعد عمل وضمانات ACID. لكن قراءة الطلبات لصفحة لوحة التحكم تحتاج عرضاً مُدمَجاً مسبقاً وغير مُعيَّر يحتوي أسماء المنتجات والحالات والإجماليات — وغالباً ما يطلبها ملايين المستخدمين المتزامنين. نموذج Order الواحد مُلزَم بتلبية الاثنين. مسار الكتابة يتباطأ لأن مسار القراءة يتطلب تحميلاً معقداً؛ ومسار القراءة يُضيف فهارس تُعيق أداء الكتابة. تحجيم أحدهما يعني تحجيم الآخر رغم اختلاف عنق الزجاجة في كلٍّ منهما.

المفهوم الجوهري: الأوامر مقابل الاستعلامات

  • الأمر (Command) — نيّة تغيير الحالة: PlaceOrder، CancelOrder، UpdateInventory. يُتحقَّق من الأمر وينفَّذ على نموذج الكتابة (جانب الأوامر)، ثم إما ينجح أو يفشل. لا يُعيد بيانات سوى إقرار بالتنفيذ.
  • الاستعلام (Query) — طلب بيانات: GetOrderSummary، ListOrdersByCustomer. يقرأ من نموذج القراءة (جانب الاستعلامات) ويُعيد DTO مُشكَّلاً بدقة للمستدعي. لا يُغيّر أي حالة أبداً.
الفكرة الجوهرية: يجب أن تُغيّر الدالة الحالة أو تُعيد بيانات — لكن ليس الاثنين معاً. هذا المبدأ الذي صاغه برتران ميير هو أساس CQRS.
CQRS Architecture — Separated Read and Write Paths Client / UI Command Bus Query Bus Command Handler Query Handler Write DB (normalized, ACID) Read DB / Cache (denormalized, fast) async sync / projection Command Query WRITE PATH READ PATH
يُقسّم CQRS التطبيق إلى مسار كتابة (الأوامر ← قاعدة بيانات مُعيَّرة) ومسار قراءة (الاستعلامات ← مخزن قراءة غير مُعيَّر)، متزامنَين بشكل غير متزامن عبر الإسقاطات.

نموذج القراءة: الإسقاطات

حين ينجح أمر ما، يُصدر حدثاً (مثل OrderPlaced). الإسقاط (Projection) يستمع إلى هذه الأحداث ويبني عرضاً مُحسَّناً للقراءة — ربما مستنداً مُسطَّحاً في Elasticsearch أو جدولاً مُجسَّداً في نسخة قراءة. يتحدّث نموذج القراءة بشكل غير متزامن. هذا يعني أن جانب القراءة يمكن أن يكون غير مُعيَّر ومُدمَجاً مسبقاً ومُفهرَساً بدقة للاستعلامات التي تحتاجها واجهة المستخدم — بدون أي ضمّ (join) وقت الاستعلام.

مثال حقيقي: في Shopify، تحتاج قوائم المنتجات إلى بيانات من جداول المنتجات والمتغيّرات والمخزون والأسعار. على جانب الكتابة، كلٌّ منها مُعيَّر. على جانب القراءة، يُجمّع إسقاطٌ مستنداً واحداً لكل قائمة حتى يكون استعلام الواجهة مجرد بحث بمفتاح-قيمة — بدون ضمّ، بكمون أقل من ميلي ثانية حتى عند ملايين الطلبات في الدقيقة.

تحجيم كل جانب باستقلالية

بما أن المسارَين منفصلان، يمكن تحجيمهما باستراتيجيات مختلفة كلياً:

  • جانب الكتابة — يحتاج اتساقاً قوياً وسلامة تعاملية. يُستخدم عادةً قاعدة بيانات علائقية أساسية واحدة (PostgreSQL أو MySQL) مع تكرار متزامن. يُحجَّم رأسياً أو بالتقسيم (sharding) حسب معرّف التجميع.
  • جانب القراءة — يحتاج إنتاجية عالية وكموناً منخفضاً. يمكن أن يكون ذاكرة تخزين Redis أو مجموعة نسخ قراءة أو Elasticsearch أو كتلة Cassandra. أضف نسخاً كيفما شئت؛ لا تنافس في الكتابة.
أرقام حقيقية: تعالج خدمة موجز LinkedIn ما يزيد على 1.5 مليار قراءة موجز يومياً. من خلال الحفاظ على مخزن موجز مُحسَّب مسبقاً لكل مستخدم (نموذج القراءة) يُحدَّث بشكل غير متزامن عندما ينشر المتواصلون محتوى (نموذج الكتابة)، تكون كل قراءة موجز مجرد بحث واحد في الذاكرة بدلاً من ضمّ عبر عشرات الملايين من الصفوف.

المقايضة: الاتساق النهائي

المزامنة بين مخزن الكتابة ومخزن القراءة غير متزامنة. بعد أن يُقدّم المستخدم طلباً، قد تكون هناك نافذة زمنية — عادةً من ميلي ثوانٍ إلى بضع ثوانٍ — لم يُحدَّث فيها نموذج القراءة بعد. إذا أعاد المستخدم تحميل قائمة طلباته فوراً، قد لا يرى الطلب الجديد. هذا هو الاتساق النهائي: سيتقارب النظام في نهاية المطاف، لكن ليس فوراً.

CQRS Eventual Consistency Timeline time PlaceOrder Command received & persisted t=0ms OrderPlaced event published to queue t=5ms Projection handler updating read store t=80ms Read model updated; query returns order t=120ms ~120ms eventual consistency window
نافذة الاتساق النهائي: يُثبَّت الأمر فوراً، لكن نموذج القراءة لا يعكس التغيير إلا بعد أن تُعالج الإسقاطات الحدث (عادةً ميلي ثوانٍ إلى ثوانٍ).

متى تستخدم CQRS

CQRS ليس الاختيار الافتراضي — فهو يُضيف تعقيداً تشغيلياً. يُجدي استخدامه حين:

  • أحمال القراءة والكتابة غير متماثلة بشكل ملحوظ (مثلاً 100:1 قراءات مقابل كتابات).
  • يستلزم نموذج القراءة تجميعات معقدة مكلفة الحساب وقت الاستعلام.
  • تحتاج عدة تمثيلات للقراءة للبيانات ذاتها (تطبيق جوال، لوحة تحكم، خط تحليلات — لكلٍّ منها شكل مختلف).
  • تستخدم بالفعل مصدر الأحداث (Event Sourcing) (الدرس التالي)، حيث الإسقاطات ملاءمة طبيعية.
لا تلجأ إلى CQRS قبل الأوان. لتطبيق CRUD عادي بحركة مرور معقولة، نموذج واحد مع نسخ قراءة أبسط بكثير وعادةً كافٍ. يزيد CQRS عدد المكوّنات المتحركة: ستصون الآن مخططَين، وآلية مزامنة، وعليك التعامل مع أعطال الإسقاطات. طبّقه حيث يتسبب عدم تطابق القراءة/الكتابة في ألم قابل للقياس.

CQRS عملياً: مجموعة تقنية نموذجية

إعداد شائع في الإنتاج: يستخدم جانب الكتابة PostgreSQL مع تحقق صارم من المخطط؛ تُرسَل الأوامر عبر حافلة رسائل (مثل RabbitMQ أو Kafka) وتُعالَجها معالجات تتحقق من قواعد العمل قبل التثبيت. عند النجاح، يُنشَر حدث نطاق إلى الحافلة. مستهلكو الإسقاطات (خدمات منفصلة أو عمّال غير متزامنين) يشتركون في هذه الأحداث ويُحدّثون كتلة Elasticsearch أو مجموعات مُرتَّبة في Redis تُخدّم واجهة برمجة القراءة. تُقدّم واجهة برمجة القراءة نقاط GET خفيفة لا تفعل إلا البحث في مستندات مُعدَّة مسبقاً.

نشرت فرق في Netflix وUber وAmazon تغييرات على هذه البنية. تختلف التفاصيل لكن الفصل الجوهري — الكتابة في مخزن مُعيَّر، القراءة من عرض مُصمَّم لغرض محدد — متسق في جميعها.