أنماط بنية البيانات للتوسع

مصادر الأحداث وCQRS والمعاملات الموزعة

4 دقيقة للقراءة

في مقابلات تصميم الأنظمة، المرشحون الذين يستطيعون شرح أنماط بنية البيانات بما يتجاوز CRUD الأساسي يُظهرون تفكيرًا على مستوى كبار المهندسين. يغطي هذا الدرس الأنماط التي تُشغّل الأنظمة في شركات مثل Uber وNetflix وShopify.

ما بعد CRUD: لماذا تهم مصادر الأحداث

أنظمة CRUD التقليدية تخزن الحالة الحالية فقط. عندما يتغير طلب من "قيد الانتظار" إلى "تم الشحن"، تُفقد الحالة السابقة. مصادر الأحداث تقلب هذا النموذج: خزّن كل تغيير كحدث غير قابل للتعديل، واستنتج الحالة الحالية بإعادة تشغيل سجل الأحداث.

الجانب CRUD مصادر الأحداث
التخزين الحالة الحالية فقط السجل الكامل للتغييرات
سجل المراجعة يتطلب تسجيلًا منفصلًا مدمج بالتصميم
تصحيح الأخطاء "ما الحالة الآن؟" "كيف وصلنا إلى هنا؟"
تكلفة التخزين أقل أعلى (يُخفف باللقطات)
التعقيد أقل أعلى

سجل الأحداث الملحق فقط

الأحداث هي حقائق غير قابلة للتغيير تصف ما حدث. لا يتم تحديثها أو حذفها أبدًا:

// أحداث المجال هي سجلات غير قابلة للتغيير لما حدث
interface OrderCreated {
  type: "OrderCreated";
  orderId: string;
  customerId: string;
  items: Array<{ productId: string; quantity: number; price: number }>;
  timestamp: string;
}

interface PaymentProcessed {
  type: "PaymentProcessed";
  orderId: string;
  paymentId: string;
  amount: number;
  timestamp: string;
}

type OrderEvent = OrderCreated | PaymentProcessed | OrderShipped | OrderCancelled;
from dataclasses import dataclass
from datetime import datetime

@dataclass(frozen=True)
class OrderCreated:
    order_id: str
    customer_id: str
    items: list[dict]
    timestamp: datetime

@dataclass(frozen=True)
class PaymentProcessed:
    order_id: str
    payment_id: str
    amount: float
    timestamp: datetime

إعادة تشغيل الأحداث واللقطات

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

Events:     [E1] -> [E2] -> [E3] -> [Snapshot@v3] -> [E4] -> [E5]
                                          |
                               أعد التشغيل من هنا (E4 وE5 فقط)

قاعدة عملية: أنشئ لقطة كل 100 حدث. هذا يُبقي وقت إعادة التشغيل أقل من بضع ميلي ثوانٍ حتى للكيانات طويلة العمر.

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

فصل مسؤولية الأوامر والاستعلامات (CQRS) يفصل نموذج الكتابة (الأوامر التي تُنتج أحداثًا) عن نموذج القراءة (الإسقاطات المحسّنة للاستعلامات).

                    +-----------------+
  Commands -------> | Command Handler | ----> Event Store
                    +-----------------+           |
                                                  | (publish)
                                                  v
                    +-----------------+     +-----------+
  Queries --------> | Read Model (DB) | <-- | Projector |
                    +-----------------+     +-----------+

لماذا نفصل النماذج؟

  • نموذج الكتابة: يفرض قواعد العمل، يتحقق من الثوابت، محسّن للاتساق
  • نموذج القراءة: غير مُطبّع، محسّن لأداء الاستعلامات، يمكن أن يحتوي على إسقاطات متعددة لحالات استخدام مختلفة

الاتساق النهائي

يتم تحديث نموذج القراءة بشكل غير متزامن بعد كتابة الأحداث. التأخير (عادةً 10-100 ميلي ثانية) مقبول لمعظم حالات الاستخدام. عندما يُطلب اتساق قوي، اقرأ مباشرةً من مخزن الأحداث.

