MongoDB: $lookup والارتباطات مع مجموعات متعددة

آخر تحديث: 2026-08-26

تعد دالة $lookup بمثابة دالة JOIN في MongoDB — وإتقانها يتيح لك استخدام MongoDB للتعامل مع معظم سيناريوهات الاستعلامات التي تشمل جداول متعددة.

دور $lookup في منظومة MongoDB: $lookup هي إمكانية استعلام ربط توفرها MongoDB — وهي تُنفِّذ وظيفة مشابهة لـ JOIN في لغة SQL ضمن مسار التجميع. ومع ذلك، فإن فلسفة تصميم MongoDB هي «التضمين أولاً» — فالسيناريوهات التي يمكن حلها باستخدام المستندات المضمنة لا تتطلب استخدام $lookup. وتعد $lookup مناسبة لما يلي: 1. مجموعات البيانات الكبيرة التي لا يمكن تضمينها (على سبيل المثال، الطلبات → المنتجات، حيث يُشار إلى منتج واحد من خلال عشرات الآلاف من الطلبات)؛ 2. البيانات التي تحتاج إلى التحديث بشكل مستقل (على سبيل المثال، التغييرات في معلومات المستخدم؛ إذا تم تضمينها، فسيتعين تحديث جميع المستندات المرجعية)؛ 3. العلاقات متعددة إلى متعددة (على سبيل المثال، العلامات → المقالات). يعد فهم متى يجب استخدام $lookup ومتى يجب استخدام التضمين قرارًا أساسيًا في تصميم بنية MongoDB.

الفرق الجوهري بين JOIN و$lookup: تعتبر عملية JOIN في لغة SQL عملية مجموعة — فهي تُجري الضرب الديكارتي لجدولين ثم تقوم بتصفية النتائج بناءً على شروط معينة. أما $lookup فهي عملية متداخلة — فلكل مستند في المجموعة اليسرى، تبحث في المجموعة اليمنى عن مستند مطابق، ويتم تضمين النتيجة كحقل مصفوفة في المستند الأيسر. ينتج عن هذا الاختلاف ما يلي: 1. تكون نتائج $lookup متداخلة بطبيعتها (يتم تخزين البيانات من الجدول الأيمن في مصفوفة)، بينما تكون نتائج JOIN صفوفًا مسطحة؛ 2. يُستخدم $lookup افتراضيًّا كـ LEFT JOIN (حيث يكون الحقل as مصفوفة فارغة في حالة عدم العثور على تطابق)، ويتطلب استخدام $unwind لتحقيق تأثير INNER JOIN؛ 3. لا يدعم $lookup كل من RIGHT JOIN وFULL JOIN.

إطار عمل اتخاذ القرار بين التضمين ووظيفة $LOOKUP: متى نستخدم التضمين، ومتى نستخدم وظيفة $LOOKUP؟ معايير اتخاذ القرار — 1. حجم البيانات: إذا كان عدد السجلات الفرعية أقل من 100 وكان النمو قابلاً للإدارة → التضمين؛ إذا كان نمو السجلات الفرعية غير متوقع → $LOOKUP؛ 2. تكرار التحديث: تظل البيانات الفرعية دون تغيير تقريبًا (مثل العناوين) → التضمين؛ يتم تحديث البيانات الفرعية بشكل متكرر وبشكل مستقل (مثل أسعار المنتجات) → $LOOKUP؛ 3. أنماط الوصول: تُقرأ دائمًا مع البيانات الأصلية → التضمين؛ تتطلب استعلامات مستقلة/تقسيم الصفحات → $LOOKUP؛ 4. متطلبات الاتساق: اتساق قوي (تضمن الجداول المضمنة تحديثات متكاملة) → التضمين؛ يُقبل الاتساق النهائي (قد يشير $LOOKUP إلى بيانات قديمة) → $LOOKUP. الخيارات النموذجية في أنظمة التجارة الإلكترونية: الطلبات → المستخدمون ($lookup؛ قد تتغير معلومات المستخدم)، الطلبات → المنتجات ($lookup + التكرار؛ قد تتغير أسعار المنتجات، لكن الطلبات تحتفظ بالسعر السائد وقت الشراء)، المستخدمون → العناوين (مضمنة؛ نادرًا ما تتغير العناوين ويتم قراءتها دائمًا معًا).

الاستراتيجية المختلطة: مزيج من التضمين و$lookup: غالبًا ما تتطلب أنظمة الإنتاج استراتيجية مختلطة — 1. التضمين في المسار الحرج (إعطاء الأولوية لأداء القراءة): تضمين لقطات المنتج (السعر/الاسم وقت تقديم الطلب) في الطلبات لضمان عدم تأثر بيانات الطلبات التاريخية بتغييرات المنتج؛ 2. البيانات في الوقت الفعلي عبر $lookup (الأولوية للاتساق): تشير الطلبات إلى userId (بدلاً من تضمين معلومات المستخدم)، ويسترد $lookup أحدث بيانات المستخدم في الوقت الفعلي (مثل أحدث صورة للملف الشخصي/مستوى العضوية)؛ 3. الحقول المكررة + $lookup للحماية المزدوجة: تضمين productTitle في الطلبات (للعرض السريع)، مع استخدام $lookup للإشارة إلى مجموعة products لاسترداد معلومات المنتج الكاملة (المطلوبة لصفحات التفاصيل). المبدأ الأساسي للاستراتيجية المختلطة هو: «استخدم التضمين للعرض، و$lookup للتفاصيل، والإشارات للبيانات التي تتغير بشكل متكرر.»

تقييم تكلفة إلغاء التطبيع: يؤدي التضمين (إلغاء التطبيع) إلى تحسين أداء القراءة، لكنه يؤدي إلى تضخيم الكتابة — 1. تضخيم الكتابة: إذا تم تضمين معلومات المستخدم في 1,000 طلب، فإن تغيير اسم المستخدم يتطلب تحديث 1,000 مستند (مقابل مستند مستخدم واحد فقط في النموذج المرجعي)؛ 2. فترة عدم اتساق البيانات: يحدث تأخير بين البيانات الزائدة المدمجة والبيانات المصدر (بعد أن يغير المستخدم اسمه، يظل اسم المستخدم في الطلبات السابقة هو الاسم القديم)؛ 3. تضخم التخزين: يتم تضمين نفس معلومات المستخدم في N طلبًا، مما يؤدي إلى تخزين N نسخة من البيانات الزائدة. صيغة التقييم: تكرار الكتابة × عدد النسخ الزائدة = معامل تضخيم الكتابة. انخفاض تكرار كتابة معلومات المستخدم (يتم التحديث مرة واحدة شهريًّا) × ارتفاع عدد النسخ (1,000 طلب) = 1,000 مستند يتم تحديثها شهريًّا، وهو أمر مقبول. يتم تحديث أسعار المنتجات يوميًّا × 1,000 طلب = 1,000 مستند يتم تحديثها يوميًّا، وهو أمر غير مقبول → استخدم المراجع.

حل إدارة الإصدارات للحقول المتكررة: يتمثل الخطر الأكبر المرتبط بالحقول المتكررة في عدم اتساق البيانات — أي عندما تتغير البيانات المصدر دون أن تتم مزامنة النسخ المتكررة. ويحل نهج إدارة الإصدارات هذه المشكلة على النحو التالي: 1. تتضمن الحقول المتكررة رقم إصدار: تضمين {productName: 'iPhone', productVersion: 3} في الطلب؛ وزيادة قيمة version عند تحديث المنتج؛ 2. مهمة المزامنة في الخلفية: قم بمسح المستندات بشكل دوري بحثًا عن الحالات التي لا يتطابق فيها version في الحقل المكرر مع version في البيانات المصدر، وقم بإجراء تحديثات مجمعة؛ 3. التحديث عند الطلب أثناء الاسترجاع: عندما تُرجع واجهة برمجة التطبيقات (API) البيانات، فإنها تتحقق من وجود تباينات في الإصدارات وتُطلق تحديثًا غير متزامن (تُرجع البيانات القديمة هذه المرة، والبيانات الجديدة في المرة التالية). مزايا وعيوب نهج الإصدارات: يوفر هذا النهج اتساقًا أعلى ولكنه يزيد من التعقيد — لا تستخدمه إلا عندما قد تؤدي حالات عدم الاتساق في الحقول الزائدة إلى مشكلات تجارية خطيرة (مثل الخسائر المالية الناتجة عن أسعار المنتجات غير الصحيحة). في السيناريوهات العادية (مثل عرض اسم مستخدم قديم)، يمكن التسامح مع حالات عدم الاتساق المؤقتة.

1. ما ستتعلمه


100%
graph LR
    A[orders Gathering] -->|$lookup<br/>userId| B[users Gathering]
    A -->|$lookup<br/>items.productId| C[products Gathering]
    A -->|$lookup<br/>customer.addressId| D[addresses Gathering]

    B --> E[Merged Order Documents<br/>with customer Array]
    C --> E
    D --> E

    style E fill:#d4edda

2. القواعد الأساسية لدالة $lookup

كيفية إجراء $lookup للمطابقات التامة: شكل المطابقة التامة هو أبسط أنواع $lookup — فلكل مستند في المجموعة الحالية، يسترد قيمة localField، ويبحث عن جميع المستندات المطابقة في foreignField من مجموعة from، ويضع النتائج في المصفوفة as. هذه العملية تعادل عملية LEFT JOIN في SQL: إذا لم تكن هناك مطابقات، فإن as يكون مصفوفة فارغة (وليس null)؛ وإذا كانت هناك مطابقات متعددة، فإن as يحتوي على جميع المستندات المطابقة. قيود نمط المطابقة بالمساواة: لا يمكنه إجراء سوى مقارنات بسيطة للمساواة بين الحقول ولا يمكنه إضافة شروط إضافية (مثل «ربط المستخدمين النشطين فقط»).

فهم دلالات LEFT JOIN: السلوك الافتراضي لـ $lookup هو LEFT JOIN — حتى في حالة عدم وجود مستندات مطابقة في مجموعة from، يتم الاحتفاظ بالمستند الحالي، مع تعيين الحقل as إلى مصفوفة فارغة []. وهذا هو التصميم الصحيح — فلا ينبغي أن تفقد الاستعلامات المدمجة أي بيانات من الجدول الأساسي. إذا كنت بحاجة إلى INNER JOIN (لاحتفاظ فقط بالوثائق التي تحتوي على تطابقات)، فما عليك سوى إضافة $unwind بعد $lookup لتقسيم المجموعة (سيتم تجاهل الوثائق التي تحتوي على مصفوفات فارغة). إذا كنت بحاجة إلى الاحتفاظ بالوثائق التي لا تحتوي على تطابقات ولكن مع عرضها على أنها null، فاستخدم $unwind: { path: '$field', preserveNullAndEmptyArrays: true }.

