لو الـ Aggregation Pipeline في MongoDB بياخد 5 ثواني على collection فيها 2 مليون مستند، المشكلة مش في حجم الداتا غالبًا — المشكلة في ترتيب الـ stages.
المشكلة باختصار
الـ Aggregation Pipeline بيشتغل خطوة بعد خطوة. كل stage بياخد المخرج من اللي قبله. لو بدأت بـ $lookup قبل $match، أنت بتعمل join على كل المستندات، وبعدين بتفلتر. النتيجة: 95% من الشغل ده مرمي في الزبالة، والـ DB ضربها CPU 100%.
الافتراض هنا: عندك collection بحجم متوسط لكبير (≥ 500K مستند)، و pipeline فيه $lookup أو $group أو $unwind.
ركّز: الـ Aggregation Pipeline بيشتغل إزاي فعلاً
مثال للمبتدئين قبل الكلام التقني
تخيّل عندك صندوق فيه 1000 كتاب. طلبت منك مديرتك حاجة من اتنين:
- الطريقة الأولى: "روح اكتشف اسم الكاتب لكل كتاب من الـ 1000، وبعدين قولّي مين منهم كاتبه أحمد." يعني 1000 عملية بحث، ثم فلترة.
- الطريقة التانية: "اطلع الأول الكتب اللي على غلافها اسم أحمد، لقيت 30 كتاب، وبعدين روح هات تفاصيل الكاتب لكل واحد فيهم." يعني 30 عملية بحث فقط.
الفرق بين الاتنين تقريبًا 33×. ده بالظبط الفرق بين $lookup قبل $match، و $lookup بعد $match في MongoDB.
التعريف العلمي الدقيق
الـ Aggregation Pipeline في MongoDB سلسلة من المراحل (stages)، كل مرحلة بتاخد مجموعة مستندات كدخل وتطلع مجموعة مستندات كخرج. ترتيب الـ stages بيحدد عدد المستندات اللي بيدخل كل stage. الـ MongoDB query optimizer بيعمل بعض إعادة الترتيب التلقائية (مثل دمج $match متتاليين، أو نقل $match قبل $project لو الحقول مستقلة)، لكنه مش بيعكس الترتيب لمّا يكون فيه dependency حقيقي بين stages — المسؤولية عليك.
الحل: رتّب الـ stages بهذا الترتيب
القاعدة الذهبية: قلّل عدد المستندات قبل كل عملية مكلفة. الترتيب الموصى به:
$match— فلترة بالـ index قدر المستطاع، أول حاجة في الـ pipeline.$project— شيل الحقول اللي مش هتستخدمها، يقلل الذاكرة و IO.$sort+$limit— لو محتاج أعلى N، طبّقهم قبل أي join.$lookup— الجوين بيتنفّذ على عدد مستندات أقل بكثير دلوقتي.$unwindو$group— في النهاية على الناتج المفلتر.
مثال تنفيذي بأرقام حقيقية
السيناريو: متجر إلكتروني فيه 2 مليون order و 500K user. المطلوب: آخر 100 طلب مكتمل لمستخدمين من مصر.
الـ pipeline البطيء (قياس فعلي: 5.21 ثانية):
db.orders.aggregate([
{ $lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}},
{ $match: {
status: "completed",
"user.country": "EG"
}},
{ $sort: { createdAt: -1 } },
{ $limit: 100 }
])
// totalDocsExamined: 2,000,000
// executionTimeMillis: 5210الـ pipeline بعد إعادة الترتيب (قياس فعلي: 180 مللي ثانية):
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $sort: { createdAt: -1 } },
{ $limit: 500 },
{ $lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user",
pipeline: [
{ $match: { country: "EG" } },
{ $project: { _id: 1, country: 1, name: 1 } }
]
}},
{ $match: { "user.0": { $exists: true } } },
{ $limit: 100 }
])
// totalDocsExamined: 18,400
// executionTimeMillis: 180وضّبت الـ index ده مرة واحدة (فرق ضخم في زمن الـ $sort):
db.orders.createIndex(
{ status: 1, createdAt: -1 },
{ name: "status_createdAt_idx" }
)للتحقق من إن الـ optimizer شغّال صح:
db.orders.explain("executionStats").aggregate([ /* الـ pipeline */ ])دور على totalDocsExamined و executionTimeMillis. لو totalDocsExamined أكبر من 10× عدد المخرجات، الترتيب بتاعك لسه فيه مشكلة.
الـ trade-offs اللي لازم تعرفها
- $limit مبكر بيوفر سرعة، لكن فيه خطر: لو فلترت لـ 500 طلب ثم الـ
$lookupاستبعد 90% منهم، ممكن تطلع بـ 50 نتيجة بدل 100. الحل: زوّد الـ limit الأول لـ 1000–2000 حسب نسبة المطابقة المتوقعة. - pipeline داخل $lookup يحتاج MongoDB 5.0+. لو لسه على 4.x، الفلترة على حقول الـ user هتحصل بعد الجوين، وده بيقلل المكسب.
- $project بيقلل الذاكرة لكن بيخسر الحقول: لو محتاج حقل لاحقًا في stage تاني، استخدم
$addFieldsبدل$projectالحصري. - الـ compound index ضروري للـ $sort بعد $match. بدون index مركّب على (status, createdAt)، الـ
$sortهيعمل in-memory sort وبيفشل بعد 100MB افتراضيًا.
متى لا تستخدم هذه الطريقة
- لو الـ collection أصغر من 100K مستند، الفرق هيكون أقل من 50ms — التعقيد مش يستاهل.
- لو شرط الفلترة الحقيقي على حقل محسوب بعد الـ join (مثل
$exprبيقارن بين order و user)، إعادة الترتيب ممكن تكسر المنطق. هنا استخدم$lookupبـlet+ sub-pipeline بدل تأخير الفلترة. - لو محتاج
$facet(تشغيل أكتر من pipeline متوازي على نفس المدخل)، الترتيب الداخلي بيختلف لكل branch — راجع كل واحدة على حدة. - لو الكوليكشن sharded والـ shard key مش في أول الـ
$match، الـ pipeline هيتنفّذ على كل الـ shards وممكن الترتيب لوحده ميحلش المشكلة.
الخطوة التالية
افتح أبطأ aggregation عندك دلوقتي وشغّل عليه .explain("executionStats"). لو totalDocsExamined أكبر من 10× المخرجات، انقل أول $match لأول الـ pipeline وضيف index على حقول الفلترة. لو التحسّن طلع أقل من 5×، الـ bottleneck غالبًا في الـ $lookup نفسه — جرّب $lookup بـ sub-pipeline على Mongo 5.0+، أو راجع denormalization جزئي للحقول الأكثر استعلامًا.
مصادر
- MongoDB Manual — Aggregation Pipeline Optimization
- MongoDB Manual — $lookup (with sub-pipeline)
- MongoDB Manual — db.collection.explain()
- MongoDB Manual — Compound Indexes
- MongoDB Manual — $match Stage