مستوى المقال: متوسط. لو بتشتغل على تطبيق فيه ORM (Django أو Rails أو Laravel أو Prisma) وصفحة بتبطّأ فجأة أول ما البيانات تكبر، المقال ده يخصّك.
مشكلة N+1: ليه صفحة واحدة تضرب قاعدة بياناتك بمئات الاستعلامات
هتطلع من هنا عارف تكتشف مشكلة N+1 في دقيقتين، وتصلّحها بسطر واحد ينزّل من 201 استعلام لـ2. ركز في المثال الأول، بعدين تعالَ للكود.
المشكلة باختصار
عندك جدول مقالات، وكل مقال ليه كاتب. بتعرض 200 مقال في صفحة، وجنب كل واحد اسم الكاتب. الكود شغّال تمام على جهازك بـ10 صفوف. بتنشر، البيانات تكبر، والصفحة تاخد تلت ثانية بس على الاستعلامات. السبب مش السيرفر — السبب إنك بتعمل استعلام لكل كاتب على حدة.
مثال يقرّبها: النادل اللي بيروح المطبخ 200 مرة
تخيّل مطعم فيه 200 طاولة. النادل بياخد أوردر السلطة من كل طاولة، وبدل ما يجمّع الطلبات ويروح المطبخ مرة واحدة، بيروح ويرجع لكل طاولة لوحدها. يبقى 200 رحلة رايح جاي. الأكل نفسه اتعمل بسرعة، بس المشي بين الصالة والمطبخ هو اللي بلع الوقت.
ده بالظبط اللي بيحصل في N+1: كل "رحلة" هي استعلام (round-trip) لقاعدة البيانات. الاستعلام نفسه سريع، لكن عدد الرحلات هو المشكلة.
الشرح العلمي: منين جت الـ N+1
الاسم معناه: استعلام واحد بيجيب قائمة المقالات (ده الـ 1)، وبعدين لكل مقال بيتعمل استعلام تاني يجيب الكاتب (دي الـ N). عندك 200 مقال؟ يبقى 1 + 200 = 201 استعلام. كل استعلام فيه زمن ذهاب وإياب للشبكة بينك وبين الداتابيز؛ حتى لو 1.5 مللي ثانية للواحد، 201 استعلام يعني تأخير تراكمي واضح، وبيزيد خطّيًا كل ما البيانات تكبر.
الافتراض اللي بيخلّي ده يحصل إن الـ ORM بيحمّل العلاقة "كسول" (lazy loading): مبيجيبش الكاتب إلا لحظة ما تطلبه جوّه اللوب.
الحل: eager loading بسطر واحد
الفكرة إنك تقول للـ ORM: هات المقالات والكتّاب في استعلام أو اتنين بس، مش 201.
# المشكلة: N+1
posts = Post.objects.all() # استعلام 1
for p in posts:
print(p.author.name) # استعلام لكل مقال (N)
# الحل: JOIN في استعلام واحد
posts = Post.objects.select_related("author") # الإجمالي: استعلام واحد
for p in posts:
print(p.author.name) # من غير أي استعلام إضافي
# لعلاقة متعددة (tags): استعلامين بدل N
posts = Post.objects.prefetch_related("tags")
في Django: select_related بيعمل JOIN، وده مناسب لعلاقة foreign key واحدة. وprefetch_related بيعمل استعلام تاني بـ IN (...)، وده مناسب للعلاقات المتعددة. المكافئ في Rails هو includes، في SQLAlchemy joinedload وselectinload، وفي Prisma include.
الأرقام: قبل وبعد
على 200 مقال: قبل = 201 استعلام. بعد select_related = استعلام واحد. لو كل round-trip حوالي 1.5 مللي ثانية، بتنزل من حوالي 300 مللي ثانية في الاستعلامات لوحدها لأقل من 5. المكسب مش في سرعة الاستعلام نفسه، لكن في عدد الرحلات — تقريبًا 100 ضعف round-trips أقل.
الـ trade-off هنا
مفيش حاجة ببلاش. select_related بيعمل JOIN، فبيكرّر أعمدة الكاتب مع كل مقال وممكن يكبّر حجم النتيجة المنقولة. prefetch_related بيجيب البيانات ويعمل الربط في بايثون، فبتكسب استعلامات أقل بس بتستهلك ذاكرة أكتر على السيرفر. يعني بتكسب سرعة، بتخسر شوية RAM أو bandwidth. ولو الكاتب فيه أعمدة كبيرة زي bio طويل، الـ JOIN ممكن يبقى مكلف؛ ساعتها حدّد الأعمدة اللي محتاجها بـ only().
متى لا تستخدم هذه الطريقة
لو أصلاً بتعرض عنصر واحد أو عناصر قليلة (5 مثلاً)، الـ N صغير وملوش لازمة تعقّد. ولو مش محتاج العلاقة في الصفحة أصلاً، متجيبهاش — أفضل استعلام هو اللي مبتعملوش. وممنوع تعمل prefetch لكل العلاقات "احتياطي"؛ ده بيجيب بيانات مش محتاجها ويقلب المشكلة لاستهلاك ذاكرة.
الخطوة التالية
افتح أبطأ صفحة عندك وشغّل عدّاد استعلامات: Django Debug Toolbar، أو لوج استعلامات SQLAlchemy، أو مكتبة Bullet في Rails. لو لقيت نفس الاستعلام بيتكرر بأرقام id مختلفة، دي N+1. ضيف select_related أو includes على العلاقة دي وقيس الفرق. لو عدد الاستعلامات نزل والصفحة اتسرّعت، المشكلة كانت هي بالظبط.
المصادر
- Django — توثيق select_related و prefetch_related الرسمي: docs.djangoproject.com (QuerySet API reference).
- SQLAlchemy — Relationship Loading Techniques: docs.sqlalchemy.org.
- Ruby on Rails Guides — Active Record Query Interface / Eager Loading Associations: guides.rubyonrails.org.
- Prisma — Relation queries (include): prisma.io/docs.
- مكتبة Bullet لاكتشاف N+1 في Rails: github.com/flyerhzm/bullet.