بهینه‌سازی جستجوهای رابطه‌ای


در جنگو، وقتی از ORM برای بازیابی داده‌ها استفاده می‌کنیم، اگر روابط بین مدل‌ها (مثل ForeignKey یا ManyToManyField) زیاد باشد، ممکن است تعداد زیادی query به پایگاه داده ارسال شود. این پدیده به عنوان "N+1 Query Problem" شناخته می‌شود.

برای جلوگیری از این اتفاق، جنگو دو ابزار قدرتمند در اختیار قرار داده است:

  • select_related برای روابط ForeignKey / OneToOneField

  • prefetch_related برای روابط ManyToMany / reverse ForeignKey


— مشکل N+1


فرض کنیم می‌خواهیم همه‌ی پروژه‌ها را به همراه نام مالک آن‌ها نمایش دهیم:

projects = Project.objects.all()
for project in projects:
    print(project.title, project.owner.username)

🔻 اتفاقی که می‌افتد:

  • ابتدا یک query برای گرفتن همه‌ی پروژه‌ها زده می‌شود.

SELECT * FROM project;
  • سپس برای هر پروژه، یک query جدا برای owner (کاربر مرتبط) اجرا می‌شود.

SELECT * FROM user WHERE id = 1;
SELECT * FROM user WHERE id = 2;
SELECT * FROM user WHERE id = 3;

اگر ۱۰۰ پروژه داشته باشیم ← ۱۰۰ + ۱ = ۱۰۱ Query به پایگاه داده ارسال می‌گردد و این N+1 problem نام دارد و عملکرد را به شدت کند می‌کند.

استفاده از select_related و یا prefetch_related، تعداد کوئری‌ها را به حداقل می‌رساند .

 

— استفاده از select_related


برای روابط ForeignKey یا OneToOneField 

projects = Project.objects.select_related('owner').all()
for project in projects:
    print(project.title, project.owner.username)

اکنون فقط یک کوئری به پایگاه داده زده می‌شود و جنگو داده‌های هر پروژه را به‌ همراه مالک آن، همزمان دریافت می‌کند. در حقیقیت جنگو، با JOIN داده‌ها در SQL، داده‌های Proejct و User را همزمان واکشی می‌کند. و زمانی که project.owner.username را صدا می‌زنیم، دیگر نیازی به کوئری جدید نیست — چون داده‌ها از قبل preload شده‌اند.

استفاده از select_related، یکی از ضروری‌ترین تکنیک‌های بهینه‌سازی در جنگو است. در پروژه‌های واقعی، نادیده گرفتن آن می‌تواند منجر به کاهش شدید عملکرد و تجربه‌ی کاربری ضعیف شود. همیشه هنگام دسترسی به فیلدهای مدل‌های مرتبط، می‌بایست بررسی گردد که آیا می‌توان از select_related برای ارتباط‌های یک‌به‌چند و یک‌به‌یک و یا prefetch_related برای ارتباط‌های چندبه‌چند به منظور کاهش تعداد کوئری‌ها استفاده نمود.

 

— استفاده از prefetch_related


برای روابط ManyToMany یا reverse ForeignKey 

projects = Project.objects.prefetch_related('tags').all()
for project in projects:
    print(project.title, [tag.name for tag in project.tags.all()])

در این حالت جنگو دو Query اجرا می‌کند  و برای هر پروژه، لیست تگ‌های مربوط به آن را از داده‌های پیش‌بارگذاری‌شده استخراج می‌کند.

  1. یک کوئری برای واکشی تمام پروژه‌ها.

  2. یک کوئری دیگر برای واکشی تمام تگ‌های مرتبط با آن پروژه‌ها در یک مرحله.

با استفاده از prefetch_related، جنگو به‌صورت هوشمندانه در سطح پایتون، داده‌های واکشی‌شده را با هم تطبیق (match) می‌دهد و نتایج را در حافظه (Cache) ذخیره می‌کند تا به هنگام دسترسی به project.tags.all()، دیگر نیازی به کوئری جدید نباشد. در حقیقت جنگو، ابتدا مدل اصلی را fetch کرده، سپس یک کوئری جداگانه برای مدل مرتبط اجرا می‌کند و نتایج را در پایتون ترکیب می‌کند و در این مسیر از IN lookup برای محدود کردن نتایج استفاده می‌کند.

 

 — ترکیب select_related و prefetch_related


در پروژه‌های واقعی، معمولاً نیاز داریم همزمان با مدل‌های مرتبط یک‌به‌چند (ForeignKey) و روابط چند‌به‌چند (ManyToManyField) کار کنیم. در چنین شرایطی، ترکیب هوشمندانه select_related و prefetch_related بهترین راه‌حل برای جلوگیری از N+1 Query Problem است.

 

projects = Project.objects.select_related('owner').prefetch_related('tags')
for project in projects:
    print(f"{project.title} | {project.owner.username} | {[tag.name for tag in project.tags.all()]}")

📊 این ترکیب:

  • select_related ← فقط برای owner (ForeignKey)

  • prefetch_related ← برای tags (ManyToMany)

با این ترکیب، جنگو فقط سه کوئری به پایگاه داده ارسال می‌کند.

  1. یک JOIN بین داده‌های مدل‌های Project و User ← برای واکشی همزمان پروژه‌ها و اطلاعات مالک (owner) آن‌ها (با استفاده از select_related).
  2. یک کوئری برای واکشی idهای پروژه‌ها (در عمل، این بخش در کوئری اول گنجانده می‌شود).
  3. یک کوئری جداگانه برای واکشی تگ‌های مرتبط ← شامل دو جدول:
    • جدول میانی (مثلا project_tags) که ارتباط بین پروژه و تگ را نگه می‌دارد
    • جدول Tag برای گرفتن نام و سایر فیلدهای تگ‌ها ← این کوئری از WHERE tag_id IN (...) استفاده می‌کند تا فقط تگ‌های مرتبط با پروژه‌های انتخاب‌شده را بگیرد (با استفاده از prefetch_related).

در نتیجه، هیچ کوئری اضافی‌ در حلقه اجرا نمی‌شود — حتی اگر صدها پروژه و هزاران تگ وجود داشته باشد!

— اگر فقط از select_related استفاده می‌کردیم، دسترسی به project.tags.all() باعث ارسال یک کوئری جداگانه برای هر پروژه می‌شد.

— و اگر فقط از prefetch_related استفاده می‌کردیم، دسترسی به project.owner.username نیز همین مشکل را ایجاد می‌کرد.


ترتیب فراخوانی مهم نیست

💡 پیشنهاد: اگر نیاز به فیلتر یا مرتب‌سازی خاصی روی tags وجود داشته باشد، می‌توان از کلاس Prefetch استفاده نمود.

projects = Project.objects.select_related('owner').prefetch_related(
    Prefetch('tags', queryset=Tag.objects.filter(is_active=True).order_by('name'))
)