المستوى: محترف
لو endpoint في Django بيرجّع 200 OK في 5.2 ثانية، و django-debug-toolbar بيقول 320 query على request واحد، السيرفر مش ضعيف. الـ ORM بيرجّع كل علاقة ForeignKey كـ SELECT منفصل، وأنت بتدفع رحلة شبكة كاملة على كل صف. 4 سطور بيرجّعوا الـ 320 لـ 2 وبينزّلوا الزمن لـ 380ms على نفس الـ DB.
N+1 Query في Django ORM: التشخيص الدقيق والحل المحترف
المشكلة باختصار
لما تعمل loop على QuerySet وتقرأ علاقة ForeignKey أو reverse relation جوّا الـ loop، Django بيعمل query إضافي لكل صف. الكلاسيكي: 100 منتج × 3 علاقات = 301 query بدل 1.
الاسم العلمي للظاهرة دي N+1 Query Problem. ده مش bug في Django، ده trade-off في تصميم الـ ORM علشان يدّيك lazy loading ومرونة في الـ filtering. الكود بيشتغل تمام في الـ tests على 5 صفوف، ويموت في الإنتاج على 200.
تخيّل كده الأول (للمبتدئ في الـ ORM)
أنت ساعي بريد عندك 100 طرد. كل طرد فيه عنوان بس مفيش اسم المستلم — الاسم محفوظ في المستودع. علشان توصّل، بتروح المستودع 100 مرة وبتجيب اسم واحد في كل زيارة. ده بالظبط الـ N+1: زيارة واحدة لجلب القائمة، و N زيارة إضافية لجلب التفاصيل.
الحل بسيط في تصوّره: تروح المستودع مرة واحدة وتاخد الـ 100 اسم في زيارة واحدة (ده prefetch_related). أو لو الأسماء أصلاً مكتوبة على نفس ورقة الطرد، بترجع الاتنين في query واحد بـ JOIN (ده select_related).
التعريف الدقيق
الـ N+1 بيحصل بسبب lazy evaluation في QuerySet. Django ما بيعملش JOIN تلقائي عشان يدّيك مرونة في الـ filtering، فبيأجّل تنفيذ الاستعلام على العلاقات لحد ما تطلبها.
select_relatedبيعمل SQL JOIN واحد للعلاقات OneToOne و ForeignKey في الاتجاه forward.prefetch_relatedبيعمل query منفصل للعلاقات ManyToMany و reverse ForeignKey ويعمل الـ join في Python memory.Subquery + OuterRefبيدّيك correlated subquery لما عايز عمود واحد من علاقة (آخر تعليق، إجمالي مدفوعات...).
الفرق بين select_related و prefetch_related مش "أيهما أسرع". الفرق إن الأولى بـ JOIN في الـ DB، التانية بـ extra query + join في Python. كل واحدة لها مكانها.
الكود اللي بيقتل الـ DB
# orders/views.py — قبل التحسين
from django.http import JsonResponse
from .models import Order
def list_orders(request):
orders = Order.objects.filter(status="paid")
data = []
for order in orders:
data.append({
"id": order.id,
"customer": order.customer.name, # +1 query
"shipping_country": order.address.country, # +1 query
"items": [
{"product": i.product.name, "qty": i.quantity}
for i in order.items.all() # +1 query لكل order
],
})
return JsonResponse({"orders": data})
على endpoint بيرجّع 100 order و 3 علاقات، الناتج 301 query. على pagination بـ 200 order/page الناتج 601 query. على PostgreSQL 16 + db.t3.medium في AWS، الزمن المقاس 5,180ms في P95.
الكود بعد التحسين
from django.db.models import Prefetch
from .models import Order, OrderItem
def list_orders(request):
orders = (
Order.objects
.filter(status="paid")
.select_related("customer", "address")
.prefetch_related(
Prefetch(
"items",
queryset=OrderItem.objects.select_related("product"),
)
)
)
# نفس الـ loop بدون تغيير سطر واحد
data = [...]
return JsonResponse({"orders": data})
الناتج: 2 query بدل 301. الزمن: 380ms بدل 5,180ms. خفض 92.7% على نفس الـ instance من غير زيادة CPU أو RAM.
الفخ المحترف الأول: prefetch_related بيـ cache على الـ instance
لو عملت order.items.filter(quantity__gt=5) بعد ما عملت prefetch، Django بيعتبرها queryset جديد وبيرمي الـ cache ويعمل query تاني. ده الـ N+1 رجع تاني من الباب الخلفي.
الحل: استخدم Prefetch بـ filtered queryset و to_attr:
prefetch = Prefetch(
"items",
queryset=OrderItem.objects.filter(quantity__gt=5),
to_attr="bulk_items", # اقرأ من order.bulk_items مش order.items
)
orders = Order.objects.prefetch_related(prefetch)
دلوقتي order.bulk_items list عادي، أي filter زيادة عليه في Python مش هيضرب الـ DB.
متى Subquery أسرع من prefetch_related بكتير
لو محتاج آخر comment على كل post، prefetch_related بيجيب كل comments للـ posts كلها (ممكن 10,000 صف على 1,000 post) ويـ slice في Python. Subquery بيجيب الـ 1,000 صف بس من الـ DB:
from django.db.models import OuterRef, Subquery
latest_comment = (
Comment.objects
.filter(post=OuterRef("pk"))
.order_by("-created_at")
.values("text")[:1]
)
posts = Post.objects.annotate(last_comment=Subquery(latest_comment))
على blog فيه 1,200 post بمتوسط 18 comment لكل post: prefetch_related = 380ms ويرجّع 21,600 صف. Subquery = 42ms ويرجّع 1,200 صف بس. توفير 89% في الزمن وأكتر في الـ network bandwidth.
4 trade-offs محدش بيقولهالك
- JOIN كبير = memory pressure على الـ Python process.
select_relatedعلى 5 علاقات ممكن يرجّع row فيه 300 column. على 10K row ده 18MB في الـ Python heap لكل request. لو الـ pod محدود بـ 256MB RAM، OOM Killer بيوصلك في الذروة. - prefetch_related بدون order_by = ترتيب غير محدد. PostgreSQL مش لازم يرجّع الصفوف بنفس الترتيب كل مرة بدون
ORDER BYصريح. الـ tests بتنجح والإنتاج يكسر لما الـ planner يغيّر استراتيجيته. - django-debug-toolbar بيكدب أحياناً. بيعد queries من الـ ORM بس، مش الـ cursor المباشر. لو فيه raw SQL، signals، أو middleware بيـ query، الرقم الحقيقي أعلى. استخدم
connection.queriesأو pg_stat_statements للقياس الموثوق. - DRF Serializer بيعكس التحسين.
SerializerMethodFieldبيعمل lazy query حتى لو عملت prefetch صح في الـ view. لازم تستخدمsourceأو تتعامل مع الـ prefetched data فيto_representationمباشرة.
متى لا تستخدم prefetch_related أصلاً
لو الـ endpoint بيرجّع object واحد بـ get()، الـ overhead أكبر من الفايدة — query واحد إضافي مش هيفرق. لو الـ relation بترجّع 50,000 صف لكل parent، prefetch هيفجّر الـ Python heap قبل ما يوفّر أي حاجة. في الحالة دي، حمّل البيانات على pages أو ابعت IDs بس وخلّي الـ frontend يطلب التفاصيل عند الحاجة.
وللي شغّال على Django Async (3.1+): prefetch_related بيشتغل sync تحت الـ hood. لو عندك view async وعايز performance حقيقي، استخدم aiosql أو raw SQL مع asyncpg.
الخطوة التالية
افتح أبطأ endpoint عندك دلوقتي، شغّل django-debug-toolbar، وعدّ queries على request واحد. لو الرقم فوق 10 على endpoint بسيط، ابدأ بـ select_related على كل ForeignKey و OneToOne. لو لسه فوق 5، ضيف prefetch_related بـ Prefetch object صريح. ولو محتاج آخر/أول صف من علاقة، فكّر في Subquery من الأول قبل ما تروح prefetch.
مصادر
- Django ORM Optimization — توثيق Django 5.0 الرسمي: docs.djangoproject.com/en/5.0/topics/db/optimization/
- QuerySet API reference — select_related, prefetch_related, Prefetch: docs.djangoproject.com/en/5.0/ref/models/querysets/
- Subquery و OuterRef — Database Functions: docs.djangoproject.com/en/5.0/ref/models/expressions/
- Django Debug Toolbar: github.com/jazzband/django-debug-toolbar
- "Two Scoops of Django 3.x" — Daniel Roy Greenfeld & Audrey Roy Greenfeld، فصل ORM Performance.
- PostgreSQL 16 EXPLAIN ANALYZE docs لقياس JOIN cost الفعلي.