N+1 Query Problem: لما dashboard بسيط بياخد 8 ثواني
لو فتحت صفحة "آخر 50 طلب" في تطبيقك ولاحظت إنها بتاخد 8 ثواني، الـ DB مش بطيئة. أنت بترسل 51 query للسيرفر بدل query واحد. المقال ده هيوريك ليه ده بيحصل بدون ما تحس، وإزاي تحلّه بسطر واحد في الـ ORM، وامتى الحل ده نفسه بيكون كارثة.
مثال بسيط قبل ما نشرح المصطلح
تخيّل إنك في مطعم وعندك ورقة فيها 50 طبق. سألت النادل عن سعر الطبق الأول. النادل قام، راح المطبخ، رجعلك بالسعر. سألته عن الطبق الثاني، نفس الرحلة. لو كرّرتها 50 مرة، النادل هيمشي 50 رحلة. لو طلبت منه السعر بتاع كل الـ 50 طبق دفعة واحدة، هيمشي رحلة واحدة بس ويرجع بالقائمة كلها. ده بالظبط الفرق.
في الكود، النادل = قاعدة البيانات. الرحلة = الـ query. لما تجيب 50 user وكل user تطلب الـ orders بتاعته على حدة، أنت عملت 51 query (واحد للـ users + 50 للـ orders).
الكود اللي بيخلق المشكلة بدون ما تحس
الكود ده طبيعي جداً وبتلاقيه في كل مشروع. خد بالك من البساطة:
# Django مثلاً
users = User.objects.filter(active=True)[:50]
for user in users:
print(user.name, user.orders.count())
# 1 query للـ users + 50 query صغير للـ orders = 51 queries
السطر user.orders ده ساحر. هو شكله بريء جداً، بس وراه query كامل بيتبعت للـ DB. أنت مش شايف الـ SQL، فمش بتحس إنك بتعمل 50 رحلة round-trip للسيرفر.
ليه ده بيحصل أصلاً؟ المفهوم العلمي
الـ ORM (Django, Rails, Laravel, Prisma) بيشتغل بنمط اسمه Lazy Loading: يعني العلاقة (relation) متجابش من الـ DB غير لما الكود يطلبها فعلاً. ده بيوفر بايتات لما العلاقة مش محتاجة، بس بيخلق مشكلة N+1 لما تكون محتاجها لكل صف.
تعريف N+1 بدقّة: query أصلي واحد بيرجّع N صف (الـ 1)، وبعدها N query إضافي علشان تجيب علاقة لكل صف. الـ "N+1" مش رقم سحري، هو وصف للنمط نفسه: 1 + N.
الحل الرسمي اسمه Eager Loading: تحميل العلاقات في query واحد إضافي بدل ما تستنى الكود يطلبها واحدة واحدة.
السيناريو الواقعي: dashboard أدمن
عندك صفحة admin بتعرض 200 user، كل user معاه 3 علاقات (orders, payments, addresses). أنت بتعمل 1 + (200 × 3) = 601 query. لو كل query بياخد 12ms متوسط (شبكة + planning + execution)، الصفحة بتاخد 601 × 12 = 7,212ms. يعني 7.2 ثانية على dashboard مفروض يفتح في أقل من ثانية.