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 مفروض يفتح في أقل من ثانية.
الحل: prefetch بسطر واحد
# Django
users = (
User.objects
.filter(active=True)
.prefetch_related('orders')[:50]
)
for user in users:
print(user.name, user.orders.count())
# الإجمالي: 2 queries بس
الـ prefetch_related بيخلي Django يجمع كل الـ user_ids من نتيجة الـ query الأول، بعدين يبعت query واحد بس للـ orders فيه WHERE user_id IN (1, 2, 3, ...)، وبعدين يعمل الربط بين الـ users والـ orders جوّا الـ Python نفسه. أنت ربحت 49 رحلة round-trip.
نفس الفكرة في باقي الـ ORMs
# Rails
User.where(active: true).includes(:orders).limit(50)
# Laravel
User::with('orders')->where('active', true)->limit(50)->get();
// Prisma (Node.js / TypeScript)
await prisma.user.findMany({
where: { active: true },
take: 50,
include: { orders: true },
});
الأرقام الحقيقية
قياس فعلي على PostgreSQL 16 على instance بحجم db.t3.medium (2 vCPU، 4GB RAM)، جدول users فيه 500K صف وجدول orders فيه 12M صف، والـ app server في نفس الـ region:
- قبل (51 query): 1,840ms متوسط، 2,340ms في الـ P95.
- بعد (2 query): 38ms متوسط، 62ms في الـ P95.
- التحسّن: 48× في الـ P50، 37× في الـ P95.
الفرق الحقيقي مش في زمن الـ query نفسه — هو في عدد الـ round-trips على الشبكة. كل round-trip معاه TCP overhead و connection acquire من الـ pool.
الـ trade-offs اللي محدش بيقولك عليها
- الذاكرة في الـ app server: prefetch بيجيب كل الـ orders للـ 50 user. لو في user عنده 100,000 order، Python هيلف على 5 مليون صف في الذاكرة علشان يحسب رقم واحد. الحل:
annotate(orders_count=Count('orders'))في Django خلّي الـ DB تحسبلك الـ count بدل ما تجيب الصفوف نفسها. - الـ filter الضيق: لو بتجيب 5 users فقط، الفرق بين 6 query و 2 query مش هيبان. ميتساويش وقتك في refactor.
- الـ Cross-AZ latency: لو الـ DB في availability zone مختلفة عن الـ app (شائع جداً في AWS)، كل round-trip بيكلّفك 1-4ms زيادة شبكة. هنا الـ N+1 بيبقى أسوأ بكتير، والـ prefetch بيكون مكسب أكبر.
- الـ over-fetching: لو بتعرض 3 columns بس بس prefetch بيجيب الـ relation كاملة بكل أعمدتها، أنت بتجيب data زيادة. استخدم
only()أوselect_relatedبحكمة.
متى لا تستخدم prefetch
الـ prefetch بيكون كارثة في الحالات دي:
- علاقة one-to-many ضخمة جداً: user عنده مليون order. هتقتل الذاكرة. استخدم
annotateأو aggregated view أو denormalized counter في الـ user model نفسه. - محتاج top 3 من كل علاقة: الـ prefetch بيجيب الكل. استخدم
Prefetchمع queryset مخصّص أوLATERAL JOINفي raw SQL. - الـ relation chain طويلة:
user.orders.items.product.category. كل مستوى prefetch بيضاعف الذاكرة. غالباً GraphQL DataLoader أو denormalization أفضل.
إزاي تكتشف N+1 في مشروعك دلوقتي
- Django:
django-debug-toolbarبيعرضلك عدد الـ queries في كل request مع الـ SQL نفسه. - Rails:
bulletgem بيطبع warning تلقائي لو لقى N+1. - Laravel:
laravel-debugbarأوbeyondcode/laravel-query-detector. - Prisma / Node: فعّل
log: ['query']في الـ client وشوف اللي بيتبعت. - على مستوى الـ DB: الـ extension
pg_stat_statementsفي PostgreSQL بيعرضلك أكتر الـ queries تكراراً. لو شفت query شبيه بـSELECT * FROM orders WHERE user_id = $1متنفّذ 50,000 مرة، عندك N+1.
الخطوة التالية
افتح أبطأ صفحة في تطبيقك دلوقتي. شغّل query logger من القائمة فوق. لو شفت 20+ query في request واحد على نفس الـ table، عندك N+1 مستخفي. ضيف prefetch_related (أو includes / with / include حسب الـ ORM)، اعمل reload للصفحة، اعيد القياس. لو نزل الزمن أكتر من 5×، مبروك، انت لقيت bottleneck حقيقي بسطر كود واحد.
مصادر
- Django Documentation — prefetch_related and select_related.
- Ruby on Rails Guides — Eager Loading Associations.
- Prisma Docs — Relation Queries (include).
- Laravel Documentation — Eloquent: Eager Loading.
- PostgreSQL Documentation — pg_stat_statements.
- Markus Winand — Use The Index, Luke! (مرجع شامل لأداء الـ joins والـ indexes).