// معالج الأوامر: يتحقق وينتج أحداثًا
function handlePlaceOrder(command: PlaceOrderCommand): OrderCreated {
  if (command.items.length === 0) {
    throw new Error("Order must have at least one item");
  }
  return {
    type: "OrderCreated",
    orderId: generateId(),
    customerId: command.customerId,
    items: command.items,
    timestamp: new Date().toISOString(),
  };
}

// الإسقاط: يبني عرضًا محسّنًا للقراءة من الأحداث
function projectOrder(events: OrderEvent[]): OrderReadModel {
  let order: OrderReadModel = { status: "unknown", items: [], total: 0 };
  for (const event of events) {
    switch (event.type) {
      case "OrderCreated":
        order = { status: "pending", items: event.items, total: sumItems(event.items) };
        break;
      case "PaymentProcessed":
        order = { ...order, status: "paid" };
        break;
      case "OrderShipped":
        order = { ...order, status: "shipped" };
        break;
    }
  }
  return order;
}

المعاملات الموزعة: نمط Saga

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

التنسيق مقابل التصميم الراقص

الجانب التنسيق (Orchestration) التصميم الراقص (Choreography)
التنسيق منسق مركزي الخدمات تتفاعل مع الأحداث
الاقتران منخفض (الخدمات لا تعرف بعضها) منخفض جدًا (لا منسق مركزي)
الرؤية سهل تتبع حالة Saga أصعب في تتبع التدفق
معالجة الفشل المنسق يدير التعويضات كل خدمة تتعامل مع حالتها
الأفضل لـ سير عمل معقد (5+ خطوات) سير عمل بسيط (2-3 خطوات)
Orchestration Saga: تقديم طلب
================================
[Orchestrator] ---> [Payment Service] : charge()
      |                    |
      | <--- success ------+
      |
      +---> [Inventory Service] : reserve()
      |                    |
      | <--- success ------+
      |
      +---> [Shipping Service] : schedule()
      |                    |
      | <--- FAILURE ------+
      |
      | تعويض:
      +---> [Inventory Service] : release()    (تراجع عن الحجز)
      +---> [Payment Service]   : refund()     (تراجع عن الخصم)

نمط Outbox

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

[Service] ---> BEGIN TRANSACTION
                 UPDATE orders SET status = 'paid'
                 INSERT INTO outbox (event_type, payload) VALUES ('PaymentProcessed', '...')
               COMMIT

[Outbox Relay] ---> SELECT * FROM outbox WHERE published = false
                    PUBLISH to message broker
                    UPDATE outbox SET published = true

هذا يضمن تسليمًا مرة واحدة على الأقل: الحدث يُنشر دائمًا إذا تم تأكيد المعاملة.

إطار اختيار قاعدة البيانات

المقابلات غالبًا تسأل: "لماذا اخترت قاعدة البيانات هذه؟" إليك إطار قرار:

نوع قاعدة البيانات أمثلة الأفضل لـ تجنب عندما
علائقية (SQL) PostgreSQL, MySQL معاملات ACID، روابط معقدة، بيانات منظمة الحاجة لتوسع أفقي ضخم
مستندات (NoSQL) MongoDB, DynamoDB مخططات مرنة، إنتاجية كتابة عالية علاقات معقدة، روابط
أعمدة واسعة Cassandra, HBase سلاسل زمنية، حجم كتابة عالٍ، أنماط استعلام معروفة استعلامات عشوائية، روابط
NewSQL CockroachDB, TiDB دلالات SQL + توسع أفقي حساسية للتكلفة، أعباء بسيطة
سلاسل زمنية InfluxDB, TimescaleDB مقاييس، بيانات IoT، بيانات مختومة بالوقت استعلامات للأغراض العامة
رسم بياني Neo4j, Amazon Neptune اجتياز العلاقات، الشبكات الاجتماعية CRUD بسيط، بيانات جدولية

نصيحة للمقابلة: دائمًا برر اختيارك بالمتطلبات المحددة. "اخترت PostgreSQL لأننا نحتاج ضمانات ACID لمعاملات الدفع والبيانات علائقية بشكل كبير" أقوى من "اخترت PostgreSQL لأنها شائعة."

التعمق في تقسيم البيانات