تحليل بنية نتيجة $lookup: يُعد حقل as في $lookup دائمًا مصفوفة — حتى لو تمت مطابقة مستند واحد فقط، فإن النتيجة تكون مصفوفة مكونة من عنصر واحد [{...}]. ويرجع ذلك إلى أن $lookup مصممة على أساس افتراض أن علاقة الارتباط «واحد إلى العديد» هي الأكثر شيوعًا. لاستخدام البيانات ذات الصلة في المراحل اللاحقة، تحتاج عادةً إلى استخدام $unwind لتفكيكها إلى كائن، أو استخدام $arrayElemAt: ['$field', 0] لاسترداد العنصر الأول. إن عدم فهم أن «النتيجة تكون دائمًا مصفوفة» هو المأزق الأكثر شيوعًا للمبتدئين الذين يستخدمون $lookup.

شرح المفهوم: $lookup هو عامل ربط في مسار التجميع في MongoDB، وهو مكافئ وظيفيًّا لعامل LEFT JOIN في لغة SQL. ويقوم بالبحث عن المستندات المطابقة في مجموعة أخرى، ثم يدمج النتائج كمصفوفة داخل المستند الحالي. هناك شكلان للصيغة: (1) مطابقة التكافؤ (localField/foreignField)؛ (2) شكل خط الأنابيب (MongoDB 5.0+، يدعم الشروط المعقدة وتمرير المتغيرات).

كيفية العمل: في هذا النوع من المطابقة القائمة على القيم، يتم استخدام قيمة localField لكل مستند في المجموعة الحالية للبحث عن تطابق في foreignField داخل مجموعة from، ويتم تخزين النتيجة في حقل المصفوفة as. يُعرّف نهج خط الأنابيب المتغيرات عبر let ويشير إليها في خط الأنابيب الفرعي pipeline باستخدام $$variable، مما يتيح شروط ربط أكثر مرونة (مثل ربط المستخدمين النشطين فقط أو إرجاع حقول معينة فقط).

تكلفة أداء عملية البحث $lookup عبر مسار التسلسل: يُعد أسلوب مسار التسلسل أبطأ من أسلوب مطابقة التساوي — لأن عمليات مطابقة التساوي يمكنها الاستفادة من الفهارس الموجودة في foreignField لإجراء عمليات بحث فعالة، في حين أن أسلوب مسار التسلسل ينفذ مسارًا فرعيًّا لكل مستند في المجموعة from. الفرق في الأداء: $lookup لمطابقة التساوي ≈ O(N) (حيث N هو عدد المستندات في المجموعة الحالية)، و$lookup للخطوات المتسلسلة ≈ O(N*M) (حيث M هو عدد المستندات في مجموعة from). استراتيجيات التخفيف: 1. التصفية في أقرب وقت ممكن باستخدام $expr في $match للخط الفرعي؛ 2. إنشاء فهارس مناسبة لمجموعة from؛ 3. التحكم في تعقيد الخط الفرعي — تنفيذ $match و$project فقط، وتجنب العمليات الثقيلة مثل $group.

نموذج تصميم لعمليات الربط أحادية المستوى: يُعد استخدام $lookup + $unwind في مستوى واحد النمط الأكثر شيوعًا لعمليات الربط — 1. عملية الربط باستخدام $lookup (تُرجع مصفوفة)؛ 2. تقوم $unwind بفك تجميع المصفوفة إلى كائن؛ 3. تقوم $project باختيار الحقول المطلوبة. يغطي هذا النموذج 80% من سيناريوهات استعلامات الربط. صيغة متقدمة: بعد $unwind، أضف $group لاستعادة بنية «واحد إلى العديد»، واستخدم $push لجمع البيانات ذات الصلة.

قائمة مراجعة لتحسين أداء الاستعلامات المدمجة: يُعد تحسين أداء $lookup أمرًا أساسيًّا لضبط مسارات التجميع — 1. يجب فهرسة foreignField (فإن $lookup غير المفهرس يعادل مسحًا كاملاً للجدول ضمن حلقة متداخلة)؛ 2. استخدم $match قبل $lookup لتقليل عدد المستندات في المجموعة الحالية (تقليل عمليات الربط بمقدار N سجل → تقليل عدد الاستعلامات بمقدار N)؛ 3. في تنسيق خط الأنابيب، اجعل $project تُرجع الحقول الضرورية فقط (لتقليل استهلاك الذاكرة وحمل الشبكة)؛ 4. تجنب فرز كميات كبيرة من البيانات المرتبطة باستخدام $sort بعد $lookup (قم بالفرز داخل خط أنابيب فرعي وقم بتطبيق $limit قبل الربط)؛ 5. يتدهور أداء عمليات $lookup متعددة المستويات بشكل أسي مع كل مستوى إضافي — فكر في إزالة التطبيع عند وجود ثلاثة مستويات أو أكثر.

100%
sequenceDiagram
    participant Order as orders Gathering
    participant Lookup as $lookup
    participant User as users Gathering
    
    Order->>Lookup: doc1: {userId: ObjectId_A}
    Lookup->>User: find({_id: ObjectId_A})
    User-->>Lookup: [{username: 'alice', email: '...'}]
    Lookup-->>Order: doc1 + {userInfo: [{username: 'alice'}]}
    
    Order->>Lookup: doc2: {userId: ObjectId_B}
    Lookup->>User: find({_id: ObjectId_B})
    User-->>Lookup: [] (No matches found)
    Lookup-->>Order: doc2 + {userInfo: []} (LEFT JOIN Behavior)
معلمة $lookup تنسيق المطابقة التامة تنسيق خط الأنابيب
from ✅ اسم المجموعة ذات الصلة ✅ اسم المجموعة ذات الصلة
localField ✅ مجال التجميع الحالي ❌ غير مستخدم
foreignField ✅ حقل مجموعة ذات صلة ❌ غير مستخدم
let ❌ غير مستخدم ✅ تعريف المتغير
pipeline ❌ غير مستخدم ✅ خط أنابيب فرعي (يدعم $match و$project وما إلى ذلك)
as ✅ اسم حقل الإخراج ✅ اسم حقل الإخراج

مقارنة بين JOIN و$lookup: تعتبر عملية JOIN في SQL عملية أصلية في قاعدة البيانات، ويمكن للمُحسِّن اختيار استراتيجيات مثل Nested Loop أو Hash Join أو Merge Join. أما $lookup فتقوم أساسًا بتنفيذ استعلام فرعي على كل مستند مدخل — ويشبه أداؤها أداء عملية Nested Loop Join في SQL، لكنها غير فعالة عند التعامل مع مجموعات البيانات الكبيرة. الاختلافات الرئيسية: 1. تُرجع عملية JOIN في SQL صفوفًا مسطحة، بينما تُرجع $lookup مصفوفات متداخلة (يجب فك تغليفها باستخدام $unwind)؛ 2. يحتوي SQL على مُحسّن استعلامات يختار استراتيجيات JOIN تلقائيًا، في حين تفتقر $lookup إلى هذا التحسين؛ 3. يسمح تنسيق مسار $lookup بإضافة شروط تصفية، على غرار JOIN + WHERE في SQL.

مشكلة N+1 وحلولها: تكمن مشكلة الأداء في $lookup في "استعلام N+1" — إذا كان هناك 1,000 سجل في orders، فإن عملية الربط المتساوي $lookup تنفذ استعلام users لكل سجل order، مما ينتج عنه إجمالي 1,001 استعلام. الحلول البديلة: 1. تأكد من فهرسة حقل الانضمام (يجب فهرسة foreignField)؛ 2. في وضع خط الأنابيب، استخدم $match للتصفية أولاً، ثم قم بالربط؛ 3. بالنسبة لمجموعات البيانات الكبيرة، ضع في اعتبارك إزالة التطبيع (تخزين أسماء المستخدمين بشكل متكرر لتقليل استعلامات $lookup)؛ 4. على الرغم من أن mongoose populate يعاني أيضًا من مشكلة N+1، إلا أنه مقبول لمجموعات البيانات الصغيرة.

تحليل متعمق لنموذج تنفيذ $lookup: منطق التنفيذ الداخلي لـ $lookup—1. شكل التكافؤ: لكل مستند في المجموعة اليسرى، يسترد MongoDB قيمة localField ويبحث عن مستند مطابق في فهرس foreignField للمجموعة اليمنى (باستخدام IXSCAN في حالة وجود فهرس، وإلا باستخدام COLLSCAN)، مع تضمين النتائج في مصفوفة as. وهذا يعادل «الربط بالحلقة المتداخلة» (Nested Loop Join) في لغة SQL — حيث تتكرر الحلقة الخارجية عبر المجموعة اليسرى، بينما تبحث الحلقة الداخلية في المجموعة اليمنى؛ 2. شكل خط الأنابيب: بالنسبة لكل مستند في المجموعة اليسرى، يتم ربط المتغيرات المُعرَّفة بـ let بقيم حقول المستند الحالي، ويتم تنفيذ خط أنابيب فرعي. الخط الفرعي هو خط تجميع كامل (على سبيل المثال، $match/$project/$group)، ويوفر مرونة أكبر ولكن أداءً أقل (لا يمكن للخطوط الفرعية الاستفادة من تلميحات الفهرس خارج $lookup)؛ 3. مقارنة الأداء: الانضمام المتساوي > خط الأنابيب (يمكن للانضمامات المتساوية الاستفادة من الفهارس ولديها خطة تنفيذ أبسط). مبدأ الاختيار: استخدم الانضمامات المتساوية للانضمامات البسيطة؛ واستخدم تنسيق خط الأنابيب عندما يتطلب الأمر تصفية شرطية.

قائمة مرجعية عملية لتحسين أداء $lookup: المفتاح لتحسين أداء $lookup هو التأكد من فهرسة الحقول ذات الصلة—1. يجب فهرسة foreignField: عندما يقوم $lookup بإجراء بحث في المجموعة اليمنى، فإنه يستخدم IXSCAN (أداء بمستوى الميلي ثانية) في حالة وجود فهرس؛ وإلا، فإنه يستخدم COLLSCAN (مسح كامل للجدول، مما يؤدي إلى زمن انتقال من المستوى الثاني مع وجود عشرات الآلاف من المستندات)؛ 2. لا يتطلب الحقل localField فهرسًا: يقوم $lookup بإجراء بحث أحادي الاتجاه من اليسار إلى اليمين، ويتم مسح المجموعة اليسرى بالتسلسل؛ 3. في تكوينات خطوط الأنابيب، يتطلب الحقل $match فهرسًا: يتبع $match داخل خطوط الأنابيب الفرعية قواعد الفهرسة القياسية؛ 4. في سيناريوهات الدمج المركب: إذا تبع $lookup مرشح $match، فتأكد من أن الحقل $match مفهرس أيضًا. أوامر التحقق من الفهرس: استخدم db.orders.getIndexes() للتأكد مما إذا كان foreignField مفهرسًا، واستخدم db.orders.explain('executionStats').aggregate(...) للتحقق مما إذا كان $lookup في خطة التنفيذ يستخدم IXSCAN.

