أنماط بنية البيانات للتوسع
مصادر الأحداث و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) يفصل نموذج الكتابة (الأوامر التي تُنتج أحداثًا) عن نموذج القراءة (الإسقاطات المحسّنة للاستعلامات).
CQRS — مساران لا يلمسان الجدول نفسه أبدًا
الكتابات تمر عبر مخزن الأحداث. القراءات لا تلمسه إطلاقًا. الإسقاط هو الشيء الوحيد الذي يعبر بينهما.
لماذا نفصل النماذج؟
- نموذج الكتابة: يفرض قواعد العمل، يتحقق من الثوابت، محسّن للاتساق
- نموذج القراءة: غير مُطبّع، محسّن لأداء الاستعلامات، يمكن أن يحتوي على إسقاطات متعددة لحالات استخدام مختلفة
الاتساق النهائي
يتم تحديث نموذج القراءة بشكل غير متزامن بعد كتابة الأحداث. التأخير (عادةً 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)
- العملية التجارية كاملة مقروءة في مكان واحد
- حالة الـ Saga صف يمكنك الاستعلام عنه حين يسأل الدعم: أين الطلب 12345؟
- ترتيب التعويضات صريح، فيصبح التراجع حتميًا
- المنسق يصبح نقطة فشل واحدة ويحتاج ديمومته الخاصة
- يتراكم فيه منطق العمل من كل مجال ينسقه — مونوليث موزع ببطء
- كل خطوة جديدة تعني تعديل خدمة مشتركة، فتصطف الفرق في طابور واحد
التصميم الراقص (Choreography)
- لا مكوّن مشترك يتنازع عليه — الفرق تضيف مشتركين باستقلال
- لا منسق يفشل؛ كل خدمة دائمة بذاتها
- إضافة مستهلك جديد لا تتطلب تعديل الناشرين الحاليين
- لا مكان واحد يُظهر التدفق — تعيد تركيبه من سجلات موزعة على الخدمات
- سلاسل الأحداث الدائرية تنشأ بالخطأ بسهولة ويصعب اكتشافها
- التعويض مسؤولية جزئية للجميع، ما يعني عمليًا أنه غير مُختبَر بما يكفي
مسار الفشل هو ما يفصل الـ Saga عن مخطط المسار السعيد. تتبّعه صراحة:
Saga منسّقة — تقديم طلب، مع التعويض
الخطوات تسير للأمام حتى تفشل واحدة. عندها تسير التعويضات للخلف على الخطوات التي أُنجزت بالفعل.
التعويضات ليست تراجعًا (rollback). فـ refund() معاملة جديدة تُبقي الخصم والاسترداد معًا في كشف حساب العميل — وهو غالبًا السلوك الصحيح، ودائمًا ما يجب أن تقوله بصوت عالٍ في المقابلة.
نمط Outbox
تُحدّث قاعدة بياناتك، ثم تنشر حدثًا إلى وسيط الرسائل. نظامان، ولا معاملة مشتركة بينهما — فقد تموت العملية في الفجوة بينهما. قاعدة البيانات تقول إن الطلب مدفوع والوسيط لم يسمع بذلك قط، ولا شيء في النظام سيلاحظ.
نمط Outbox يزيل الفجوة بجعل الحدث جزءًا من نفس معاملة تغيير الحالة:
الكتابة المزدوجة مقابل Outbox المعاملاتي
sql1BEGIN;2 UPDATE orders SET status = 'paid' WHERE id = 42;3COMMIT;45-- العملية تموت هنا فلا يوجد الحدث أصلًا.6-- الطلب مدفوع. والشحن لم يُبلَّغ أبدًا.7-- لا إعادة محاولة تنفع: لا شيء سجّل النية.8broker.publish('PaymentProcessed', payload);
1BEGIN;2 UPDATE orders SET status = 'paid' WHERE id = 42;3 INSERT INTO outbox (event_type, payload, published)4 VALUES ('PaymentProcessed', :payload, false);5COMMIT;67-- صارت النية دائمة مع تغيير الحالة.8-- مُرحِّل منفصل يصرّفها، وله أن يعيد المحاولة بحرية:9-- SELECT * FROM outbox WHERE published = false;10-- broker.publish(...);11-- UPDATE outbox SET published = true;
هذا يشتري لك تسليمًا مرة واحدة على الأقل، لا مرة واحدة بالضبط. فقد ينشر المُرحِّل ثم ينهار قبل تعليم الصف، فيخرج الحدث نفسه مرتين. لذا يجب أن يكون المستهلكون عديمي التأثير التراكمي (idempotent) — عادةً بالاعتماد على معرّف الحدث. قول هذا دون أن يُسأل عنه إشارة قوية في المقابلة، لأنه يُظهر أنك تعرف أي ضمان اشتريته فعلًا.
إطار اختيار قاعدة البيانات
المقابلات غالبًا تسأل: "لماذا اخترت قاعدة البيانات هذه؟" إليك إطار قرار:
| نوع قاعدة البيانات | أمثلة | الأفضل لـ | تجنب عندما |
|---|---|---|---|
| علائقية (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 عملية إلحاق أحداث/ثانية. الإلحاق فقط هو ألطف نمط كتابة تعرفه قاعدة بيانات علائقية، لذا فمثيل أساسي واحد جيد التجهيز نقطة بداية قابلة للدفاع — لكن قل إن السقف يعتمد على العتاد وإعدادات ديمومة
fsyncوعدد الفهارس، وإنك ستحدده باختبار حِمل على مسار الكتابة لديك بدل اقتباس رقم جاهز. ابدأ بمثيل أساسي مع نسخ للقراءة، وخطط للتجزئة قبل بلوغ الحد المقيس لا بعده.
التالي: اختبر فهمك في اختبار الوحدة، ثم طبّق هذه الأنماط في المعمل العملي. :::
سجّل الدخول للتقييم