أنماط بنية البيانات للتوسع
مصادر الأحداث وCQRS والمعاملات الموزعة
في مقابلات تصميم الأنظمة، المرشحون الذين يستطيعون شرح أنماط بنية البيانات بما يتجاوز 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 | نطاقات تاريخ، أبجدية |
التخفيف من الأقسام الساخنة
عندما يتلقى قسم واحد حركة مرور غير متناسبة (مثلاً منتج فيروسي):
- إضافة لاحقة عشوائية: وزّع المفاتيح الساخنة عبر الأقسام بإلحاق رقم عشوائي (0-9)، ثم اجمع النتائج عند القراءة
- قسم مخصص: انقل المفاتيح الساخنة المعروفة إلى قسم خاص بها بموارد أكثر
- طبقة تخزين مؤقت: خزّن البيانات الساخنة مؤقتًا أمام القسم
تطبيق المقابلة: خدمة طلبات التجارة الإلكترونية
"صمم خدمة طلبات للتجارة الإلكترونية تتعامل مع 100 ألف طلب/دقيقة مع سجل مراجعة كامل."
إجابة معمارية باستخدام أنماط اليوم:
- مصادر الأحداث للكيان المجمّع للطلب: كل تغيير في الحالة (إنشاء، دفع، شحن، إلغاء) هو حدث غير قابل للتعديل. هذا يمنحك سجل المراجعة الكامل مجانًا.
- CQRS مع إسقاطات منفصلة: واحدة لحالة الطلب الموجهة للعميل (محسّنة للبحث عن طلب واحد)، وواحدة للتحليلات (تجميع الإيرادات وعدد الطلبات).
- منسق Saga لسير عمل الطلب: ينسق الدفع والمخزون والشحن مع التعويض عند الفشل.
- اختيارات قاعدة البيانات: مخزن الأحداث على PostgreSQL (ضمانات ACID لترتيب الأحداث)، نماذج القراءة على DynamoDB (عمليات بحث سريعة بالمفتاح على نطاق واسع)، التحليلات على ClickHouse (عمودي لاستعلامات التجميع).
- التقسيم: تجزئة قائمة على hash بناءً على
orderIdعبر مخزن الأحداث، مع تجزئة متسقة لسهولة إعادة التوازن. - حساب الإنتاجية: 100 ألف طلب/دقيقة = ~1,700/ثانية. مع متوسط 5 أحداث لكل طلب، هذا ~8,500 عملية كتابة أحداث/ثانية. مثيل PostgreSQL واحد يتعامل مع 10-50 ألف عملية كتابة/ثانية، لذا ابدأ بمثيل أساسي واحد مع نسخ للقراءة، وخطط للتجزئة عند الاقتراب من الحدود.
التالي: اختبر فهمك في اختبار الوحدة، ثم طبّق هذه الأنماط في المعمل العملي. :::