JAVASCRIPT
// === Basic $lookup ===
db.orders.aggregate([
  {
    $lookup: {
      from: 'users',              // Associative Set
      localField: 'userId',       // Current collection fields
      foreignField: '_id',        // Associated Set Fields
      as: 'userInfo'              // Output Field Name
    }
  }
]);
// Results:Added to each order document userInfo Array(User documents containing matches)

// === Analogy SQL ===
// SELECT orders.*, users.*
// FROM orders
// LEFT JOIN users ON orders.userId = users._id


3. أمثلة عملية على دالة $lookup

نظرة عامة على المفهوم: يوضح هذا القسم الاستخدام العملي لـ $lookup من خلال ثلاثة سيناريوهات متدرجة: عمليات الربط أحادية المستوى (الطلب → المستخدم)، وعمليات الربط على غرار خط الأنابيب (مع التصفية الشرطية)، وعمليات الربط المتداخلة (الطلب → المستخدم → العنوان). وتلبي كل طريقة متطلبات الربط بمستويات متفاوتة من التعقيد.

كيفية العمل: التعيين أحادي الطبقة هو أبسط طريقة؛ حيث يقوم بمطابقة قيم الحقول مباشرةً. في تنسيق مسار المعالجة، يقوم let أولاً بتعريف حقول المستند الحالي كمتغيرات، ثم يستخدم مسار المعالجة الفرعي $expr + $$variable للإشارة إلى هذه المتغيرات لإجراء المطابقة الشرطية. يتم تحقيق الارتباطات المتداخلة من خلال عمليات متعددة متتالية من $lookup + $unwind، حيث تربط كل خطوة طبقة واحدة، لتجميع البيانات الكاملة تدريجيًا.

مقارنة بين ثلاثة أوضاع عملية لـ $lookup: لكل وضع من أوضاع الربط الثلاثة حالات الاستخدام الخاصة به — 1. مطابقة التكافؤ (localField/foreignField): أبسطها وأسرعها، ومناسبة لـ 90% من عمليات الربط «واحد إلى عدة» (الطلب → المستخدم، المقالة → المؤلف)؛ 2. تنسيق المسار (let + pipeline + $expr): مرن ولكنه بطيء؛ مناسب للسيناريوهات التي تتطلب تصفية نتائج الانضمام (على سبيل المثال، ضم المستخدمين النشطين فقط، وإرجاع الطلبات الحديثة فقط)؛ 3. عمليات الربط المتداخلة (تسلسل عدة عبارات $lookup): تجمع البيانات المتداخلة متعددة المستويات، لكن كل مستوى يزيد من تعقيد الاستعلام؛ في حالة وجود ثلاثة مستويات أو أكثر، فكر في استخدام الحقول المتكررة بدلاً من ذلك (على سبيل المثال، تخزين user.name بشكل متكرر في مجموعة orders بدلاً من استخدام $lookup إلى مجموعة users).

القواعد الذهبية لتحسين أداء $lookup: يعتمد أداء $lookup على عاملين — 1. ما إذا كان foreignField في مجموعة from مفهرسًا (هذا هو العامل الأكثر أهمية! فبدون فهرس، يقوم $lookup بإجراء مسح كامل للمجموعة لكل مستند مدخل؛ N مستند مدخل × M مستند from = O(N*M)، مما يؤدي إلى كارثة في الأداء)؛ 2. عدد المستندات المدخلة (استخدم $match قبل $lookup لتقليل حجم المدخلات). قائمة مراجعة التحسين: ① أنشئ فهرسًا على الحقل الأجنبي (foreignField)؛ ② قم بالمعالجة المسبقة باستخدام $match لتقليل حجم المدخلات؛ ③ في خط الأنابيب، قم بتنفيذ $match و$project في خطوط الأنابيب الفرعية في أقرب وقت ممكن؛ ④ تجنب عمليات $lookup المتداخلة (استبدلها بحقول متكررة)؛ ⑤ ضع في اعتبارك تنفيذ $lookup من الجانب «الأكبر» (على سبيل المثال، 100 طلب مرتبط بـ 10 مستخدمين — يكون تنفيذ البحث من جانب الطلبات أكثر كفاءة).

100%
graph TD
    A[Single-layer association<br/>localField/foreignField] --> B[pipelineForm<br/>let + pipeline + $expr]
    B --> C[Nested Relationships<br/>Several$lookupIn Series]
    
    A --> D["Simple Equivalence Matching<br/>Order→User"]
    B --> E["Conditional Association<br/>Active users only"]
    C --> F["Multi-level nesting<br/>Order→User→Address"]
    
    D --> G["1The query is complete<br/>Replacepopulate"]
    E --> H["Variable Passing+Filter<br/>High flexibility"]
    F --> I["Step-by-Step Assembly<br/>Note$unwind"]
    
    style D fill:#d4edda
    style E fill:#cce5ff
    style F fill:#fff3cd

(1) الارتباط أحادي الطبقة

لماذا يعد استخدام $unwind ضروريًا بعد $lookup: تُرجع دالة $lookup دائمًا مصفوفة — حتى لو تمت مطابقة مستند واحد فقط، فإن النتيجة تكون userInfo: [{name: 'Alice'}]. وللسماح للواجهة الأمامية باستخدام user.name مباشرةً بدلاً من user[0].name، فإن $unwind ضروري لتحويل المصفوفة إلى كائن. $unwind: '$userInfo' يحول userInfo: [{name: 'Alice'}] إلى userInfo: {name: 'Alice'}. ملاحظة: إذا لم تتطابق $lookup مع أي مستندات (لا توجد تطابقات في LEFT JOIN)، فستتجاهل $unwind المستند — استخدم preserveNullAndEmptyArrays: true للحفاظ على دلالات LEFT JOIN.

ثلاث طرق لاستخدام $unwind: يمكن استخدام $unwind بثلاث طرق في استعلامات الانضمام — 1. مصفوفة → مستندات متعددة (الاستخدام القياسي): تقوم $unwind: '$items' بتحويل [{_id:1, items:[{a:1},{a:2}]}] إلى [{_id:1, items:{a:1}}, {_id:1, items:{a:2}}]، حيث يُنشئ كل عنصر في المصفوفة مستندًا جديدًا لإعادة التجميع باستخدام $group؛ 2. مصفوفة → كائن (استرداد القيم بعد $lookup): يحول $unwind: '$userInfo' userInfo:[{name:'Alice'}] إلى userInfo:{name:'Alice'}، وعند استخدامه مع preserveNullAndEmptyArrays، فإنه يحافظ على LEFT JOIN؛ 3. تفكيك المصفوفات المتداخلة: أولاً، استخدم $unwind على المصفوفة الخارجية، ثم على المصفوفة الداخلية (على سبيل المثال، ordersitemstags). ينتج عن كل مستوى من $unwind حاصل ضرب ديكارت؛ انتبه إلى توسع حجم البيانات. النمط 2 هو الأكثر شيوعًا (غالبًا ما يتبع $lookup $unwind)، بينما يُستخدم النمط 1 للعد المستقل للعناصر داخل المصفوفة.

متطلبات الفهرسة لعمليات الربط أحادية المستوى: يعتمد أداء $lookup بشكل كبير على الفهرس الموجود في حقل الربط — يجب فهرسة foreignField؛ وإلا، يتم إجراء مسح كامل للمجموعة لكل عملية مطابقة. بالنسبة لشكل المساواة $lookup (localField/foreignField)، لا يلزم فهرسة سوى foreignField؛ أما بالنسبة لشكل خط الأنابيب $lookup، فإن الأداء يعتمد على ما إذا كان $match في خط الأنابيب الفرعي يمكنه استخدام فهرس أم لا. في بيئات الإنتاج، يجب إنشاء فهرس على الحقل الأجنبي لمجموعة from — وهذه هي الأولوية القصوى لتحسين أداء $lookup.

