فصل ۳: QuerySet حرفه‌ای — سریع، درست و بدون N+1

مسئله‌ی N+1: select_related، prefetch_related و django-debug-toolbar

صفحه‌ای که با ۲۰۱ کوئری باز می‌شود

مهم‌ترین مشکل کارایی در پروژه‌های جنگو N+1 است: یک کوئری برای فهرست و سپس برای هر ردیف یک کوئری دیگر برای رابطه‌اش. این کد بی‌گناه به نظر می‌رسد:

{% for order in orders %}
  <tr>
    <td>{{ order.tracking_code }}</td>
    <td>{{ order.customer.full_name }}</td>       {# هر بار یک کوئری #}
    <td>{{ order.items.count }}</td>               {# باز یک کوئری #}
  </tr>
{% endfor %}

با ۱۰۰ سفارش: ۱ + ۱۰۰ + ۱۰۰ = ۲۰۱ کوئری. روی لوکال با SQLite سریع به نظر می‌رسد؛ روی سرور با پایگاه داده‌ی شبکه‌ای، صفحه چند ثانیه طول می‌کشد.

راه‌حل‌ها

ابزاربرایروش
select_relatedForeignKey و OneToOne (رو به جلو)JOIN در همان کوئری
prefetch_relatedManyToMany و رابطه‌ی معکوسیک کوئری جدا با IN و اتصال در پایتون
annotate(Count)وقتی فقط تعداد یا جمع لازم استGROUP BY در همان کوئری
from django.db.models import Count, Prefetch

orders = (Order.objects
          .select_related("customer", "customer__user")
          .annotate(item_count=Count("items"))
          .prefetch_related(
              Prefetch("items",
                       queryset=OrderItem.objects.select_related("carpet").order_by("id"),
                       to_attr="item_list"))
          )[:50]
# در قالب: order.customer.full_name، order.item_count، و حلقه روی order.item_list
# مجموع: ۲ کوئری، مستقل از تعداد سفارش‌ها

django-debug-toolbar: اول ببینید، بعد بهینه کنید

pip install django-debug-toolbar
# settings (فقط توسعه)
if DEBUG:
    INSTALLED_APPS += ["debug_toolbar"]
    MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
    INTERNAL_IPS = ["127.0.0.1"]

# config/urls.py
from django.conf import settings
if settings.DEBUG:
    urlpatterns += [path("__debug__/", include("debug_toolbar.urls"))]

پنل SQL نوار ابزار تعداد کوئری‌ها، زمان هر کدام و مهم‌تر از همه «similar» و «duplicate» را نشان می‌دهد؛ ده کوئری مشابه یعنی یک N+1.

نکته‌هایی که کمتر کسی می‌داند

  • اگر روی رابطه‌ی prefetch‌شده دوباره .filter() یا .order_by() بزنید (order.items.filter(...))، کش prefetch دور ریخته می‌شود و کوئری تازه می‌رود؛ فیلتر را داخل Prefetch(queryset=...) بگذارید.
  • وقتی در Prefetch از to_attr استفاده می‌کنید، نتیجه فقط در همان ویژگی (item_list) است؛ order.items.all و order.items.count در قالب دوباره برای هر سفارش کوئری می‌زنند. در قالب فقط از item_list استفاده کنید.
  • در تست‌ها با self.assertNumQueries(2) تعداد کوئری را قفل کنید تا کسی بعداً بی‌صدا N+1 را برنگرداند.
  • debug-toolbar روی پاسخ‌های JSON و API ظاهر نمی‌شود؛ برای آن‌ها connection.queries (فقط با DEBUG=True) یا لاگر django.db.backends را در سطح DEBUG روشن کنید.
  • .iterator() از Django 4.1 با prefetch_related کار می‌کند به شرطی که chunk_size بدهید؛ برای خروجی اکسل از صدها هزار ردیف بدون پر شدن حافظه.

برای ذخیره‌ی پیشرفت و شرکت در آزمون، وارد شوید — رایگان است.