الانتقال من نظام علاقي إلى MongoDB دون إعادة التفكير في النمذجة هو أغلى خطأ في NoSQL. محرّك المستندات لا يُكافئ الصيغة الاعتيادية الثالثة: يُكافئ القراءات بنداء واحد. يشرح هذا الدليل كيف تختار بين embedded documents وreferenced documents، ثم كيف تُركّب الأنماط المعروفة (extended reference، subset، computed، bucket) لنمذجة بحدس صحيح على MongoDB 8.0 LTS.
المتطلّبات
- MongoDB 8.0 أو أحدث (Community أو Atlas M0 المجاني).
- mongosh 2.x مثبَّت محلّيًّا أو متاح عبر Atlas.
- قاعدة اختبار مخصَّصة — لا نُجرِّب النمذجة على الإنتاج.
- مستوى متوسّط: تعرف إدراج مستند وتشغيل
find. - الوقت المتوقّع: 90 دقيقة لاستكمال الخطوات التسع مع الأمثلة.
الخطوة 1 — العادة الواجب تجاوزها
في SQL، تُجزّئ طلبًا تلقائيًّا إلى ثلاث جداول: customers، orders، order_items. كلّ كيان له جدوله، وكلّ علاقة لها مفتاح أجنبي. حين تقرأ طلبًا كاملًا تكتب JOIN على ثلاث جداول. هذا التوجّه صحيح في العلاقي لأنّ البيانات تتغيّر باستقلال ولأنّ كلفة JOIN مع فهرسة جيّدة تبقى مضبوطة.
في MongoDB، نفس التفكيك يُنتج ثلاث مجموعات ويفرض ثلاثة استعلامات متتابعة أو $lookup لإعادة تركيب الطلب عند القراءة. يعمل، لكنّه يُناقض العقد المستندي: قراءة واحدة، جواب واحد. تنطلق النمذجة في MongoDB من السؤال المقابل: ما الشكل الذي يحتاج العميل أن يستردّه في نداء واحد؟ هذا الشكل يصير شكل المستند.
// SQL — ثلاث استعلامات أو JOIN
SELECT * FROM customers WHERE id = 42;
SELECT * FROM orders WHERE customer_id = 42;
SELECT * FROM order_items WHERE order_id IN (...);
الكود أعلاه يُنمذج ثلاثة كيانات منفصلة. في MongoDB، نسأل: من يقرأ هذه البيانات، وكم مرّة في الثانية؟ إن كان الجواب « تطبيق الجوال، عند كلّ فتح للحساب »، فقراءة واحدة findOne({_id: 42}) تُرجع العميل مع طلباته المُضَمَّنة هي المثالية. الكتابات المتعدّدة تأتي لاحقًا، لا قبل القراءة المهيمنة.
الخطوة 2 — Embedded documents (واحد-إلى-قليل)
نمط الـ embedded يتمثّل في تضمين الكيانات المرتبطة مباشرة داخل المستند الأب. هذا هو الحدس الافتراضي للنمذجة في MongoDB، خصوصًا حين تكون العلاقة محدودة الحجم وتُقرأ مع الأب معًا.
// مستند عميل مع عناوين مُضَمَّنة
{
_id: ObjectId("..."),
email: "salim@example.io",
nom: "Salim Al-Otaibi",
adresses: [
{ type: "facturation", rue: "King Fahd Rd 240", ville: "Riyadh" },
{ type: "livraison", rue: "Al Olaya District 12", ville: "Riyadh" }
],
cree_le: ISODate("2026-05-01T10:00:00Z")
}
المستند أعلاه يُقرأ في استعلام واحد: db.clients.findOne({email: "salim@example.io"}) يُرجع كلّ شيء. لا JOIN ولا تجميع. الميزة هي القُرب الفيزيائي: العناوين على نفس صفحة القرص مع العميل، ويقوم الخادم بتجاوز فهرس واحد. العيب: إن أردت الإجابة عن « كلّ العملاء في الرياض »، عليك فهرسة adresses.ville وعمل multikey index scan.
القاعدة التجريبية في جملة واحدة: إن كان الكيان الفرعي لا يُقرأ ولا يُستعلَم عنه أبدًا دون الأب، فضَمِّنه. هذا نمطيًّا حال عناوين العميل، أسطر طلب مُجَمَّد، تبويبات إعدادات ملف شخصي. الحجم الموصى به: أقلّ من 100 مستند فرعي لكلّ أب، نمطيًّا بضع عشرات.
الخطوة 3 — Referenced documents (واحد-إلى-كثير)
نمط الـ referenced يحفظ _id المستند المرتبط في الأب، كالمفتاح الأجنبي SQL. يُستعمل حين يكون للكيان الفرعي حياة مستقلّة — يُقرأ، يُفَهرَس، ويُعدَّل خارج الأب.
// مجموعة الطلبات — كلّ طلب يُحيل على عميله
{
_id: ObjectId("..."),
client_id: ObjectId("64a..."),
montant_usd: 80,
statut: "payee",
cree_le: ISODate("2026-05-12T14:30:00Z")
}
لإعادة تركيب الثنائي طلب + عميل، خياران. الأوّل: استعلامان findOne من جانب التطبيق — بسيط، متوقَّع، مناسب حين يكون العميل أصلًا في الـ cache. الثاني: $lookup داخل aggregation pipeline — عملي لإنتاج تقرير، ثقيل على مجموعات بملايين المستندات إن لم تكن مُفَهرسة. المرجع: التوثيق الرسمي Data Model Design.
القاعدة التجريبية معاكسة للخطوة 2: إن كان للكيان الفرعي هويّته الخاصّة وحياته وطلباته، فأَحِل عليه. هذا نمطيًّا حال منتجات كتالوج (تُقرأ منفردة، تُحدَّث منفردة)، مستخدمي منظّمة، معاملات مالية لحساب. لا حدّ أعلى للحجم، لكن تجنّب مجموعات وصل بأسلوب SQL — فهي تكشف نمذجة علاقية سيّئة التحويل.
الخطوة 4 — نمط Extended Reference
تضمين الحدّ الأدنى لتجنّب قراءة إضافية، دون تكرار الكائن كلّه. هذا هو التسوية الأكثر استعمالًا في الإنتاج: نحتفظ بالمرجع للاتّساق على المدى البعيد، ونُكرّر بضع حقول تُقرأ كثيرًا للقضاء على الذهاب والإياب.
// طلب مع extended reference إلى العميل
{
_id: ObjectId("..."),
client_id: ObjectId("64a..."),
client_snapshot: {
nom: "Salim Al-Otaibi",
email: "salim@example.io"
},
montant_usd: 80,
statut: "payee"
}
قراءة في استعلام واحد، لا $lookup، شاشة « تفاصيل الطلب » تظهر في قراءة واحدة. الكلفة: إذا غيّر العميل بريده، يجب نشر التعديل في كلّ طلباته. الحلّ: لا تُكرّر سوى الحقول التي لا تتغيّر تقريبًا (الاسم، البريد الرئيسي)، وقم بالإبطال عبر مهمّة مُجَدوَلة عند التغيير، أو اقبل الانحراف المؤقّت في الطلبات التاريخية. المرجع: MongoDB Building with Patterns — Extended Reference.
الخطوة 5 — نمط Subset
حين يصير مستند ثقيلًا جدًّا لأنّه يحوي مصفوفة طويلة (تعليقات مقال، تقييمات منتج، نقاط GPS لرحلة)، نقطع: العناصر الـ N الأولى تبقى مُضَمَّنة، والباقي ينتقل إلى مجموعة مساعِدة.
// مقال + آخر 10 تعليقات مُضَمَّنة
{
_id: ObjectId("..."),
titre: "نمذجة MongoDB عمليًّا",
commentaires_recents: [
{ auteur: "مريم", texte: "مفيد جدًّا، شكرًا", le: ISODate("2026-05-12") },
// ... حتى 10 تعليقات الأحدث
],
commentaires_total: 487
}
// مجموعة مساعِدة: التعليقات الكاملة
{ _id: ..., article_id: ObjectId("..."), auteur: "...", texte: "...", le: ... }
شاشة المقال تفتح في قراءة واحدة، آخر 10 تعليقات تظهر فورًا. زرّ « اعرض الـ 477 السابقة » يُشغّل نداء ثانيًا على مجموعة التعليقات الكاملة. هذا النمط يحمي المستند الأب من الانتفاخ (تذكير: يُحدّد MongoDB كلّ مستند بـ 16 ميغا) ويُبقي نافذة القراءة الأولى سريعة. المرجع: MongoDB Building with Patterns — Subset.
الخطوة 6 — نمط Computed
حساب قيمة مُجَمَّعة عند الكتابة لتجنّب إعادة حسابها عند كلّ قراءة. الانعكاس SQL سيكون الاحتفاظ بـ materialized view؛ في MongoDB نُخزّن الحقل المحسوب مباشرة في المستند.
// عدّاد محسوب عند كلّ إدراج
db.commandes.insertOne({ client_id: id, montant_usd: 80, ... });
db.clients.updateOne(
{ _id: id },
{
$inc: { commandes_total: 1, ca_usd: 80 },
$set: { derniere_commande_le: new Date() }
}
);
شاشة « لوحة العميل » التي تعرض عدد الطلبات ورقم الأعمال تقرأ هذه العدّادات في استعلام واحد بدل تجميع مجموعة الطلبات كلّها مع كلّ زيارة. الكلفة: كلّ كتابة تصير ثنائيًّا insert + update، ويجب إدارة احتمالات عدم التزامن (معاملة متعدّدة المستندات أو مهمّة توفيق دوري). المرجع: MongoDB Building with Patterns — Computed.
الخطوة 7 — نمط Bucket للسلاسل الزمنية
مجموعات time series الأصلية موجودة منذ MongoDB 5.0، والإصدار 8.0 يُقدّم block processing الذي يُسرِّع تجميعات $group بأكثر من 200% على هذه المجموعات. للحالات غير الأصلية (مستشعرات IoT، سجلّات تجارية، نقاط GPS)، يبقى نمط bucket مناسبًا: تجميع القياسات في نافذة زمنية داخل مستند واحد.
// مستند bucket: ساعة من قياسات مستشعر
{
_id: ObjectId("..."),
capteur_id: "temp-cabinet-3",
debut: ISODate("2026-05-12T14:00:00Z"),
fin: ISODate("2026-05-12T14:59:59Z"),
mesures: [
{ t: 0, v: 23.4 },
{ t: 60, v: 23.5 },
// ... 60 قياسًا في الساعة
],
count: 60,
min: 23.1,
max: 24.0
}
الميزة ضخمة: 60 قياسًا في مستند واحد بدل 60 مستندًا. تخزين مُكثَّف، قراءة في استعلام واحد لساعة كاملة، تجميع على اليوم بقراءة 24 مستندًا بدل 1440. الحقول min، max، count هي computed pattern مُطَبَّق على الـ bucket. المرجع الرسمي: Time Series Collections — Manual.
الخطوة 8 — التحقّق من المخطّط بـ $jsonSchema
NoSQL لا تعني no schema. يُطبّق MongoDB قواعد تحقّق تصريحية تنفّذ عند الإدراج أو التحديث. التحقّق اختياري لكنّه يُوصى به بشدّة في الإنتاج — يُمسك بالأخطاء التطبيقية التي كانت ستحقن مستندات سيّئة التشكيل.
db.createCollection("commandes", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["client_id", "montant_usd", "statut", "cree_le"],
properties: {
client_id: { bsonType: "objectId" },
montant_usd: { bsonType: "int", minimum: 0 },
statut: { enum: ["en_attente", "payee", "annulee", "remboursee"] },
cree_le: { bsonType: "date" }
}
}
},
validationLevel: "strict",
validationAction: "error"
});
كلّ إدراج لا يحترم العقد يُرفَض بالخطأ 121 (DocumentValidationFailure). الخيار validationAction: "warn" يُتيح انتقالًا سلسًا: الكتابات غير المطابقة مسموحة لكن تُسجَّل، ريثما تنظِّف المصادر التي كانت تُنتج مستندات غير صحيحة. المرجع الرسمي: Schema Validation — Manual.
الخطوة 9 — اختيار embedded أو referenced بثلاثة أسئلة
حين يستقرّ الشكّ بين النمطين الأساسيين، اطرح هذه الأسئلة الثلاثة بالترتيب.
السؤال 1 — الحجم: كم عدد المستندات الفرعية لكلّ أب في أسوأ حال؟ إن كان الجواب « بضع عشرات، لا يتجاوز 100 أبدًا »، فالـ embedded قابل للتطبيق. ما فوق ذلك، يخاطر المستند بالنموّ غير المحدود، يقترب من 16 ميغا، ويصير نمط subset أو referenced ضروريًّا.
السؤال 2 — الحياة المستقلّة: هل يحتاج الكيان الفرعي إلى أن يُقرأ، يُحدَّث، يُفَهرَس خارج الأب؟ إن نعم (منتج، مستخدم، معاملة)، فاستعمل referenced. إن لا (عنوان، سطر سلّة مُجَمَّد)، فاستعمل embedded.
السؤال 3 — القراءة المهيمنة: كيف يبدو الاستعلام الأكثر تكرارًا في التطبيق؟ إن أراد الأب وكلّ أبنائه معًا (شاشة حساب، شاشة طلب)، فاستعمل embedded. إن أراد تصفية الأبناء وحدهم دون سياق الأب (تقرير طلبات الشهر)، فاستعمل referenced.
الأنماط المتقدّمة (extended reference، subset، computed) تأتي في مرحلة ثانية للضبط الدقيق: تكرار الحدّ الأدنى المقروء بكثرة، قطع المصفوفات التي تنتفخ، حساب ما كان يُجَمَّع عند الطلب. هذه الشبكة تحلّ 90% من الحالات العملية.
الأخطاء الشائعة
| الخطأ | السبب | الحلّ |
|---|---|---|
| مستند يتجاوز 16 ميغا | مصفوفة مُضَمَّنة تنمو دون حدّ (تعليقات، سجلّات) | الانتقال إلى subset pattern أو مجموعة مساعِدة |
| قراءات متعدّدة لكلّ شاشة | نموذج SQL مُنقَل دون تحوير | تدقيق الاستعلامات الأكثر تكرارًا، embedded للزوجين المقروءين معًا |
| عدم تزامن extended reference | snapshot لم يُحدَّث عند تغيّر المصدر | معاملة متعدّدة المستندات، أو مهمّة توفيق أسبوعية |
| حقول null في كلّ مكان | مخطّط ضبابي، لا $jsonSchema يُصفّي | أضف validation strict، شَغِّل سكربت تنظيف $exists: false |
| فهرس multikey ينفجر | مصفوفة مُضَمَّنة طويلة جدًّا مُفَهرسة على عدّة حقول | فهرس جزئي أو الانتقال إلى referenced مع فهرس مخصَّص |
الأسئلة الشائعة
س: هل يجب دائمًا تفضيل embedded للأداء؟
ج: لا. embedded يفوز في القراءات المشتركة لكنّه يخسر حين يجب الاستعلام عن الكيان الفرعي وحده (تقرير شامل) أو حين لا يكون نموّه محدودًا. embedded سيّئ أكثر كلفة من referenced جيّد مُفَهرَس.
س: ما الحجم الأقصى لمستند MongoDB؟
ج: 16 ميغا. حدّ صارم وغير قابل للتغيير، ويتعلّق بالمستند بعد التسلسل إلى BSON. لتخزين أكثر، استعمل GridFS (ملفّات ثنائية مقطّعة إلى chunks).
س: كيف نُهَجِّر نموذجًا SQL قائمًا إلى MongoDB؟
ج: لا تنقل جدولًا بجدول. اسرد 5 إلى 10 من الاستعلامات الأكثر تكرارًا في التطبيق، ارسم لكلّ منها شكل المستند الذي يُجيب في قراءة واحدة، ثم ادمج المخطّطات الناتجة. النموذج النهائي نادرًا ما يكون مطابقًا لـ SQL الأصلي.
س: ما النمط الأكثر استعمالًا عمليًّا؟
ج: extended reference، لأنّه يقبل تسوية العالم الواقعي: قراءة سريعة بفضل snapshot مُضَمَّن، اتساق على المدى البعيد بفضل المرجع. غالبية مجموعات الإنتاج تستعمله دون أن تُسمّيه.
س: متى نستعمل مجموعة وصل منفصلة؟
ج: تقريبًا أبدًا. إن كتبت مجموعة لا تحوي سوى _id أو اثنين وحقل أو حقلين، تكون قد أعدت بناء جدول وصل SQL وتخلّيت عن منفعة المستندات. فضّل embedded من جهة أو أخرى حسب الحجم.
للتعمّق
- MongoDB: NoSQL في الممارسة — نقطة دخول عامّة لمنظومة MongoDB.
- Aggregation Pipeline متقدّم — استثمار
$lookupو$groupو$facetعلى النموذج الذي وضعناه. - فهرسة MongoDB والأداء — مرافقة كلّ نمط بفهارس مناسبة.
- Data Modeling Introduction — التوثيق الرسمي المرجعي.
- Building with Patterns: A Summary — ملخّص MongoDB Inc. للأنماط الـ 12.