التجزئة المتسقة مع العقد الافتراضية

التجزئة القياسية (hash(key) % N) تتعطل عند إضافة أو إزالة عقد، مما يسبب إعادة توزيع ضخمة للبيانات. التجزئة المتسقة تُعيّن كلًا من المفاتيح والعقد على حلقة، لذا إضافة عقدة تنقل جزءًا فقط من المفاتيح:

Hash Ring مع العقد الافتراضية:
         Node A (v1)
           |
    Node C (v2) --- Node B (v1)
           |            |
    Node A (v2) --- Node C (v1)
           |
        Node B (v2)

كل عقدة فيزيائية تحصل على عدة عقد افتراضية (مثلاً 150-200)
على الحلقة. هذا يضمن توزيعًا متساويًا للبيانات.

التجزئة القائمة على Hash مقابل التجزئة القائمة على النطاق

الاستراتيجية قائمة على Hash قائمة على النطاق
التوزيع متساوٍ قد يكون غير متساوٍ
استعلامات النطاق غير مدعومة فعّالة
الأقسام الساخنة غير محتملة ممكنة (بيانات زمنية)
مثال User ID % N نطاقات تاريخ، أبجدية

التخفيف من الأقسام الساخنة

عندما يتلقى قسم واحد حركة مرور غير متناسبة (مثلاً منتج فيروسي):

  1. إضافة لاحقة عشوائية: وزّع المفاتيح الساخنة عبر الأقسام بإلحاق رقم عشوائي (0-9)، ثم اجمع النتائج عند القراءة
  2. قسم مخصص: انقل المفاتيح الساخنة المعروفة إلى قسم خاص بها بموارد أكثر
  3. طبقة تخزين مؤقت: خزّن البيانات الساخنة مؤقتًا أمام القسم

تطبيق المقابلة: خدمة طلبات التجارة الإلكترونية

"صمم خدمة طلبات للتجارة الإلكترونية تتعامل مع 100 ألف طلب/دقيقة مع سجل مراجعة كامل."

إجابة معمارية باستخدام أنماط اليوم:

  1. مصادر الأحداث للكيان المجمّع للطلب: كل تغيير في الحالة (إنشاء، دفع، شحن، إلغاء) هو حدث غير قابل للتعديل. هذا يمنحك سجل المراجعة الكامل مجانًا.
  2. CQRS مع إسقاطات منفصلة: واحدة لحالة الطلب الموجهة للعميل (محسّنة للبحث عن طلب واحد)، وواحدة للتحليلات (تجميع الإيرادات وعدد الطلبات).
  3. منسق Saga لسير عمل الطلب: ينسق الدفع والمخزون والشحن مع التعويض عند الفشل.
  4. اختيارات قاعدة البيانات: مخزن الأحداث على PostgreSQL (ضمانات ACID لترتيب الأحداث)، نماذج القراءة على DynamoDB (عمليات بحث سريعة بالمفتاح على نطاق واسع)، التحليلات على ClickHouse (عمودي لاستعلامات التجميع).
  5. التقسيم: تجزئة قائمة على hash بناءً على orderId عبر مخزن الأحداث، مع تجزئة متسقة لسهولة إعادة التوازن.
  6. حساب الإنتاجية: 100 ألف طلب/دقيقة = ~1,700/ثانية. مع متوسط 5 أحداث لكل طلب، هذا ~8,500 عملية كتابة أحداث/ثانية. مثيل PostgreSQL واحد يتعامل مع 10-50 ألف عملية كتابة/ثانية، لذا ابدأ بمثيل أساسي واحد مع نسخ للقراءة، وخطط للتجزئة عند الاقتراب من الحدود.

التالي: اختبر فهمك في اختبار الوحدة، ثم طبّق هذه الأنماط في المعمل العملي. :::

اختبار

اختبار الوحدة 2: أنماط بنية البيانات

خذ الاختبار
نشرة أسبوعية مجانية

ابقَ على مسار النيرد

بريد واحد أسبوعياً — دورات، مقالات معمّقة، أدوات، وتجارب ذكاء اصطناعي.

بدون إزعاج. إلغاء الاشتراك في أي وقت.