JAVASCRIPT
// === Order + User ===
db.orders.aggregate([
  {
    $lookup: {
      from: 'users',
      localField: 'userId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' }  // Treating factorials as objects
]);

(2) تنسيق Pipeline (MongoDB 5.0+)

مرونة تنسيق خط الأنابيب: تعمل دالة $lookup بنمط خط الأنابيب على معالجة أربعة أنواع من السيناريوهات التي لا تستطيع المطابقات الدقيقة التعامل معها: 1. عمليات الربط الشرطي (ربط المستخدمين النشطين فقط، أو ربط أحدث سجل فقط)؛ 2. المطابقات متعددة الشروط (المطابقة لكل من القسم ومستوى الوظيفة)؛ 3. الإسقاط أثناء عمليات الربط (إرجاع الحقول المحددة فقط من المجموعة المربوطة)؛ 4. عمليات الربط الحسابية (الشروط في $expr التي لا تتمثل في مساواة بسيطة بين الحقول). يتيح تنسيق خط الأنابيب تمرير المتغيرات عبر المجموعات المختلفة باستخدام صيغة let + $$variable — حيث يحدد let تعيين اسم المتغير، ويُستخدم $$variable للإشارة إليه داخل خط الأنابيب.

آلية تمرير المتغيرات باستخدام + $$variable: جوهر بناء جملة خط الأنابيب هو تمرير المتغيرات — حيث يقوم let: { orderUserId: '$userId' } بتعيين حقل userId في المستند الحالي إلى المتغير $$orderUserId، الذي يُشار إليه بعد ذلك في $match.$expr داخل خط أنابيب فرعي. ملاحظة: $expr مطلوب — لا يمكن لـ $match العادي الإشارة إلى $$variable؛ ولا يمكن استخدامها إلا في تعبيرات التجميع داخل $expr. وهذا هو السبب الأساسي الذي يجعل تنسيق خط الأنابيب أكثر تعقيدًا ولكنه أيضًا أكثر مرونة من تنسيق المساواة.

تكلفة أداء مسار $lookup: يُعد نهج المسار أبطأ من نهج مطابقة التساوي — لأن عمليات مطابقة التساوي يمكنها الاستفادة من الفهارس الموجودة في foreignField لإجراء عمليات بحث فعالة، في حين أن نهج المسار ينفذ مسارًا فرعيًّا على المجموعة from. الفرق في الأداء: مطابقة المساواة ≈ O(N) (حيث N هو عدد المستندات في المجموعة الحالية)، وخط الأنابيب ≈ O(N*M) (حيث M هو عدد المستندات في مجموعة from). استراتيجيات التخفيف: 1. التصفية في أقرب وقت ممكن باستخدام $expr في $match الخاصة بالخط الفرعي؛ 2. إنشاء فهارس مناسبة على مجموعة from؛ 3. التحكم في تعقيد الخط الفرعي — تنفيذ $match و$project فقط، وتجنب العمليات الثقيلة مثل $group.

دليل الاختيار بين تنسيقي «التكافؤ» و«خط الأنابيب»: الاختيار بين تنسيقي $lookup — 1. سيناريوهات تنسيق «التكافؤ»: حيث يتضمن كل من localField وforeignField مطابقات بسيطة لتساوي الحقول (على سبيل المثال، orders.userId = users._id). ويمثل هذا 80% من حالات الاستخدام الفعلية ويوفر أداءً مثاليًّا؛ 2. سيناريوهات تنسيق خط الأنابيب: يلزم وجود شروط تصفية إضافية (على سبيل المثال، ربط المستخدمين الذين ينطبق عليهم status='active' فقط)، أو مطابقات تجميعية متعددة الحقول (على سبيل المثال، مطابقة كل من department وlevel في آن واحد)، أو عمليات الإسقاط أثناء عمليات الربط (استرداد الحقول المحددة فقط من المستندات المرتبطة)، أو شروط الحساب الديناميكي باستخدام $expr؛ 3. الاستخدام المختلط: يمكنك استخدام كل من صيغة المساواة وصيغة خط الأنابيب في آن واحد ضمن نفس خط أنابيب التجميع — استخدم صيغة المساواة للربط البسيط (أسرع) وصيغة خط الأنابيب للربط المعقد (أكثر مرونة). القاعدة العامة: جرب صيغة المساواة أولاً؛ وإذا لم تكن كافية، فانتقل إلى صيغة خط الأنابيب.

JAVASCRIPT
// === pipeline Form(Supports complex conditions)===
db.orders.aggregate([
  {
    $lookup: {
      from: 'users',
      let: { order_user_id: '$userId' },
      pipeline: [
        {
          $match: {
            $expr: {
              $and: [
                { $eq: ['$_id', '$$order_user_id'] },
                { $eq: ['$isActive', true] }  // Active users only
              ]
            }
          }
        },
        {
          $project: {                  // Projection
            username: 1,
            email: 1,
            avatar: 1
          }
        }
      ],
      as: 'customer'
    }
  }
]);

(3) دالة $lookup المتداخلة

تحديات الأداء في عمليات الربط متعددة المستويات: تُستخدم عمليات $lookup المتداخلة لتنفيذ عمليات الربط متعددة المستويات (الطلب → المستخدم → العنوان)، لكن كل مستوى من مستويات $lookup يضيف استعلامًا فرعيًّا، مما يؤدي إلى تدهور الأداء بشكل خطي مع زيادة عمق التداخل. استراتيجيات التخفيف: 1. استخدم نهج خط الأنابيب لتقليل عدد الحقول التي يتم إرجاعها في كل مستوى (إسقاط $project)؛ 2. ضع في اعتبارك إزالة التطبيع — قم بتخزين أسماء المستخدمين والعناوين بشكل متكرر في جدول الطلبات لتجنب استخدام $lookup؛ 3. بالنسبة لعمليات الربط ذات المستويات الثلاثة أو أكثر، يُنصح باستخدام استعلامات بسيطة متعددة في طبقة التطبيق وتجميع النتائج في الذاكرة، بدلاً من تداخل $lookup داخل خط الأنابيب.

تصميمات بديلة لعمليات الربط المتداخلة: عمليات البحث المتداخلة $lookup ليست الحل الوحيد لعمليات الربط متعددة المستويات — وفيما يلي مقارنة بين أربعة بدائل: 1. الحقول المكررة (تخزين user.name + address.city في الطلب؛ تحديث الحقول المكررة أثناء عمليات الكتابة؛ لا حاجة إلى استخدام $lookup أثناء عمليات القراءة؛ مناسب للحالات التي تتسم بعمليات قراءة متكررة وعمليات كتابة نادرة)؛ 2. استعلامات مستقلة متعددة (الاستعلام الأول عن Orders → جمع userId → استخدام $in للاستعلام عن Users → جمع addressId → استخدام $in للاستعلام عن Addresses؛ يتطلب كودًا أكثر لكن الأداء قابل للتحكم)؛ 3. $graphLookup (الارتباط التكراري؛ مناسب لهياكل الشجرة/الرسم البياني لكنه غير مناسب للارتباطات البسيطة ذات المستويات الثلاثة؛ أداء ضعيف)؛ 4. ORM على مستوى طبقة التطبيق (يدعم Mongoose populate استدعاءات populate المتداخلة، لكن هذا يعادل في الأساس استعلامات متعددة). في المشاريع الواقعية، النهج الأكثر شيوعًا هو مزيج من الخيار 1 (التكرار) والخيار 2 (الاستعلامات عند الطلب)، مع استخدام $lookup المتداخلة فقط في سيناريوهات إعداد التقارير.

التحكم في حجم بيانات نتائج $lookup: الحقل as في $lookup هو مصفوفة قد تكون ضخمة جدًّا — على سبيل المثال، إذا كان لدى مستخدم واحد 1,000 طلب، فإن المصفوفة as في $lookup تحتوي على 1,000 مستند. للتحكم في حجم البيانات: 1. أضف $limit بتنسيق خط الأنابيب (لا تُرجع سوى أحدث 5 طلبات)؛ 2. قم بتبسيط الحقول في $project (لا تُرجع سوى orderId وtotal، وليس تفاصيل الطلب الكاملة)؛ 3. قم بالتصفية في $match (لا تُرجع سوى الطلبات المدفوعة)؛ 4. استخدم $slice لتقليص المصفوفة ($project: {recentOrders: {$slice: ['$orders', 5]}}). يعد الفشل في التحكم في حجم نتائج $lookup سببًا شائعًا لتجاوز سعة الذاكرة — حيث يمكن لمجموعات النتائج الكبيرة من $facet مقترنةً بـ $lookup أن تتجاوز بسهولة 100 ميغابايت.

تطبيع البيانات مقابل عدم التطبيع: يتطلب تصميم العلاقات في MongoDB تحقيق التوازن بين التطبيع وعدم التطبيع — حيث يضمن التطبيع (المراجع + $lookup) اتساقًا جيدًا للبيانات ولكنه يؤدي إلى استعلامات معقدة، في حين أن عدم التطبيع (التضمين المتكرر) يبسط الاستعلامات ولكنه يتطلب تحديثات متزامنة. قواعد اتخاذ القرار: 1. نادرًا ما تتغير البيانات (مثل أسماء المستخدمين، وعناوين المنتجات) → التخزين المتكرر؛ 2. تتغير البيانات بشكل متكرر (مثل الصور الرمزية للمستخدمين، والمخزون) → التخزين المرجعي؛ 3. البيانات ذات الصلة مطلوبة دائمًا → التخزين المتكرر؛ 4. البيانات ذات الصلة مطلوبة من حين لآخر → التخزين المرجعي.

الحفاظ على الاتساق في الحقول المكررة: بمجرد اختيار التخزين المكرر، يجب معالجة مسألة مزامنة البيانات — فإذا قام مستخدم بتغيير صورة ملفه الشخصي، يجب أيضًا تحديث صورة ملفه الشخصي في طلباته. هناك ثلاث استراتيجيات للمزامنة: 1. مدفوعة بالأحداث (يتم بث التحديثات عبر Change Stream عندما يقوم المستخدم بإجراء تغيير، ويقوم المشتركون بمزامنة جميع النسخ المتكررة) — توفر أفضل أداء في الوقت الفعلي ولكن تنفيذها معقد؛ 2. المزامنة الدفعية (تقوم مهمة مجدولة بالبحث عن التحديثات كل ساعة) — بسيطة ولكنها تتسبب في تأخير مدته ساعة واحدة؛ 3. الدمج عند الاستعلام (تخزين الحقول الأساسية بشكل متكرر واستخدام $lookup لجلب أحدث الحقول أثناء الاستعلامات) — حل وسط. في معظم السيناريوهات، تكون الاستراتيجية 2 كافية، شريطة أن تكون قادرًا على تحمل فترات قصيرة من عدم الاتساق.

نظام تحديد الإصدارات للحقول المتكررة: نظام مزامنة متكرر أكثر دقة — يتمثل في إضافة رقم إصدار إلى الحقول المتكررة — 1. تضمين userSnapshot: {name: 'Alice', avatar: 'url1', version: 3} في الترتيب؛ 2. زيادة version عند تحديث المستخدم؛ 3. عند الاستعلام، مقارنة أرقام الإصدارات؛ إذا كان order.userSnapshot.version < user.currentVersion، فاستخدم $lookup لجلب أحدث البيانات؛ 4. أثناء المزامنة الدفعية، قم بتحديث المستندات ذات الإصدارات القديمة فقط (قم بالتصفية باستخدام $match لتقليل حجم التحديثات). مزايا نهج الإصدارات: أثناء الاستعلامات، يمكن تحديد ما إذا كانت البيانات الزائدة قديمة، ويتم تحديث البيانات القديمة عند الطلب بدلاً من إجراء مسح كامل. المقابل هو خطوة إضافية لمقارنة الإصدارات أثناء كل استعلام (لكن تكلفة هذه المقارنة أقل بكثير من تكلفة المزامنة الكاملة).

JAVASCRIPT
// === Order → User → User Address ===
db.orders.aggregate([
  {
    $lookup: {
      from: 'users',
      localField: 'userId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' },
  {
    $lookup: {
      from: 'addresses',
      localField: 'customer.defaultAddressId',
      foreignField: '_id',
      as: 'customer.defaultAddress'
    }
  },
  { $unwind: '$customer.defaultAddress' }
]);


4. $unwind: تفكيك المصفوفات

طبيعة ومخاطر $unwind: تتمثل الوظيفة الأساسية لـ $unwind في تحويل علاقة «واحد إلى عدة» من تنسيق مصفوفة إلى تنسيق «متعدد الصفوف» — وهذا يمثل قيمتها ومخاطرها في آن واحد. القيمة: بعد التقسيم، يمكنك استخدام $match لتصفية العناصر الفردية، و$lookup لإعادة إنشاء العلاقات، و$group لإعادة تجميع البيانات. المخاطر: 1. يؤدي تقسيم المصفوفات الكبيرة إلى تضخم المستندات (مصفوفة تحتوي على N عنصرًا → N ضعف عدد المستندات)؛ 2. بعد التقسيم، تحتاج إلى استخدام $group لإعادة التنظيم حسب _id؛ 3. القيمة الافتراضية لـ preserveNullAndEmptyArrays هي false، مما يؤدي إلى استبعاد المستندات التي تحتوي على مصفوفات فارغة. أفضل الممارسات: استخدم $group أو $match مباشرةً بعد $unwind لتجنب تمرير البيانات المتضخمة عبر مسار المعالجة.

ثلاثة أنماط لاستخدام $unwind: هناك ثلاث طرق نموذجية لاستخدام $unwind في مسار التجميع — 1. $lookup + $unwind (الأكثر شيوعًا): بعد عملية ربط $lookup، يصبح الحقل as مصفوفة؛ حيث تقوم $unwind بتقسيمه إلى كائنات، مما يحقق تحويلًا دلاليًا من "LEFT JOIN" إلى "INNER JOIN"؛ 2. $unwind + $group (عد عناصر المصفوفة): أولاً، قم بتقسيم مصفوفة tags إلى مستندات متعددة، ثم قم بالتجميع والعد حسب العلامة لتحديد تكرار العلامة؛ 3. $unwind + $unwind (توسيع المصفوفات المتداخلة): بالنسبة للمصفوفات المتداخلة ذات المستويين (على سبيل المثال، orderitemsvariants)، يلزم إجراء عمليتي $unwind لتوسيع كل مستوى. يعادل معامل توسيع البيانات لكل عملية $unwind متوسط طول المصفوفة ناقصًا 3 — فالمصفوفة المكونة من 3 عناصر تتوسع 3 أضعاف، بينما تتوسع المصفوفة المكونة من 100 عنصر 100 ضعف. للتحكم في هذا التوسيع، استخدم $project قبل $unwind للاحتفاظ بالحقول الضرورية فقط، مما يقلل من حجم كل مستند موسع.

بدائل لـ $unwind: لا تكون $unwind ضرورية في كل الحالات — 1. إذا كنت تحتاج فقط إلى طول المصفوفة: استخدم $size بدلاً من ذلك ($project: {tagCount: {$size: '$tags'}}, بدون تفكيك)؛ 2. إذا كنت تحتاج فقط إلى عنصر معين في المصفوفة: استخدم $arrayElemAt بدلاً من ذلك ($project: {firstTag: {$arrayElemAt: ['$tags', 0]}}, بدون تقسيم)؛ 3. إذا كنت بحاجة إلى تصفية العناصر في المصفوفة: استخدم $filter بدلاً من ذلك ($project: {highPrice: {$filter: {input: '$items', cond: {$gte: ['$$this.price', 1000]}}}}, بدون تفريغ)؛ 4. إذا كنت تحتاج فقط إلى تحويل المصفوفة: استخدم $map بدلاً من ذلك ($project: {upperTags: {$map: {input: '$tags', in: {$toUpper: '$$this'}}}}، لا تقم بالتقسيم). استخدم $unwind فقط عندما تحتاج إلى «معاملة عناصر المصفوفة كوثائق منفصلة من أجل التجميع اللاحق» — مثل التجميع حسب عناصر المصفوفة باستخدام $group أو إجراء عمليات البحث استنادًا إلى عناصر المصفوفة باستخدام $lookup.

التجربة العملية في تحسين أداء $unwind: تتمثل عقبة الأداء في $unwind في تضخم البيانات — ومبدأ التحسين هو «تقليل حجم البيانات في أقرب وقت ممكن» — —1. تنفيذ $project قبل $unwind: الاحتفاظ فقط بـ _id والحقول المراد تقسيمها، مما يقلل حجم كل مستند موسع (على سبيل المثال، إذا كان المستند الأصلي يحتوي على 50 حقلًا، فإن كل مستند بعد $unwind لا يتطلب سوى 5 حقول؛ ويؤدي تنفيذ $project قبل $unwind إلى تقليل استخدام الذاكرة بنسبة 90%)؛ 2. استخدام $match بعد $unwind: تصفية عناصر المصفوفة غير الضرورية على الفور (على سبيل المثال، الاحتفاظ فقط بالعناصر التي تحتوي على status: 'active' بعد $unwind) لتقليل عبء المعالجة في المراحل اللاحقة؛ 3. تجنب استخدام $unwind + $sort: استخدم $match/$group أولاً لتقليل عدد المستندات، ثم قم بتنفيذ $sort، بدلاً من تضخيم البيانات باستخدام $unwind أولاً ثم فرزها (الأمر الذي يتطلب فرز بيانات تم تضخيمها N مرات في الذاكرة)؛ 4. تحسين استخدام $unwind المتداخل: يُعد استخدام $unwind المتداخل مقبولًا عندما يكون المصفوف الخارجي قصيرًا (3–5 عناصر)؛ وعندما يكون المصفوف الخارجي طويلًا (100+ عنصر)، فكر في استخدام $reduce لدمج المصفوفات الداخلية أولاً.

100%
graph LR
    A["{items: [A, B, C]}"] --> B["$unwind: '$items'"]
    B --> C["{items: A}"]
    B --> D["{items: B}"]
    B --> E["{items: C}"]
    
    F["{items: []}"] --> G["$unwind<br/>preserveNull: false"]
    G --> H[❌ The document was discarded]
    
    F --> I["$unwind<br/>preserveNull: true"]
    I --> J["{items: null} ✅"]
    
    style C fill:#d4edda
    style D fill:#d4edda
    style E fill:#d4edda
    style H fill:#f8d7da
    style J fill:#d4edda
خيار $unwind التأثير المكافئ في SQL
{ path: '$items' } تقسيم المصفوفات الفارغة وإزالتها INNER JOIN
{ path: '$items', preserveNullAndEmptyArrays: true } تقسيم، مع الاحتفاظ بالمصفوفات الفارغة LEFT JOIN

نمط إعادة التنظيم "$unwind + $group": بعد أن تقوم $unwind بتقسيم المجموعات، تُستخدم $group عادةً لإعادة تنظيمها حسب _id — وهذا هو النمط الأكثر شيوعًا لـ "التقسيم - المعالجة - إعادة التنظيم" في مسار التجميع. سير العمل النموذجي: $unwind '$items' → $group لإعادة التجميع حسب orderId، باستخدام $push لتجميع العناصر التي تمت معالجتها. الفرق الرئيسي: تقوم $push بجمع العناصر الفردية (التي تمت معالجتها بالفعل) بعد $unwind، بدلاً من عناصر المصفوفة الأصلية. على سبيل المثال: بعد $unwind، تضيف $addFields سعرًا مخفضًا لكل عنصر؛ وعند استخدام $group، تقوم $push بجمع الكائنات الجديدة التي تحتوي على الأسعار المخفضة.

بدائل لـ $unwind: لا تتطلب جميع عمليات المصفوفات استخدام $unwind — تجنب استخدام $unwind إذا كان بإمكان $map أو $filter إنجاز المهمة. تقوم $map بتحويل عناصر المصفوفة دون تغيير عدد المستندات، بينما تقوم $filter بتصفية عناصر المصفوفة دون تغيير عدد المستندات. استخدم $unwind فقط عندما تحتاج إلى «معاملة عناصر المصفوفة كمستندات منفصلة». معايير اتخاذ القرار: 1. إذا كنت تحتاج فقط إلى تصفية عناصر المصفوفة أو تحويلها → استخدم $filter/$map؛ 2. إذا كنت بحاجة إلى إجراء $lookup على كل عنصر → استخدم $unwind + $lookup؛ 3. إذا كنت بحاجة إلى $group حسب عناصر المصفوفة → استخدم $unwind + $group؛ 4. إذا كنت بحاجة إلى الفرز حسب عناصر المصفوفة → استخدم $unwind + $sort.

JAVASCRIPT
// === $unwind Split into subgroups ===
db.orders.aggregate([
  {
    $lookup: {
      from: 'order_items',
      localField: '_id',
      foreignField: 'orderId',
      as: 'items'
    }
  },
  { $unwind: '$items' }
]);
// Each items Array Elements Become Separate Documents

// === preserveNullAndEmptyArrays Keep an empty array ===
db.orders.aggregate([
  { $lookup: { from: 'order_items', localField: '_id', foreignField: 'orderId', as: 'items' } },
  { $unwind: { path: '$items', preserveNullAndEmptyArrays: true } }
]);


5. مقارنة بين $lookup و mongoose populate

البعد $lookup ملء البيانات في Mongoose
موقع التنفيذ طبقة قاعدة البيانات طبقة التطبيق (استعلامات متعددة)
الأداء استعلام تجميعي واحد عدة دورات ذهابًا وإيابًا (كلما زادت كمية البيانات المطلوب إدخالها، زادت بطء العملية)
المرونة يدعم مسارات العمل المعقدة يدعم ربط المراجع فقط
التداخل يدعم مستويات متعددة يدعم مستويات متعددة (تعبئة متداخلة)
مجموعات النتائج الكبيرة ⚠️ الضغط على الذاكرة ⚠️ مشكلة الاستعلام N+1

$lookup مقابل populate: الاختيار بين نموذجي ربط: يمكن لكل من $lookup و populate تنفيذ استعلامات الربط، لكنهما مناسبان لسيناريوهات مختلفة. يُعد $lookup مناسبًا لـ: 1. شروط الربط المعقدة (على سبيل المثال، ربط المستخدمين النشطين فقط، وإرجاع حقول معينة فقط)؛ 2. الحالات التي تتطلب التجميع (حساب الإحصائيات بعد عملية الربط)؛ 3. مجموعات البيانات الكبيرة (أكثر كفاءة من استعلامات N+1). يُعد populate مناسبًا لـ: 1. عمليات الربط البسيطة ref (حيث لا يلزم ملء سوى الحقل المرجعي)؛ 2. الاستدعاءات المتسلسلة (.populate().populate())؛ 3. عندما تكون برامج الوسيطة Mongoose أو الحقول الافتراضية مطلوبة. في المشاريع الواقعية، استخدم populate للربط البسيط (كفاءة تطوير أعلى) و$lookup للربط والتجميع المعقدين (كفاءة تشغيل أعلى).

استراتيجية الجمع بين $lookup و populate: غالبًا ما تتطلب مشاريع الإنتاج الجمع بين هاتين الطريقتين للربط — 1. استخدام $lookup لصفحات قوائم واجهة برمجة التطبيقات (API): إرجاع البيانات المرتبطة في طلب واحد لتقليل عدد رحلات الشبكة ذهابًا وإيابًا (على سبيل المثال، قائمة المنتجات + اسم الفئة + اسم العلامة التجارية)؛ 2. استخدام populate لصفحات تفاصيل واجهة برمجة التطبيقات (API): تجعل استعلامات populate المتسلسلة العلاقات متعددة المستويات أكثر سهولة (على سبيل المثال، تفاصيل الطلب → المستخدم + العنوان + المنتج + التقييمات)، ولا تشكل مشكلة N+1 مصدر قلق كبير بالنسبة لاستعلامات السجل الفردي؛ 3. استخدام populate للوحة تحكم المسؤول: إعطاء الأولوية لكفاءة التطوير (التنفيذ السريع)؛ مع أحجام البيانات الصغيرة (عدد قليل من المستخدمين في لوحة تحكم المسؤول)، لا يمثل الأداء مشكلة؛ 4. استخدام $lookup للتقارير الإحصائية: يلزم وجود مسارات تجميع، ولا يمكن لـ populate معالجة الحسابات الإحصائية. المبدأ الأساسي لهذه الاستراتيجية المختلطة هو: «استخدام $lookup للقوائم والإحصائيات الموجهة للمستخدمين، واستخدام populate للتفاصيل والإدارة الموجهة للمطورين.»

شرح مفصل لمشكلة N+1 في populate: تتمثل مشكلة الأداء في populate في Mongoose في استعلام N+1 — بعد أن يُرجع استعلام قائمة N طلبًا، يقوم populate('userId') بتنفيذ findOne لكل userId، مما يؤدي إلى إجمالي N+1 استعلامًا. الحلول البديلة: 1. lean() + $lookup يدوي (يقلل N+1 إلى استعلام تجميع واحد)؛ 2. populate دفعي (يقوم Mongoose 5.0+ بتحسين هذا تلقائيًا عن طريق دمج عدة استدعاءات populate في استعلام $in واحد)؛ 3. ملء الحقول الضرورية فقط (.populate('userId', 'username') يقلل من عمليات الإدخال/الإخراج). التأثير العملي: populate مقبول لعدد يصل إلى 100 سجل (<50 مللي ثانية)؛ أما بالنسبة لأكثر من 100 سجل، فيجب التبديل إلى $lookup.

الكشف التلقائي عن مشكلة N+1: غالبًا ما تكون مشكلة N+1 غير واضحة خلال مرحلة التطوير (بسبب محدودية بيانات الاختبار)، ولا تظهر إلا بعد النشر مع تزايد حجم البيانات — 1. سجلات الاستعلامات البطيئة: يقوم تكوين slowms في MongoDB بتسجيل الاستعلامات التي تزيد مدة تنفيذها عن 100 مللي ثانية؛ وتعد الاستعلامات المتعددة على نفس المجموعة خلال فترة زمنية قصيرة من سمات مشكلة N+1؛ 2. أدوات APM: تكتشف New Relic/DataDog تلقائيًا الأنماط التي يتم فيها «الاستعلام عن نفس المجموعة عدة مرات ضمن طلب واحد»؛ 3. مراجعة الكود: وجود await Model.findOne() داخل حلقة for في وحدة التحكم يُعد نمطًا كلاسيكيًا لمشكلة N+1؛ 4. اختبارات الوحدة: قم بمحاكاة عدد استدعاءات Model.find؛ إذا تجاوز العدد التوقعات، فهذا يعني وجود مشكلة N+1. أولوية إصلاح مشكلات N+1 بعد اكتشافها: صفحات القوائم > صفحات التفاصيل > لوحة تحكم المسؤول (مرتبة حسب نطاق التأثير على المستخدم).

قائمة مراجعة لتحسين أداء الاستعلامات المرتبطة: هناك خمس نقاط أساسية لتحسين أداء الاستعلامات المرتبطة ($lookup أو populate)—1. يجب فهرسة foreignField (تعد فهرسة حقل الربط في مجموعة from الخاصة بالاستعلام $lookup العامل الأهم في الأداء)؛ 2. قم بالمعالجة المسبقة لمدخلات $lookup باستخدام $match لتقليل حجم البيانات (قم بالتصفية باستخدام $match أولاً، ثم قم بدمج عدد صغير من المستندات باستخدام $lookup)؛ 3. تبسيط الحقول باستخدام $project (استخدام $project في أقرب وقت ممكن في مسار $lookup لاسترداد الحقول الضرورية فقط، مما يقلل من استهلاك الذاكرة وتكاليف الإرسال)؛ 4. التحكم في عمق التداخل (تحديد تداخل $lookup بما لا يزيد عن مستويين؛ وفي حالة وجود 3 مستويات أو أكثر، استخدام الحقول الزائدة أو تجميع البيانات في طبقة التطبيق)؛ 5. النظر في تكرار البيانات (تخزين user.name بشكل متكرر في مجموعة orders لتجنب $lookup إلى مجموعة users، مع التضحية باتساق الكتابة مقابل مكاسب في أداء القراءة).

▶ المثال 1: التطبيق العملي لدالة $lookup مع مجموعات متعددة - تقرير تفاصيل الطلبات

خيارات الهندسة المعمارية للتقارير التي تتضمن ربطًا بين جداول متعددة: يتطلب تقرير تفاصيل الطلبات ربطًا بين أربعة جداول (الطلبات + المستخدمون + المنتجات + العناوين). وهناك طريقتان للتنفيذ: 1. مسار تجميع أحادي المرور: طبقات متعددة من $lookup + $unwind + $group. ويؤدي هذا إلى إكمال الاستعلام في مرور واحد، لكن المسار معقد ويصعب صيانته؛ 2. التجميع على مستوى طبقة التطبيق: عدة استعلامات بسيطة مقترنة بربط داخل الذاكرة باستخدام Node.js؛ الكود واضح لكنه يعاني من مشكلة N+1. معايير الاختيار: حجم البيانات < 1,000 سجل → التجميع على مستوى طبقة التطبيق (بسيط وموثوق)؛ حجم البيانات > 1,000 سجل → مسار التجميع (أداء أفضل).

نمط إعادة التنظيم $lookup + $group: تتضمن العملية النموذجية لتقارير ربط الجداول المتعددة ثلاث خطوات: "unwind → join → reorganize" — يقوم $unwind بفك ضغط المصفوفة items إلى مستندات فردية → يقوم $lookup بربط معلومات المنتج لكل عنصر → يقوم $group بإعادة التجميع حسب orderId (يقوم $push بجمع المصفوفة items). يكمن التحدي في هذا النمط في ضمان صحة خطوة $group: يجب التجميع حسب معرّف الطلب باستخدام _id: '$_id'، واستخدام $first للحفاظ على الحقول غير المصفوفية، واستخدام $push لتجميع الحقول المصفوفية.

JAVASCRIPT
// Preparing the Data:Order + User + Products + Address Table 4
db.users.insertMany([
  { _id: ObjectId('507f1f77bcf86cd799439011'), username: 'alice', email: 'alice@example.com', isActive: true },
  { _id: ObjectId('507f1f77bcf86cd799439012'), username: 'bob',   email: 'bob@example.com',   isActive: true }
]);

db.products.insertMany([
  { _id: ObjectId('507f1f77bcf86cd799439021'), sku: 'PHONE-001', title: 'Smartphone X', price: 599.99 },
  { _id: ObjectId('507f1f77bcf86cd799439022'), sku: 'LAPTOP-001', title: 'Laptop Pro',  price: 1299.99 }
]);

db.orders.insertOne({
  _id: ObjectId('507f1f77bcf86cd799439031'),
  orderNumber: 'ORD-2026-001',
  userId: ObjectId('507f1f77bcf86cd799439011'),
  status: 'paid',
  total: 1899.98,
  items: [
    { productId: ObjectId('507f1f77bcf86cd799439021'), qty: 1, price: 599.99 },
    { productId: ObjectId('507f1f77bcf86cd799439022'), qty: 1, price: 1299.99 }
  ],
  createdAt: new Date('2026-07-01')
});

// Multi-layer $lookup:Order → User → Product Details
db.orders.aggregate([
  { $match: { status: 'paid' } },

  // Linked Users
  {
    $lookup: {
      from: 'users',
      localField: 'userId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' },

  // Items Associated with the Order(pipeline Form + Variable)
  {
    $lookup: {
      from: 'products',
      let: { items: '$items' },
      pipeline: [
        { $match: { $expr: { $in: ['$_id', '$$items.productId'] } } },
        { $project: { sku: 1, title: 1, price: 1 } }
      ],
      as: 'productDetails'
    }
  },

  // Final Project Report
  {
    $project: {
      orderNumber: 1,
      total: 1,
      createdAt: 1,
      customer: { username: '$customer.username', email: '$customer.email' },
      itemCount: { $size: '$items' },
      products: '$productDetails'
    }
  }
]);

// Output Results:
// {
//   orderNumber: 'ORD-2026-001',
//   total: 1899.98,
//   createdAt: 2026-07-01T00:00:00.000Z,
//   customer: { username: 'alice', email: 'alice@example.com' },
//   itemCount: 2,
//   products: [
//     { sku: 'PHONE-001',  title: 'Smartphone X', price: 599.99 },
//     { sku: 'LAPTOP-001', title: 'Laptop Pro',   price: 1299.99 }
//   ]
// }

// Simultaneous Demonstration mongoose populate(Application Layer Solutions):
const order = await Order.findById(orderId)
  .populate('userId', 'username email')
  .populate({
    path: 'items.productId',
    select: 'sku title price'
  })
  .lean();
// populate requires N+1 queries (Poor performance but flexible), $lookup all at once (Good performance)

الإخراج:

TEXT 📖 للعرض فقط
{
  orderNumber: 'ORD-2026-001',
  total: 1899.98,
  createdAt: 2026-07-01T00:00:00.000Z,
  customer: { username: 'alice', email: 'alice@example.com' },
  itemCount: 2,
  products: [
    { sku: 'PHONE-001', title: 'Smartphone X', price: 599.99 },
    { sku: 'LAPTOP-001', title: 'Laptop Pro', price: 1299.99 }
  ]
}

النتيجة: تقرير شامل عن الطلبات يربط تلقائيًا بين معلومات المستخدم ومعلومات المنتج، مما يلغي الحاجة إلى إجراء استعلامات متعددة.



6. التدريب العملي الشامل

شرح المفهوم: يجمع هذا التمرين العملي الشامل بين استخدام دوال $lookup و$unwind و$group لتنفيذ أكثر تقارير ربط الجداول تعقيدًا في سيناريوهات التجارة الإلكترونية: وهو ربط بين أربعة جداول تشمل «الطلبات» و«المستخدمين» و«المنتجات» و«العناوين». ويكمن التحدي الأساسي في أنه بعد استخدام دالة $unwind، يجب استخدام دالة $group لإعادة تجميع البيانات واستعادة العلاقة «واحد إلى عدة».

نمط تصميم الأنابيب للتقارير التي تتضمن ربطًا بين جداول متعددة: يتبع تصميم الأنابيب للتقارير التي تتضمن أربعة جداول نمطًا ثابتًا — 1. $match: تصفية البيانات من الجدول الرئيسي (على سبيل المثال، استرداد الطلبات المدفوعة فقط)؛ 2. $lookup + $unwind: ربط الجداول الفرعية واحدة تلو الأخرى (ربط جدول المستخدم أولاً، ثم جدول العنوان، وأخيرًا جدول المنتج)، مع تحويل المصفوفة فورًا إلى كائن باستخدام $unwind بعد كل $lookup؛ 3. $group: إعادة التجميع حسب _id للجدول الرئيسي، باستخدام $push لجمع العلاقات من نوع «واحد إلى عدة» (على سبيل المثال، عناصر منتجات متعددة لطلب واحد)؛ 4. $project: تبسيط حقول الإخراج، مع إرجاع الحقول التي تحتاجها الواجهة الأمامية فقط. نقطة أساسية: يجب أن يتضمن _id في $group جميع الحقول المطلوبة من الجدول الرئيسي (نظرًا لأن $group لا يُخرج سوى _id ونتيجة عملية التجميع)، وإلا فستُفقد الحقول غير المتعلقة بـ _id.

الأنماط الخاطئة في استخدام $lookup: 1. الإفراط في عمليات الربط — استرجاع حقول غير ضرورية عبر $lookup، مما يؤدي إلى إهدار عمليات الإدخال/الإخراج (استخدم $project في مسار المعالجة لاسترجاع الحقول المطلوبة فقط)؛ 2. استخدام $unwind دون preserveNullAndEmptyArrays — يتم فقدان دلالات LEFT JOIN (يتم تجاهل الطلبات التي لا توجد لها مطابقات)؛ 3. تداخل $lookup لأكثر من 3 مستويات — ينخفض الأداء بشكل حاد؛ فكر في إزالة التطبيع؛ 4. استخدام الحقول ذات الصلة بعد $lookup دون $unwind — تظل النتيجة مصفوفة بدلاً من كائن (على سبيل المثال، customer: [{name: 'alice'}] بدلاً من customer: {name: 'alice'}).

طرق تحسين أداء الاستعلامات المجمعة: هناك نهج منهجي لتحسين أداء $lookup — 1. التحقق من الفهرس: تأكد من أن foreignField في مجموعة from مفهرس (هذا أمر بالغ الأهمية! يؤدي $lookup غير المفهرس إلى مسح كامل للمجموعة، مما يؤدي إلى كارثة في الأداء بنسبة N×M)؛ 2. التحكم في حجم البيانات: استخدم $match قبل $lookup لتقليل عدد المستندات المدخلة، وأضف $project إلى مسار $lookup لتقليل عدد الحقول التي يتم إرجاعها؛ 3. تقييم البدائل: استخدم populate لعمليات الربط البسيطة (كفاءة تطوير أعلى)، و$lookup لعمليات الربط المعقدة (كفاءة تشغيل أعلى)، والحقول الزائدة لمجموعات البيانات الضخمة جدًّا (لتجنب عمليات الربط)؛ 4. تحليل explain(): استخدم db.orders.aggregate([...]).explain() لعرض خطة التنفيذ والتحقق مما إذا كانت مرحلة $lookup تستخدم فهرسًا (IXSCAN مقابل COLLSCAN)؛ 5. تصحيح الأخطاء خطوة بخطوة: قم أولاً بإزالة $lookup لاختبار صحة الأجزاء الأخرى من مسار المعالجة، ثم أعد إضافة $lookup وقم بتصحيح أخطائه بشكل منفصل.

النمط الخاطئ العواقب أفضل الممارسات
لا يوجد $project تم إرجاع عدد كبير جدًا من الحقول أضف $project إلى مسار المعالجة
عدم الحفاظ على القيم الفارغة يتحول LEFT JOIN إلى INNER JOIN الحفاظ على القيم الفارغة والمصفوفات الفارغة
3 مستويات أو أكثر من التداخل أداء ضعيف تكرار البيانات بسبب عدم التطبيع
ليس $unwind الحقل المرتبط عبارة عن مصفوفة $unwind أو $arrayElemAt
100%
graph LR
    A[orders] -->|"$lookup<br/>users"| B[orders + customerArray]
    B -->|"$unwind"| C[orders + customerObject]
    C -->|"$lookup<br/>order_items"| D[orders + itemsArray]
    D -->|"$unwind"| E[Each row 1 item]
    E -->|"$lookup<br/>products"| F[Each row 1 item+product]
    F -->|"$group<br/>$_id"| G[Order-Level Aggregation<br/>items: $push]
    G -->|"$sort/$limit"| H[Final Report]
    
    style H fill:#d4edda

(1) تقارير طلبات التجارة الإلكترونية

JAVASCRIPT
// === Order + User + Products + Address Complete Report ===
db.orders.aggregate([
  { $match: { status: 'paid' } },
  {
    $lookup: {
      from: 'users',
      localField: 'userId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' },
  {
    $lookup: {
      from: 'order_items',
      localField: '_id',
      foreignField: 'orderId',
      as: 'items'
    }
  },
  { $unwind: '$items' },
  {
    $lookup: {
      from: 'products',
      localField: 'items.productId',
      foreignField: '_id',
      as: 'items.product'
    }
  },
  { $unwind: '$items.product' },
  {
    $group: {
      _id: '$_id',
      orderNumber: { $first: '$orderNumber' },
      customer: { $first: '$customer' },
      total: { $first: '$total' },
      items: { $push: '$items' },
      createdAt: { $first: '$createdAt' }
    }
  },
  { $sort: { createdAt: -1 } },
  { $limit: 50 }
]);

نمط إعادة بناء بنية المستند: تتمثل الخطوة الأخيرة في عملية $lookup + $unwind متعددة المستويات في استخدام $group لإعادة التجميع حسب _id، مما يعيد البنية المتداخلة «واحد إلى عدة». تسترد قيمة $first في $group قيمة العنصر الأول (مثل رقم الطلب أو معلومات المستخدم)، بينما تجمع $push المصفوفات (مثل قوائم المنتجات). يُعد نمط «فك التجميع → المعالجة → إعادة التجميع» هذا النموذج القياسي للتعامل مع العلاقات المعقدة في MongoDB — وهو ما يقابل عملية GROUP BY + وظائف التجميع في SQL.

كيف يؤثر موضع $lookup على الأداء: في مسار المعالجة، يتحسن الأداء كلما ظهر $lookup في مرحلة لاحقة — لأن عمليات $match/$project السابقة قد خفضت بالفعل عدد المستندات المدخلة. مثال معاكس: استخدام $lookup أولاً لربط جميع البيانات، ثم التصفية باستخدام $match — يؤدي هذا إلى ربط بيانات غير ضرورية، مما يهدر الموارد الحاسوبية والذاكرة. أفضل الممارسات: التصفية أولاً باستخدام $match (على سبيل المثال، استرداد الطلبات المدفوعة فقط)، ثم الدمج باستخدام $lookup — وهذا يؤدي إلى دمج البيانات الضرورية فقط. يتوافق هذا المبدأ مع مبدأ «استخدام $match في أقرب وقت ممكن».


▶ المثال 2: استخدام $lookup بتنسيق خط الأنابيب مع عمليات الربط الشرطية

سيناريوهات العمل النموذجية لعمليات الربط الشرطي: يعمل $lookup على غرار خط الأنابيب على حل السيناريوهات التي لا يمكن معالجتها عن طريق المطابقات الدقيقة—(1) ربط المستخدمين النشطين فقط (كما في هذا المثال): تصفية المستخدمين الذين غادروا الشركة أو تم تعطيل حساباتهم لتجنب عرض معلومات غير صالحة؛ (2) ربط أحدث سجل فقط: استخدام $sort + $limit(1) داخل خط أنابيب فرعي، مثل العثور على أحدث تسجيل دخول لكل مستخدم؛ (3) عمليات الربط متعددة الشروط: المطابقة المتزامنة بين القسم ومستوى الوظيفة، مثل العثور على الزملاء في نفس القسم وعلى نفس مستوى الوظيفة؛ (4) عمليات الربط الحسابية: لا تستند شروط الربط في $expr إلى المساواة البسيطة بين الحقول، بل إلى المساواة بين النتائج المحسوبة. على الرغم من أن أداء تنسيق خط الأنابيب ضعيف نسبيًا، إلا أنه لا غنى عنه في سيناريوهات الأعمال هذه.

قواعد الإشارة إلى المتغيرات في $expr: يقوم المسار $lookup بتعريف المتغيرات باستخدام let، ويتم الإشارة إليها في المسارات الفرعية باستخدام $$variable. القواعد الأساسية: 1. يمكن تخصيص اسم المتغير في let (على سبيل المثال، order_user_id)، ولكن البادئة $$ إلزامية؛ 2. لا يمكن استخدام $$variable إلا داخل $expr$match: {field: '$$var'} غير صالح ويجب كتابته على النحو التالي: $match: {$expr: {$eq: ['$field', '$$var']}}؛ 3. للإشارة إلى حقل تجميع حالي داخل أنبوب فرعي، استخدم $field؛ وللإشارة إلى متغير let، استخدم $$var — البادئات الخاصة بهذين المتغيرين مختلفة، ويُعد الخلط بينهما خطأً شائعًا.

JAVASCRIPT
// Scene:TechCorp The order system needs to look up orders.,Include only active users,And return only the user's basic information
db.users.insertMany([
  { _id: ObjectId('507f1f77bcf86cd799439011'), username: 'alice', email: 'alice@techcorp.com', isActive: true, role: 'admin' },
  { _id: ObjectId('507f1f77bcf86cd799439012'), username: 'bob', email: 'bob@techcorp.com', isActive: false, role: 'customer' },
  { _id: ObjectId('507f1f77bcf86cd799439013'), username: 'charlie', email: 'charlie@techcorp.com', isActive: true, role: 'customer' }
]);

db.orders.insertMany([
  { orderNumber: 'ORD-001', userId: ObjectId('507f1f77bcf86cd799439011'), total: 599, status: 'paid', createdAt: new Date('2026-06-01') },
  { orderNumber: 'ORD-002', userId: ObjectId('507f1f77bcf86cd799439012'), total: 299, status: 'paid', createdAt: new Date('2026-06-15') },
  { orderNumber: 'ORD-003', userId: ObjectId('507f1f77bcf86cd799439013'), total: 899, status: 'paid', createdAt: new Date('2026-07-01') }
]);

// pipeline $lookup:Include only active users,Filter Fields
db.orders.aggregate([
  { $match: { status: 'paid' } },
  {
    $lookup: {
      from: 'users',
      let: { orderUserId: '$userId' },
      pipeline: [
        {
          $match: {
            $expr: {
              $and: [
                { $eq: ['$_id', '$$orderUserId'] },
                { $eq: ['$isActive', true] }
              ]
            }
          }
        },
        { $project: { username: 1, email: 1, role: 1, _id: 0 } }
      ],
      as: 'customerInfo'
    }
  },
  {
    $addFields: {
      // Convert an empty array to null(Because bob Inactive users,Mismatch)
      customerInfo: { $arrayElemAt: ['$customerInfo', 0] }
    }
  },
  { $sort: { createdAt: -1 } }
]);

// Output:
// ORD-003: customerInfo: {username: 'charlie', email: 'charlie@techcorp.com', role: 'customer'}
// ORD-001: customerInfo: {username: 'alice', email: 'alice@techcorp.com', role: 'admin'}
// ORD-002: customerInfo: null (bob Inactive users have been filtered out)

الإخراج:

TEXT 📖 للعرض فقط
[
  { orderNumber: 'ORD-003', createdAt: 2026-07-01T00:00:00.000Z, customerInfo: { username: 'charlie', email: 'charlie@techcorp.com', role: 'customer' } },
  { orderNumber: 'ORD-001', createdAt: 2026-06-01T00:00:00.000Z, customerInfo: { username: 'alice', email: 'alice@techcorp.com', role: 'admin' } },
  { orderNumber: 'ORD-002', createdAt: 2026-06-15T00:00:00.000Z, customerInfo: null }
]

النتيجة: تُرجع العملية pipeline $lookup المستخدمين النشطين فقط؛ ويتم استبعاد bob من ORD-002 لأن قيمة isActive هي «false»، وبالتالي فإن قيمة customerInfo هي «null».


▶ المثال 3:تقرير الطلبات مع ربط أربع مجموعات(الصعوبة ⭐⭐⭐)

JAVASCRIPT
// Scene:ShopHub Complete order report with multi-table joins
db.customers.insertMany([
  { _id: ObjectId('507f1f77bcf86cd799439001'), name: 'أحمد محمد', email: 'ahmed@example.com', tier: 'Gold' },
  { _id: ObjectId('507f1f77bcf86cd799439002'), name: 'فاطمة علي', email: 'fatima@example.com', tier: 'Silver' }
]);

db.products.insertMany([
  { _id: ObjectId('507f1f77bcf86cd799439101'), sku: 'PHONE-001', name: 'هاتف ذكي X', price: 599, category: 'Electronics' },
  { _id: ObjectId('507f1f77bcf86cd799439102'), sku: 'LAPTOP-001', name: 'لابتوب برو', price: 1299, category: 'Electronics' }
]);

db.orders.insertMany([
  { _id: ObjectId('507f1f77bcf86cd799439201'), orderNumber: 'ORD-001', customerId: ObjectId('507f1f77bcf86cd799439001'), status: 'paid', createdAt: new Date('2026-07-01') },
  { _id: ObjectId('507f1f77bcf86cd799439202'), orderNumber: 'ORD-002', customerId: ObjectId('507f1f77bcf86cd799439002'), status: 'paid', createdAt: new Date('2026-07-10') }
]);

db.order_items.insertMany([
  { orderId: ObjectId('507f1f77bcf86cd799439201'), productId: ObjectId('507f1f77bcf86cd799439101'), quantity: 2, unitPrice: 599 },
  { orderId: ObjectId('507f1f77bcf86cd799439201'), productId: ObjectId('507f1f77bcf86cd799439102'), quantity: 1, unitPrice: 1299 },
  { orderId: ObjectId('507f1f77bcf86cd799439202'), productId: ObjectId('507f1f77bcf86cd799439101'), quantity: 1, unitPrice: 599 }
]);

// تقرير كامل مع ربط جميع الجداول
db.orders.aggregate([
  { $match: { status: 'paid' } },
  // 1. ربط معلومات العميل
  {
    $lookup: {
      from: 'customers',
      localField: 'customerId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' },
  // 2. ربط عناصر الطلب
  {
    $lookup: {
      from: 'order_items',
      localField: '_id',
      foreignField: 'orderId',
      as: 'items'
    }
  },
  // 3. فك عناصر الطلب وربط المنتجات
  { $unwind: '$items' },
  {
    $lookup: {
      from: 'products',
      localField: 'items.productId',
      foreignField: '_id',
      as: 'items.product'
    }
  },
  { $unwind: '$items.product' },
  // 4. إعادة تجميع البنية
  {
    $group: {
      _id: '$_id',
      orderNumber: { $first: '$orderNumber' },
      customer: { $first: '$customer' },
      createdAt: { $first: '$createdAt' },
      items: {
        $push: {
          product: '$items.product.name',
          quantity: '$items.quantity',
          unitPrice: '$items.unitPrice',
          subtotal: { $multiply: ['$items.quantity', '$items.unitPrice'] }
        }
      },
      totalAmount: { $sum: { $multiply: ['$items.quantity', '$items.unitPrice'] } }
    }
  },
  { $sort: { createdAt: -1 } }
]);

الإخراج:

TEXT 📖 للعرض فقط
[
  {
    _id: ...,
    orderNumber: 'ORD-002',
    customer: {name: 'فاطمة علي', email: 'fatima@example.com', tier: 'Silver'},
    items: [{product: 'هاتف ذكي X', quantity: 1, unitPrice: 599, subtotal: 599}],
    totalAmount: 599
  },
  {
    _id: ...,
    orderNumber: 'ORD-001',
    customer: {name: 'أحمد محمد', email: 'ahmed@example.com', tier: 'Gold'},
    items: [{product: 'هاتف ذكي X', quantity: 2, unitPrice: 599, subtotal: 1198}, {product: 'لابتوب برو', quantity: 1, unitPrice: 1299, subtotal: 1299}],
    totalAmount: 2497
  }
]

❓ أسئلة شائعة

المشاكل الشائعة عند استخدام $lookup: 1. يؤدي نسيان استخدام $unwind إلى بقاء الحقل as دائمًا في صورة مصفوفة — مما يؤدي إلى حدوث خطأ في الأكواد اللاحقة التي تصل إليه باعتباره obj.field بدلاً من obj.field[0]؛ 2. مشكلات أداء $lookup — تنفيذ $lookup على مجموعة كبيرة حيث تفتقر مجموعة from إلى فهرس foreignField سيؤدي إلى مسح كامل للمجموعة؛ 3. تضخم $unwind — بعد استخدام $unwind في علاقة «واحد إلى العديد»، يتضاعف عدد المستندات؛ وقد يؤدي تراكم عمليات $unwind المتعددة إلى انفجار في المنتج الديكارتي؛ 4. أسماء المتغيرات المكتوبة بشكل خاطئ في صيغة خط الأنابيب — $$variable حساسة لحالة الأحرف؛ ولن تؤدي الأخطاء الإملائية إلى ظهور خطأ، ولكنها ستُرجع قيمة null.

س ما مدى الفرق في الأداء بين $lookup و populate؟
ج تُنجز $lookup العملية في استعلام واحد، بينما تتطلب populate N+1 استعلامًا. بالنسبة لعمليات الربط المعقدة، نوصي باستخدام $lookup.
س هل تدعم وظيفة $lookup العمليات عبر قواعد البيانات؟
ج يدعم MongoDB 4.0+ $unionWith العمليات عبر قواعد البيانات، لكن وظيفة $lookup تقتصر على قاعدة البيانات نفسها.
س كيف يمكنني حل مشكلات ضغط الذاكرة عند استخدام $lookup؟
ج اضبط allowDiskUse: true للسماح بالكتابة على القرص؛ وقم بمعالجة البيانات على دفعات (1,000 سجل لكل دفعة).

نظرة متعمقة على الأسئلة الشائعة: تسلط هذه الأسئلة الثلاثة الضوء على القيود الثلاثة لـ $lookup — وهي قيود الأداء (N+1 مقابل استعلام واحد)، وقيود النطاق (القيود داخل قاعدة البيانات نفسها)، وقيود الذاكرة (حد أقصى يبلغ 100 ميغابايت). إن فهم هذه القيود أهم من حفظ الإجابات — ففقط من خلال معرفة قيود $lookup يمكنك اتخاذ الخيارات الصحيحة أثناء تصميم المخطط: استخدم $unionWith للاستعلامات عبر قواعد البيانات، وتعامل مع مجموعات البيانات الضخمة جدًّا باستخدام المعالجة المقسمة، واستخدم populate لعمليات الربط البسيطة.


📖 ملخص

شبكة المعرفة: تعمل دالة $lookup كجسر بين «عمليات المجموعة الواحدة» و«عمليات الربط بين مجموعات متعددة» — ركزت الدروسان 14 و15 على عمليات التجميع داخل مجموعة واحدة، بينما يقدم هذا الدرس القدرة على إجراء عمليات الربط عبر المجموعات. يُعد الجمع بين $lookup و$unwind و$group النمط القياسي لاستعلامات المجموعات المتعددة في MongoDB، وهو ما يقابل JOIN + GROUP BY في لغة SQL. وبمجرد فهم هذا النمط، يمكنك تنفيذ معظم سيناريوهات استعلامات الجداول المتعددة في SQL داخل MongoDB.

مسار التعلم الخاص بوظيفة $lookup: مسار التعلم الموصى به لإتقان وظيفة $lookup — 1. ابدأ بتعلم تنسيق المطابقة القائم على المساواة (localField/foreignField)، وفهم دلالات LEFT JOIN ونتائج المصفوفات التي تُرجعها as؛ 2. تعلم $unwind، وإتقان التحويل من المصفوفات إلى الكائنات وخيار preserveNullAndEmptyArrays؛ 3. تعلم شكل خط الأنابيب لـ $lookup، وفهم تمرير المتغيرات عبر let و$$variable، والتصفية الشرطية في خطوط الأنابيب الفرعية؛ 4. تعلم استخدام $lookup المتداخل لإتقان تجميع البيانات من عمليات الربط متعددة المستويات؛ 5. تعلم استخدام $lookup + $group لإتقان أنماط إعادة التجميع بعد الربط؛ 6. المقارنة مع populate لفهم الاختلافات بين عمليات الربط على مستوى التطبيق مقابل عمليات الربط على مستوى قاعدة البيانات. لكل خطوة، نوصي باستخدام Compass Aggregation Pipeline Builder للتصحيح البصري — السحب والإفلات، وعرض النتائج الوسيطة، والتحقق من الصحة في الوقت الفعلي.

ملخص القرارات المعمارية الخاصة بالاستعلامات المدمجة: الاستعلامات المدمجة ليست مسألة تقنية بل معمارية — 1. التضمين أولاً: إذا كان حجم البيانات قابلاً للإدارة، وتكرار التحديث منخفضًا، وكانت البيانات تُقرأ دائمًا معًا، فإن التضمين هو الحل الأبسط (لا حاجة إلى $lookup)؛ 2. الإشارة + $lookup: عندما يكون حجم البيانات كبيرًا، وتكون هناك حاجة إلى تحديثات مستقلة، واستعلامات مستقلة، فإن الإشارة + $lookup هي الحل القياسي؛ 3. الإشارة + التعبئة: نهج طبقة التطبيق في Mongoose للتعامل مع العلاقات؛ سهل التنفيذ ولكن بأداء ضعيف (استعلامات N+1)، ومناسب لسيناريوهات التزامن المنخفض مثل الخلفيات الإدارية؛ 4. التكرار + الإشارة: تضمين البيانات الحيوية (لقطة) والإشارة إلى البيانات في الوقت الفعلي ($lookup) لتحقيق التوازن بين الأداء والاتساق. يكمن مفتاح اختيار الحل في فهم أنماط القراءة/الكتابة الخاصة بسيناريو العمل — فلا يوجد حل واحد يناسب الجميع، بل الحل الأنسب فقط.


📝 تمارين

نهج تصميم التمارين: تتراوح التمارين الأربعة من البسيطة إلى المعقدة — حيث تختبر التمارين الأساسية القواعد النحوية الأساسية لـ $lookup، وتختبر التمارين المتقدمة هياكل مسارات المعالجة والتصفية الشرطية، بينما تختبر التمارين الشاملة تنسيق مسارات المعالجة متعددة الخطوات باستخدام $lookup و$group. نوصي بتصحيح أخطاء مسارات المعالجة خطوة بخطوة في Compass أولاً، ثم كتابة الكود.

  1. السؤال الأساسي (⭐): استخدم دالة $lookup لربط جدولي «الطلبات» و«المستخدمين» والاستعلام عن معلومات المستخدم.
  2. المشكلة الأساسية (⭐): استخدم $unwind لتقسيم المصفوفة items الخاصة بأحد الطلبات.
  3. مشكلة متقدمة (⭐⭐): استخدم مسارًا يتألف من $lookup + التصفية الشرطية (المستخدمون النشطون فقط).
  4. تمرين متقدم (⭐⭐): استخدم طريقة populate من Mongoose لتنفيذ علاقة متعددة المستويات (الطلب → المستخدم → العنوان).
  5. التحدي (⭐⭐⭐): إعداد تقرير الطلبات (ربط الجداول الأربعة: الطلبات، والمستخدمين، والمنتجات، والعناوين).
Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%