N+1 Query در Django چیست؟ چرا پروژه شما ناگهان کند میشود؟
یکی از مشکلاتی که تقریباً هر Django Developer ممکن است دیر یا زود با آن روبهرو شود، مشکلی به نام N+1 Query است.
این مشکل در پروژههای کوچک شاید اصلاً به چشم نیاید، اما وقتی تعداد کاربران، دادهها و درخواستهای سایت افزایش پیدا میکند، میتواند باعث افزایش شدید تعداد Queryهای دیتابیس، کند شدن صفحات و حتی بالا رفتن مصرف منابع سرور شود.
نکته جالب اینجاست که کد شما ممکن است کاملاً درست به نظر برسد.
نه خطایی در Django دریافت میکنید، نه Exception خاصی رخ میدهد و نه حتی نتیجه اشتباهی نمایش داده میشود.
همه چیز درست کار میکند؛ فقط پشت صحنه، دیتابیس ممکن است صدها بار بیشتر از چیزی که انتظار دارید Query دریافت کند.
در این مقاله قرار است N+1 Query را از پایه بررسی کنیم، ببینیم چرا اتفاق میافتد، چطور آن را تشخیص دهیم و مهمتر از همه، چطور با ابزارهای خود Django آن را برطرف کنیم.
N+1 Query دقیقاً چیست؟
فرض کنید یک فروشگاه اینترنتی با Django ساختهایم.
در این فروشگاه، هر محصول متعلق به یک دستهبندی است.
مدلهای ما چیزی شبیه این هستند:
from django.db import models
class Category(models.Model):
name = models.CharField(max_length=100)
class Product(models.Model):
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
category = models.ForeignKey(
Category,
on_delete=models.CASCADE
)
حالا فرض کنید میخواهیم لیستی از محصولات را نمایش دهیم و کنار هر محصول، نام دستهبندی آن را هم نشان دهیم.
ممکن است View را خیلی ساده بنویسیم:
def product_list(request):
products = Product.objects.all()
return render(
request,
"products/list.html",
{"products": products}
)
و در Template:
{% for product in products %}
<h2>{{ product.name }}</h2>
<p>{{ product.category.name }}</p>
{% endfor %}
در نگاه اول هیچ مشکلی وجود ندارد.
اما اینجا ممکن است یک مشکل Performance جدی ایجاد شود.
چه اتفاقی پشت صحنه میافتد؟
فرض کنید در دیتابیس 100 محصول داریم.
این Query را اجرا کردهایم:
Product.objects.all()
در نتیجه Django برای دریافت محصولات یک Query به دیتابیس ارسال میکند.
پس فعلاً:
1 Query
اما وقتی داخل Template به این قسمت میرسیم:
{{ product.category.name }}
Django باید اطلاعات Category مربوط به هر Product را هم از دیتابیس دریافت کند.
اگر 100 محصول داشته باشیم، ممکن است برای هر محصول یک Query جداگانه اجرا شود.
1 Query برای دریافت محصولات
+
100 Query برای دریافت Categoryها
=
101 Query
اینجاست که با مفهوم N+1 Query روبهرو میشویم.
N یعنی تعداد رکوردهایی که داریم.
پس اگر N محصول داشته باشیم:
1 + N
Query اجرا میشود.
به همین دلیل نام این مشکل را N+1 Query گذاشتهاند.
یک مثال سادهتر
برای درک بهتر، فرض کنید فقط 5 محصول داریم.
کد ما:
products = Product.objects.all()
اول یک Query اجرا میشود:
SELECT *
FROM product;
حالا Template شروع به نمایش محصولات میکند.
برای محصول اول:
SELECT *
FROM category
WHERE id = 1;
برای محصول دوم:
SELECT *
FROM category
WHERE id = 2;
برای محصول سوم:
SELECT *
FROM category
WHERE id = 1;
و همین روند ادامه پیدا میکند.
در نهایت ممکن است چیزی شبیه این داشته باشیم:
1 Query برای Product
5 Query برای Category
Total = 6 Queries
یعنی برای نمایش فقط 5 محصول، 6 بار به دیتابیس درخواست ارسال شده است.
اگر تعداد محصولات به 1000 عدد برسد، وضعیت میتواند بسیار بدتر شود.
چرا این مشکل خطرناک است؟
ممکن است با خودتان بگویید:
«خب 101 تا Query که چیز خاصی نیست.»
در یک پروژه کوچک شاید واقعاً مشکل بزرگی نباشد.
اما مشکل زمانی خودش را نشان میدهد که این Queryها در یک درخواست واقعی، روی دیتابیس واقعی و با حجم داده واقعی اجرا شوند.
فرض کنید:
1000 محصول
داریم.
کد ما میتواند باعث اجرای چیزی نزدیک به:
1001 Query
شود.
حالا اگر همین View توسط 50 کاربر بهصورت همزمان درخواست شود، فشار روی دیتابیس میتواند خیلی سریع افزایش پیدا کند.
مشکل فقط تعداد Queryها نیست.
هر Query شامل هزینههایی مثل موارد زیر است:
- ارسال درخواست به دیتابیس
- پردازش Query
- پیدا کردن اطلاعات موردنظر
- برگرداندن نتیجه
- انتقال داده بین Application و Database
بنابراین حتی اگر هر Query بهتنهایی سریع باشد، تعداد بسیار زیاد Queryها میتواند زمان پاسخ را افزایش دهد.
یک نکته مهم
N+1 Query الزاماً به این معنی نیست که دقیقاً 1 + N Query خواهیم داشت.
ممکن است در بعضی شرایط تعداد Queryها متفاوت باشد. مثلاً اگر چند Object به یک Category مشترک اشاره کنند، رفتار Cache مربوط به Related Objectها میتواند روی تعداد Queryها تأثیر بگذارد.
اما مفهوم اصلی همچنان همان است:
بهجای اینکه اطلاعات مرتبط را در تعداد کمی Query دریافت کنیم، برای هر رکورد یک Query جداگانه اجرا میکنیم.
این الگو معمولاً نشانه این است که Queryset ما برای نوع استفادهای که از داده داریم، بهینه نشده است.
مشکل از کجاست؟
مشکل اصلی این قسمت است:
Product.objects.all()
ما فقط Productها را دریافت کردهایم.
اما در ادامه، به اطلاعات مرتبط با Product نیاز داریم:
product.category
Django بهصورت پیشفرض همیشه تمام روابط ForeignKey را همراه Query اصلی دریافت نمیکند.
این رفتار اتفاقاً منطقی است.
فرض کنید Product مدل ما 20 رابطه مختلف داشته باشد. اگر Django قرار بود همیشه تمام اطلاعات مرتبط را همراه Product دریافت کند، ممکن بود مقدار بسیار زیادی داده بدون نیاز واقعی از دیتابیس دریافت شود.
بنابراین Django اجازه میدهد خودمان مشخص کنیم چه روابطی را میخواهیم از قبل دریافت کنیم.
اینجاست که یکی از مهمترین ابزارهای Django ORM وارد ماجرا میشود:
select_related()
در پارت بعدی چه میبینیم؟
در بخش بعدی دقیقاً بررسی میکنیم که select_related() چگونه این مشکل را حل میکند و چرا در بسیاری از پروژههای Django میتواند تعداد Queryهای ما را از:
N + 1
به چیزی نزدیک به:
1
برساند.
حل مشکل N+1 Query با select_related()
حالا که فهمیدیم مشکل N+1 Query چیست، وقت آن رسیده که ببینیم چطور میتوانیم آن را در Django برطرف کنیم.
یکی از مهمترین ابزارهای Django ORM برای حل این مشکل، متد select_related() است.
اگر بخواهیم خیلی ساده بگوییم:
select_related() به Django میگوید اطلاعات رابطههای مشخصشده را همراه Query اصلی از دیتابیس دریافت کن.
در نتیجه، بهجای اینکه برای هر Object یک Query جداگانه اجرا شود، Django میتواند اطلاعات مرتبط را با استفاده از یک Query و معمولاً با SQL JOIN دریافت کند.
نسخهای که مشکل دارد
دوباره به همان مثال فروشگاه برگردیم.
def product_list(request):
products = Product.objects.all()
return render(
request,
"products/list.html",
{"products": products}
)
در Template نیز داریم:
{% for product in products %}
<h2>{{ product.name }}</h2>
<p>{{ product.category.name }}</p>
{% endfor %}
مشکل اینجاست که Query اولیه فقط Productها را دریافت میکند.
وقتی Django به product.category میرسد، ممکن است مجبور شود برای دریافت Category مربوط به هر Product، Query دیگری اجرا کند.
بنابراین اگر 100 محصول داشته باشیم، ممکن است چیزی شبیه این اتفاق بیفتد:
1 Query
+
100 Query
=
101 Query
این همان چیزی است که میخواهیم از آن جلوگیری کنیم.
راهحل با select_related()
حالا فقط کافی است Queryset خود را کمی تغییر دهیم:
products = Product.objects.select_related("category")
یا در View:
def product_list(request):
products = Product.objects.select_related("category")
return render(
request,
"products/list.html",
{"products": products}
)
همین تغییر کوچک میتواند تفاوت بسیار بزرگی در Performance ایجاد کند.
حالا وقتی در Template به این قسمت میرسیم:
{{ product.category.name }}
Django دیگر لازم نیست برای هر Product یک Query جداگانه برای Category اجرا کند؛ چون اطلاعات Category از قبل همراه Query اصلی دریافت شده است.
چه اتفاقی در دیتابیس میافتد؟
بدون select_related() ممکن است رفتار برنامه چیزی شبیه این باشد:
SELECT *
FROM product;
و بعد برای هر Category:
SELECT *
FROM category
WHERE id = 1;
SELECT *
FROM category
WHERE id = 2;
SELECT *
FROM category
WHERE id = 3;
اما با select_related() Django میتواند از JOIN استفاده کند.
بهصورت مفهومی Query میتواند چیزی شبیه این باشد:
SELECT
product.*,
category.*
FROM product
INNER JOIN category
ON product.category_id = category.id;
در نتیجه اطلاعات Product و Category در یک Query دریافت میشوند.
البته توجه داشته باشید که SQL دقیق تولیدشده توسط Django میتواند بسته به دیتابیس، مدلها و Queryset متفاوت باشد؛ بنابراین نباید انتظار داشته باشیم همیشه دقیقاً همین SQL تولید شود.
تفاوت تعداد Queryها
فرض کنیم 100 محصول داریم و هر محصول یک Category دارد.
در حالت نامناسب:
1 Query برای Product
+
100 Query برای Category
= 101 Queries
اما با:
Product.objects.select_related("category")
در حالت معمول میتوانیم اطلاعات موردنیاز را با یک Query دریافت کنیم:
1 Query
یعنی تعداد Queryها از:
101
به:
1
رسیده است.
این دقیقاً همان جایی است که بهینهسازی Query در Django میتواند تأثیر بسیار زیادی روی Performance داشته باشد.
select_related() دقیقاً برای چه رابطههایی است؟
یکی از نکات مهم درباره select_related() این است که برای تمام Relationshipها کاربرد یکسانی ندارد.
بهطور معمول، از select_related() برای رابطههایی مثل موارد زیر استفاده میکنیم:
- ForeignKey
- OneToOneField
برای مثال:
class Author(models.Model):
name = models.CharField(max_length=100)
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(
Author,
on_delete=models.CASCADE
)
اگر بخواهیم کتابها را همراه نویسنده دریافت کنیم:
books = Book.objects.select_related("author")
حالا میتوانیم بدون ایجاد Query اضافی برای هر کتاب، از اطلاعات نویسنده استفاده کنیم:
{% for book in books %}
<h2>{{ book.title }}</h2>
<p>نویسنده: {{ book.author.name }}</p>
{% endfor %}
استفاده از چند رابطه
فقط محدود به یک رابطه نیستیم.
فرض کنید مدل ما به این شکل باشد:
class Product(models.Model):
name = models.CharField(max_length=200)
category = models.ForeignKey(
Category,
on_delete=models.CASCADE
)
brand = models.ForeignKey(
Brand,
on_delete=models.CASCADE
)
میتوانیم چند رابطه را همزمان دریافت کنیم:
products = Product.objects.select_related(
"category",
"brand"
)
حالا اطلاعات category و brand هر دو همراه Query اصلی دریافت میشوند.
رابطههای تو در تو
یکی از قابلیتهای جالب select_related() این است که میتوانیم رابطههای تو در تو را نیز مشخص کنیم.
فرض کنید چنین ساختاری داریم:
class Country(models.Model):
name = models.CharField(max_length=100)
class Brand(models.Model):
name = models.CharField(max_length=100)
country = models.ForeignKey(
Country,
on_delete=models.CASCADE
)
class Product(models.Model):
name = models.CharField(max_length=200)
brand = models.ForeignKey(
Brand,
on_delete=models.CASCADE
)
حالا اگر بخواهیم Product را همراه Brand و Country دریافت کنیم، میتوانیم بنویسیم:
products = Product.objects.select_related(
"brand__country"
)
سپس در Template:
{{ product.brand.name }}
{{ product.brand.country.name }}
در این حالت Django رابطهها را طبق مسیر مشخصشده دنبال میکند.
آیا select_related() همیشه بهتر است؟
اینجا یک اشتباه رایج وجود دارد.
بعضی از Developerها بعد از آشنایی با select_related() شروع میکنند آن را روی تمام Querysetهای پروژه قرار میدهند.
این کار لزوماً درست نیست.
هدف ما این نیست که «همیشه Query بیشتری Join کنیم».
هدف ما این است که:
فقط دادههایی را که واقعاً نیاز داریم، با کمترین هزینه ممکن دریافت کنیم.
اگر یک رابطه را در صفحه استفاده نمیکنیم، لزوماً دلیلی برای دریافت آن وجود ندارد.
مثلاً اگر فقط نام Product را نمایش میدهیم:
Product.objects.all()
احتمالاً نیازی نداریم Category را هم با آن Join کنیم.
اما اگر در همان صفحه برای هر Product نام Category را نمایش میدهیم:
Product.objects.select_related("category")
انتخاب منطقیتری است.
یک قانون ساده برای به خاطر سپردن
برای شروع میتوانید این قانون ساده را در ذهن داشته باشید:
ForeignKey و OneToOne → select_related()
اما این پایان داستان N+1 Query نیست.
چون هنوز یک نوع بسیار مهم Relationship باقی مانده است:
ManyToMany
Reverse ForeignKey
و اینجا select_related() دیگر ابزار مناسبی نیست.
برای این روابط باید سراغ ابزار مهم دیگری از Django ORM برویم:
prefetch_related()
در پارت بعدی دقیقاً بررسی میکنیم که prefetch_related() چگونه کار میکند، چه تفاوتی با select_related() دارد و چطور میتوانیم N+1 Query را در رابطههای ManyToMany و Reverse ForeignKey حل کنیم.
حل N+1 Query با prefetch_related()
در بخش قبلی دیدیم که چگونه میتوانیم با استفاده از select_related() مشکل N+1 Query را در رابطههایی مثل ForeignKey و OneToOneField حل کنیم.
اما همه رابطههای Django را نمیتوان با select_related() بهینه کرد.
فرض کنید یک محصول میتواند چندین تصویر داشته باشد. در این حالت رابطه ما دیگر یک ForeignKey ساده از Product به یک Object نیست؛ بلکه یک رابطه یکبهچند یا حتی Many-to-Many داریم.
اینجاست که prefetch_related() وارد میشود.
select_related() یا prefetch_related()؟
قبل از اینکه سراغ مثال برویم، بهتر است تفاوت این دو را خیلی ساده مشخص کنیم.
| متد | مناسب برای | روش کلی |
|---|---|---|
select_related() |
ForeignKey و OneToOne | استفاده از SQL JOIN |
prefetch_related() |
ManyToMany و Reverse ForeignKey و رابطههای پیچیدهتر | اجرای Queryهای جداگانه و اتصال نتایج در Python |
پس یک قانون ساده داریم:
ForeignKey / OneToOne → select_related()
ManyToMany / Reverse ForeignKey → prefetch_related()
البته این قانون یک خلاصه کاربردی است و در پروژههای واقعی، نوع Query و شکل دادهها هم باید در نظر گرفته شود.
یک مثال با Reverse ForeignKey
فرض کنید یک فروشگاه داریم که هر محصول میتواند چندین Review داشته باشد.
class Product(models.Model):
name = models.CharField(max_length=200)
class Review(models.Model):
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
related_name="reviews"
)
text = models.TextField()
rating = models.PositiveSmallIntegerField()
حالا میخواهیم لیستی از محصولات را نمایش دهیم و Reviewهای هر محصول را نیز نشان دهیم.
ممکن است View را اینطور بنویسیم:
def product_list(request):
products = Product.objects.all()
return render(
request,
"products/list.html",
{"products": products}
)
و در Template:
{% for product in products %}
<h2>{{ product.name }}</h2>
{% for review in product.reviews.all %}
<p>{{ review.text }}</p>
{% endfor %}
{% endfor %}
اینجا احتمال ایجاد N+1 Query وجود دارد.
چرا؟
چون ابتدا یک Query برای دریافت Productها اجرا میشود:
1 Query
سپس برای هر Product، درخواست product.reviews.all() میتواند یک Query جداگانه اجرا کند.
اگر 100 محصول داشته باشیم:
1 Query برای Product
+
100 Query برای Review
= 101 Queries
دوباره به همان مشکل N+1 رسیدهایم.
حل مشکل با prefetch_related()
راهحل:
products = Product.objects.prefetch_related("reviews")
حالا View کامل:
def product_list(request):
products = Product.objects.prefetch_related("reviews")
return render(
request,
"products/list.html",
{"products": products}
)
در این حالت Django اطلاعات Productها و Reviewهای مرتبط را به شکل بهینهتری دریافت میکند و نتایج را در سمت Python به یکدیگر متصل میکند.
در یک حالت ساده میتوانیم چیزی شبیه این داشته باشیم:
Query 1 → دریافت Products
Query 2 → دریافت Reviews مربوط به Products
یعنی بهجای صدها Query، ممکن است فقط دو Query اصلی داشته باشیم.
2 Queries
این یکی از مهمترین مزایای prefetch_related() است.
prefetch_related() چگونه کار میکند؟
نکته مهم این است که prefetch_related() معمولاً برخلاف select_related() همه چیز را با یک SQL JOIN انجام نمیدهد.
Django معمولاً ابتدا Query اصلی را اجرا میکند:
SELECT *
FROM product;
سپس اطلاعات مرتبط را با Query دیگری دریافت میکند. برای مثال:
SELECT *
FROM review
WHERE product_id IN (1, 2, 3, 4, 5);
سپس Django در حافظه، Reviewهای دریافتشده را به Productهای مربوطه متصل میکند.
در نتیجه وقتی در Template مینویسیم:
{{ product.reviews.all }}
Django میتواند از دادههایی که قبلاً Prefetch شدهاند استفاده کند و Query اضافی غیرضروری اجرا نکند.
مثال ManyToMany
حالا یک مثال دیگر را بررسی کنیم.
فرض کنید هر مقاله میتواند چند Tag داشته باشد و هر Tag نیز میتواند روی چند مقاله استفاده شود.
class Tag(models.Model):
name = models.CharField(max_length=50)
class Article(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
tags = models.ManyToManyField(Tag)
حالا میخواهیم تمام Articleها را نمایش دهیم و Tagهای هر مقاله را نیز نشان دهیم.
اگر این Query را داشته باشیم:
articles = Article.objects.all()
و در Template:
{% for article in articles %}
<h2>{{ article.title }}</h2>
{% for tag in article.tags.all %}
<span>{{ tag.name }}</span>
{% endfor %}
{% endfor %}
میتواند دوباره باعث ایجاد تعداد زیادی Query شود.
راهحل:
articles = Article.objects.prefetch_related("tags")
حالا Django Tagهای مربوط به تمام Articleها را به شکل بهینهتری دریافت میکند.
ترکیب select_related و prefetch_related
در پروژههای واقعی، معمولاً مجبور نیستیم فقط یکی از این دو متد را انتخاب کنیم.
حتی ممکن است در یک Queryset به هر دو نیاز داشته باشیم.
فرض کنید چنین مدلهایی داریم:
class Category(models.Model):
name = models.CharField(max_length=100)
class Product(models.Model):
name = models.CharField(max_length=200)
category = models.ForeignKey(
Category,
on_delete=models.CASCADE
)
class Review(models.Model):
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
related_name="reviews"
)
text = models.TextField()
حالا میخواهیم:
- Category محصول را داشته باشیم.
- Reviewهای محصول را نیز داشته باشیم.
میتوانیم بنویسیم:
products = Product.objects.select_related(
"category"
).prefetch_related(
"reviews"
)
در اینجا:
category → select_related()
reviews → prefetch_related()
هر ابزار برای نوع رابطه مناسب خودش استفاده شده است.
چطور بفهمیم N+1 Query داریم؟
تا اینجا درباره حل مشکل صحبت کردیم، اما یک سؤال مهم باقی میماند:
چطور بفهمیم اصلاً در پروژه ما N+1 Query وجود دارد؟
یکی از سادهترین روشها استفاده از ابزارهای توسعه Django است.
Django Debug Toolbar
یکی از ابزارهای بسیار کاربردی برای بررسی Queryهای Django، Django Debug Toolbar است.
بعد از نصب و تنظیم آن، میتوانیم اطلاعاتی مثل تعداد Queryهای اجراشده، زمان اجرای Queryها و SQL تولیدشده را مشاهده کنیم.
برای مثال ممکن است در Toolbar ببینیم:
SQL Queries: 101
Total SQL Time: 85ms
وقتی میبینیم تعداد Queryها برای یک صفحه ساده به 101 رسیده است، باید بررسی کنیم آیا الگوی N+1 در کد ما وجود دارد یا نه.
بعد از استفاده از select_related() یا prefetch_related() ممکن است ببینیم:
SQL Queries: 2
Total SQL Time: 8ms
البته عدد دقیق کاملاً به پروژه، دیتابیس، حجم داده و Queryهای دیگر صفحه بستگی دارد.
فقط به تعداد Query نگاه نکنید
یک نکته مهم در Performance این است که کاهش تعداد Queryها همیشه به معنی سریعتر شدن برنامه نیست.
مثلاً ممکن است یک Query بسیار سنگین ایجاد کنیم که حجم زیادی از داده را برگرداند.
بنابراین هنگام بهینهسازی باید چند مورد را با هم بررسی کنیم:
- تعداد Queryها
- زمان اجرای Queryها
- حجم داده دریافتشده
- Indexهای دیتابیس
- ساختار Query
- تعداد رکوردها
- Memory مصرفشده
هدف اصلی، ساختن یک Query با کمترین تعداد ممکن نیست؛ هدف این است که داده موردنیاز را با هزینه منطقی دریافت کنیم.
یک اشتباه رایج
گاهی Developer برای حل N+1 Query، بدون بررسی دقیق، روی همه رابطهها prefetch_related() قرار میدهد:
Product.objects.prefetch_related(
"category",
"reviews",
"brand",
"comments",
"tags",
"images"
)
این کار لزوماً بهینه نیست.
اگر صفحه فقط نام Product و Category را نیاز داشته باشد، دریافت Reviewها، Comments، Images و Tags فقط باعث انتقال و پردازش دادههای غیرضروری میشود.
بهینهسازی واقعی یعنی:
فقط چیزی را Prefetch یا Select کن که واقعاً در همان Query نیاز داری.
یک قانون طلایی برای Django ORM
اگر بخواهیم کل این بخش را در چند خط خلاصه کنیم:
# ForeignKey / OneToOne
Product.objects.select_related("category")
# ManyToMany / Reverse ForeignKey
Product.objects.prefetch_related("reviews")
# ترکیب هر دو
Product.objects.select_related(
"category"
).prefetch_related(
"reviews"
)
با همین سه الگو میتوانیم بخش بزرگی از مشکلات رایج N+1 Query در پروژههای Django را کنترل کنیم.
اما هنوز یک سؤال مهم باقی مانده است...
فرض کنید select_related() و prefetch_related() را بهدرستی استفاده کردهایم.
آیا دیگر Performance پروژه ما تضمین شده است؟
خیر.
N+1 Query فقط یکی از مشکلات Performance در Django است.
ممکن است Queryهای ما کم باشند اما خود Query بسیار سنگین باشد. ممکن است روی ستونهای مهم Index نداشته باشیم. ممکن است تمام ستونهای یک جدول را دریافت کنیم در حالی که فقط دو ستون نیاز داریم. یا حتی ممکن است Pagination نداشته باشیم و هزاران Object را یکباره از دیتابیس دریافت کنیم.
در پارت بعدی سراغ همین موضوع میرویم و بررسی میکنیم که چگونه با ابزارهایی مثل only()، defer()، values()، annotate() و Pagination Queryهای Django را حرفهایتر و بهینهتر کنیم.
همچنین یاد میگیریم چطور قبل و بعد از بهینهسازی، Performance واقعی Query را اندازهگیری کنیم و صرفاً بر اساس حدس درباره سرعت تصمیم نگیریم.
فقط Query را کم نکن؛ داده اضافی هم دریافت نکن
تا اینجا یاد گرفتیم چگونه تعداد Queryهای غیرضروری را کاهش دهیم. اما Performance در Django فقط به تعداد Queryها محدود نمیشود.
فرض کنید یک Query فقط یک بار اجرا میشود، اما از یک جدول با 50 ستون، تمام اطلاعات را دریافت میکند؛ در حالی که ما فقط به دو ستون نیاز داریم.
در چنین شرایطی تعداد Query پایین است، اما همچنان داده بیشتری از چیزی که لازم داریم از دیتابیس دریافت میکنیم.
بنابراین یک قانون مهم دیگر داریم:
فقط دادهای را از دیتابیس دریافت کن که واقعاً به آن نیاز داری.
استفاده از only()
یکی از ابزارهای Django ORM برای محدود کردن Fieldهایی که از دیتابیس دریافت میکنیم، only() است.
فرض کنید مدل User ما Fieldهای زیادی دارد:
class User(models.Model):
username = models.CharField(max_length=150)
email = models.EmailField()
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
bio = models.TextField()
address = models.TextField()
phone = models.CharField(max_length=20)
birth_date = models.DateField()
profile_image = models.ImageField(upload_to="profiles/")
حالا فرض کنید در یک صفحه فقط به username و email نیاز داریم.
بهصورت معمول:
users = User.objects.all()
Django تمام Fieldهای لازم برای ساخت Object را دریافت میکند.
اما میتوانیم Query را محدود کنیم:
users = User.objects.only(
"username",
"email"
)
حالا Django میداند که در این Query فقط این Fieldها برای استفاده اولیه موردنیاز هستند.
یک نکته بسیار مهم درباره only()
استفاده از only() به این معنی نیست که Fieldهای دیگر برای همیشه غیرقابل دسترسی هستند.
اگر بعداً به Fieldی که Load نشده دسترسی پیدا کنیم، Django ممکن است یک Query اضافی اجرا کند.
مثلاً:
users = User.objects.only(
"username",
"email"
)
for user in users:
print(user.username)
print(user.bio)
در اینجا username از قبل دریافت شده است.
اما bio در Query اولیه قرار نگرفته است.
بنابراین دسترسی به آن میتواند باعث اجرای Query اضافی شود.
در نتیجه استفاده نادرست از only() حتی میتواند دوباره ما را به مشکل Queryهای اضافی برساند.
only() ابزار قدرتمندی است، اما باید با آگاهی از Fieldهایی که بعداً استفاده میکنید به کار برود.
defer() چیست؟
متد دیگری که عملکردی تقریباً معکوس only() دارد، defer() است.
فرض کنید مدل ما یک Field بسیار سنگین دارد:
class Article(models.Model):
title = models.CharField(max_length=200)
summary = models.TextField()
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
فرض کنید در صفحه لیست مقالات فقط عنوان و خلاصه را نمایش میدهیم و فعلاً به content نیازی نداریم.
میتوانیم بنویسیم:
articles = Article.objects.defer("content")
در این حالت Field مشخصشده از Query اولیه کنار گذاشته میشود.
اگر بعداً به آن دسترسی پیدا کنیم:
article.content
ممکن است Django برای دریافت آن Field یک Query جداگانه اجرا کند.
بنابراین defer() نیز باید با دقت استفاده شود.
only() یا defer()؟
| متد | رویکرد | کاربرد |
|---|---|---|
only() |
مشخص میکند چه Fieldهایی را میخواهیم | وقتی تعداد Fieldهای موردنیاز کم است |
defer() |
مشخص میکند چه Fieldهایی فعلاً لازم نیستند | وقتی فقط چند Field سنگین را نمیخواهیم |
مثلاً اگر مدل 30 Field داشته باشد و فقط 3 Field لازم داشته باشیم، ممکن است only() انتخاب مناسبی باشد.
اما اگر 30 Field داشته باشیم و فقط یک Field بزرگ مثل content را نخواهیم، ممکن است defer() خواناتر باشد.
values()؛ وقتی Object کامل Django را نمیخواهیم
گاهی حتی به خود Model Object هم نیاز نداریم.
فرض کنید فقط میخواهیم نام و قیمت محصولات را برای یک گزارش دریافت کنیم.
میتوانیم بنویسیم:
products = Product.objects.values(
"name",
"price"
)
در این حالت نتیجه بهصورت Dictionaryهای Python برمیگردد.
مثلاً:
[
{
"name": "Laptop",
"price": 1200
},
{
"name": "Mouse",
"price": 50
}
]
این روش زمانی بسیار کاربردی است که قرار نیست با Model Objectها کار پیچیدهای انجام دهیم و فقط به داده خام نیاز داریم.
values_list()
اگر حتی Dictionary هم نمیخواهیم و فقط چند مقدار ساده نیاز داریم، میتوانیم از values_list() استفاده کنیم.
products = Product.objects.values_list(
"name",
"price"
)
نتیجه میتواند چیزی شبیه این باشد:
[
("Laptop", 1200),
("Mouse", 50)
]
اگر فقط یک Field نیاز داشته باشیم، میتوانیم از flat=True استفاده کنیم:
names = Product.objects.values_list(
"name",
flat=True
)
حالا نتیجه میتواند به شکل زیر باشد:
[
"Laptop",
"Mouse",
"Keyboard"
]
چه زمانی values() بهتر است؟
فرض کنید یک API یا گزارش داریم که فقط به چند Field مشخص نیاز دارد.
مثلاً:
products = Product.objects.values(
"id",
"name",
"price"
)
اگر قرار نیست متدها یا رفتارهای Model را استفاده کنیم، دریافت Dictionary میتواند برای چنین سناریویی مناسب باشد.
اما نباید تصور کنیم values() همیشه سریعتر از Model Object است.
Performance واقعی به Query، حجم داده، دیتابیس و نحوه استفاده از نتیجه بستگی دارد.
پس بهتر است قبل از تغییر معماری فقط بر اساس یک قانون کلی تصمیم نگیریم و نتیجه را اندازهگیری کنیم.
Pagination؛ دشمن پنهان Performance
یکی از مشکلات رایج در پروژههای واقعی، دریافت تعداد بسیار زیادی Object در یک Query است.
فرض کنید 500 هزار محصول داریم.
اگر بنویسیم:
products = Product.objects.all()
و بخواهیم همه آنها را در یک صفحه نمایش دهیم، با یک مشکل جدی روبهرو خواهیم شد.
حتی اگر N+1 Query نداشته باشیم، دریافت صدها هزار رکورد به یکباره منطقی نیست.
راهحل:
Pagination
مثلاً بهجای دریافت 500 هزار محصول:
Page 1 → 20 Products
Page 2 → 20 Products
Page 3 → 20 Products
...
در Django میتوانیم از Paginator استفاده کنیم.
from django.core.paginator import Paginator
products = Product.objects.all()
paginator = Paginator(products, 20)
page_number = request.GET.get("page")
page = paginator.get_page(page_number)
حالا در هر صفحه فقط تعداد محدودی از دادهها نمایش داده میشود.
بهینهسازی واقعی یعنی ترکیب چند تکنیک
در یک پروژه واقعی معمولاً فقط یک تکنیک استفاده نمیکنیم.
برای مثال فرض کنید یک صفحه محصولات داریم که باید:
- محصولات را Pagination کند.
- Category محصول را نمایش دهد.
- Reviewهای محصول را نمایش دهد.
- فقط Fieldهای موردنیاز را دریافت کند.
در چنین شرایطی ممکن است Query ما چیزی شبیه این باشد:
products = (
Product.objects
.select_related("category")
.prefetch_related("reviews")
.only(
"id",
"name",
"price",
"category__id",
"category__name"
)
)
و سپس Pagination را روی Queryset اعمال کنیم.
البته این مثال فقط برای نشان دادن ترکیب تکنیکهاست و در پروژه واقعی باید بررسی کنیم که آیا تمام Fieldهای موردنیاز رابطهها نیز بهدرستی Load میشوند یا نه.
اما از کجا بفهمیم واقعاً بهتر شدهایم؟
تا اینجا چندین تکنیک مختلف یاد گرفتیم:
select_related()prefetch_related()only()defer()values()values_list()- Pagination
اما یک سؤال بسیار مهم باقی میماند:
چطور مطمئن شویم این تغییرات واقعاً Performance را بهتر کردهاند؟
جواب این سؤال فقط با نگاه کردن به کد مشخص نمیشود.
باید Queryها را ببینیم، زمان اجرای آنها را اندازهگیری کنیم و در صورت نیاز Execution Plan دیتابیس را بررسی کنیم.
در پارت بعدی سراغ همین بخش میرویم: Debug Toolbar، شمارش Queryها، اندازهگیری زمان، EXPLAIN و Indexها.
اینجاست که مقاله از یک آموزش ساده Django به یک راهنمای واقعی برای Performance تبدیل میشود.
چطور Performance Queryهای Django را واقعاً اندازهگیری کنیم؟
تا اینجا درباره تکنیکهای مختلف بهینهسازی Query در Django صحبت کردیم. اما یک اصل مهم در بهینهسازی وجود دارد:
Performance را حدس نزن؛ اندازهگیری کن.
اینکه یک Query کوتاهتر به نظر میرسد یا کد ما تمیزتر شده، بهتنهایی نشان نمیدهد که برنامه سریعتر شده است.
برای بررسی واقعی Performance باید ببینیم:
- چند Query اجرا میشود؟
- هر Query چقدر زمان میبرد؟
- چه SQLای توسط Django تولید شده است؟
- دیتابیس چگونه Query را اجرا میکند؟
- آیا از Index مناسب استفاده میشود؟
- آیا حجم دادهای که دریافت میکنیم منطقی است؟
Django Debug Toolbar
یکی از بهترین ابزارها برای بررسی Queryهای Django در محیط Development، Django Debug Toolbar است.
این ابزار اطلاعات بسیار مفیدی درباره Request فعلی در اختیار Developer قرار میدهد.
برای نصب:
pip install django-debug-toolbar
سپس آن را به INSTALLED_APPS اضافه میکنیم:
INSTALLED_APPS = [
# ...
"debug_toolbar",
]
Middleware مربوط به آن را نیز اضافه میکنیم:
MIDDLEWARE = [
# ...
"debug_toolbar.middleware.DebugToolbarMiddleware",
]
و URLهای Toolbar را در محیط Development اضافه میکنیم:
from django.conf import settings
from django.urls import include, path
urlpatterns = [
# ...
path("__debug__/", include("debug_toolbar.urls")),
]
البته تنظیمات دقیق Debug Toolbar ممکن است بسته به نسخه Django و ساختار پروژه متفاوت باشد.
چطور N+1 Query را با Debug Toolbar پیدا کنیم؟
فرض کنید این View را داریم:
def product_list(request):
products = Product.objects.all()
return render(
request,
"products/list.html",
{"products": products}
)
و در Template:
{% for product in products %}
<h2>{{ product.name }}</h2>
<p>{{ product.category.name }}</p>
{% endfor %}
فرض کنید صفحه را باز میکنیم و Debug Toolbar نشان میدهد:
SQL Queries: 101
این عدد یک علامت هشدار جدی است.
حالا Queryset را تغییر میدهیم:
products = Product.objects.select_related("category")
دوباره صفحه را اجرا میکنیم.
اگر حالا ببینیم:
SQL Queries: 1
متوجه میشویم که مشکل N+1 را برطرف کردهایم.
این روش بسیار بهتر از این است که فقط بر اساس حدس بگوییم:
«فکر کنم این Query سریعتر شده باشد.»
فقط تعداد Query مهم نیست
فرض کنید دو نسخه داریم:
Version A
Queries: 10
Time: 20ms
Version B
Queries: 2
Time: 80ms
در این مثال Version B تعداد Query کمتری دارد، اما زمان اجرای آن بیشتر است.
بنابراین نباید Performance را فقط با یک عدد قضاوت کنیم.
گاهی یک Query بزرگ و پیچیده میتواند از چند Query کوچک کندتر باشد.
هدف واقعی این است که ترکیب مناسبی از موارد زیر داشته باشیم:
- تعداد مناسب Query
- زمان اجرای مناسب
- حجم داده مناسب
- مصرف Memory منطقی
- استفاده درست از Index
بررسی SQL واقعی تولیدشده توسط Django
Django ORM باعث میشود نیازی نباشد بیشتر مواقع SQL را مستقیماً بنویسیم.
اما برای Performance باید بتوانیم SQL پشت Queryset را نیز بررسی کنیم.
برای مثال:
queryset = Product.objects.select_related("category")
print(queryset.query)
Django SQL تولیدشده را نمایش میدهد.
این قابلیت برای Debug کردن Query بسیار مفید است.
مثلاً ممکن است متوجه شویم Query ما شامل یک JOIN است:
SELECT ...
FROM product
INNER JOIN category
ON product.category_id = category.id;
با دیدن SQL واقعی، بهتر میتوانیم بفهمیم Django در پشت صحنه چه کاری انجام میدهد.
EXPLAIN چیست؟
گاهی دانستن SQL کافی نیست.
ممکن است Query ما کاملاً منطقی به نظر برسد، اما دیتابیس آن را به شکل نامناسبی اجرا کند.
اینجاست که EXPLAIN اهمیت پیدا میکند.
EXPLAIN به ما کمک میکند Execution Plan یک Query را بررسی کنیم.
به زبان ساده، Execution Plan نشان میدهد دیتابیس برای اجرای Query چه مسیری را انتخاب کرده است.
برای مثال:
EXPLAIN
SELECT *
FROM product
WHERE category_id = 5;
خروجی این دستور میتواند اطلاعاتی درباره روش دسترسی به دادهها، Index مورد استفاده، تعداد تقریبی رکوردها و عملیات انجامشده در اختیار ما قرار دهد.
EXPLAIN در Django
Django ORM نیز امکان استفاده از explain() را فراهم میکند.
مثلاً:
queryset = Product.objects.filter(
category_id=5
)
print(queryset.explain())
در نتیجه میتوانیم Execution Plan دیتابیس را مشاهده کنیم.
در برخی دیتابیسها مانند PostgreSQL میتوانیم گزینههای بیشتری نیز برای EXPLAIN استفاده کنیم.
print(
queryset.explain(
analyze=True,
verbose=True
)
)
اما باید در استفاده از analyze=True دقت کنیم؛ زیرا برخلاف EXPLAIN ساده، Query واقعاً اجرا میشود.
Index چیست و چرا مهم است؟
یکی از مهمترین مفاهیم Performance در دیتابیس، Index است.
فرض کنید یک جدول با یک میلیون User داریم.
و مرتباً کاربران را بر اساس Email جستجو میکنیم:
User.objects.get(
email="user@example.com"
)
اگر دیتابیس مجبور باشد تمام یک میلیون رکورد را بررسی کند، چنین Queryای میتواند هزینه زیادی داشته باشد.
Index میتواند به دیتابیس کمک کند که مسیر سریعتری برای پیدا کردن داده موردنظر داشته باشد.
ساخت Index در Django
در Django میتوانیم برای Fieldهای موردنظر Index تعریف کنیم.
برای مثال:
class User(models.Model):
email = models.EmailField(db_index=True)
یا میتوانیم Index را در Meta تعریف کنیم:
class Product(models.Model):
name = models.CharField(max_length=200)
category_id = models.IntegerField()
created_at = models.DateTimeField()
class Meta:
indexes = [
models.Index(
fields=["category_id"]
),
]
بعد از تغییر مدل:
python manage.py makemigrations
python manage.py migrate
Django تغییرات مربوط به Index را در دیتابیس اعمال میکند.
آیا باید روی همه Fieldها Index بسازیم؟
خیر.
این یکی از اشتباهات رایج در طراحی دیتابیس است.
Index رایگان نیست.
Indexها فضای ذخیرهسازی مصرف میکنند و عملیاتهایی مثل INSERT و UPDATE نیز باید Indexهای مربوطه را بهروزرسانی کنند.
بنابراین Index باید بر اساس الگوی واقعی Queryهای برنامه ایجاد شود.
Fieldهایی که مرتباً در موارد زیر استفاده میشوند، ممکن است کاندیدای مناسبی برای Index باشند:
WHEREORDER BYJOIN- جستوجوهای پرتکرار
- Foreign Keyهای مهم
اما همیشه باید Execution Plan و الگوی واقعی استفاده از دیتابیس را بررسی کنیم.
Index ترکیبی چیست؟
گاهی Query ما بر اساس چند Field فیلتر میشود.
مثلاً:
Product.objects.filter(
category_id=5,
is_active=True
)
اگر این Query بسیار پرتکرار باشد، ممکن است یک Composite Index مناسب باشد:
class Product(models.Model):
category_id = models.IntegerField()
is_active = models.BooleanField()
class Meta:
indexes = [
models.Index(
fields=["category_id", "is_active"]
),
]
اما ترتیب Fieldها در Composite Index مهم است و انتخاب آن باید بر اساس Queryهای واقعی پروژه انجام شود.
یک نکته مهم درباره ForeignKey
Django معمولاً برای ForeignKeyها Index ایجاد میکند.
بنابراین نباید بدون بررسی، روی هر ForeignKey دوباره Index جداگانه تعریف کنیم.
همیشه ابتدا بررسی کنیم Django و دیتابیس چه Indexهایی را از قبل ایجاد کردهاند و آیا Index جدید واقعاً موردنیاز است یا خیر.
بهینهسازی را مرحلهبهمرحله انجام دهید
یک اشتباه دیگر این است که Developer بدون اندازهگیری، چندین تغییر را همزمان انجام دهد.
مثلاً:
select_related()
+
prefetch_related()
+
only()
+
Index
+
Caching
و بعد بگوید:
«الان سریعتر شد.»
مشکل این روش این است که نمیدانیم کدام تغییر واقعاً تأثیر داشته است.
روش بهتر:
- Performance فعلی را اندازهگیری کنید.
- مشکل اصلی را پیدا کنید.
- فقط یک تغییر مهم انجام دهید.
- دوباره اندازهگیری کنید.
- نتیجه را با نسخه قبلی مقایسه کنید.
این روش باعث میشود تصمیمهای Performance ما بر اساس داده باشند، نه حدس.
یک مثال کامل از فرآیند بهینهسازی
فرض کنید صفحه Product List بسیار کند است.
ابتدا بررسی میکنیم:
SQL Queries: 101
Total SQL Time: 150ms
با بررسی Queryها متوجه N+1 میشویم.
پس:
Product.objects.all()
را به:
Product.objects.select_related("category")
تغییر میدهیم.
دوباره اندازهگیری:
SQL Queries: 1
Total SQL Time: 20ms
حالا متوجه میشویم تعداد Queryها عالی شده، اما هنوز Query زمان زیادی میبرد.
SQL را بررسی میکنیم و Execution Plan را با EXPLAIN میبینیم.
فرض کنیم متوجه میشویم Filter موردنظر Index مناسبی ندارد.
Index مناسب ایجاد میکنیم و دوباره تست میکنیم.
مثلاً:
SQL Queries: 1
Total SQL Time: 4ms
حالا میتوانیم با اطمینان بیشتری بگوییم که Performance بهتر شده است.
Performance یک فرایند است، نه یک دستور جادویی
یکی از مهمترین چیزهایی که باید از این مقاله یاد بگیریم این است که در Django یک دستور جادویی وجود ندارد که تمام مشکلات Performance را حل کند.
گاهی مشکل از N+1 Query است.
گاهی مشکل از Index است.
گاهی داده بسیار زیادی دریافت میکنیم.
گاهی Pagination نداریم.
گاهی Query پیچیده است.
و گاهی مشکل حتی در دیتابیس نیست و باید Application یا معماری سیستم را بررسی کنیم.
بنابراین یک Developer حرفهای ابتدا مشکل را اندازهگیری میکند و سپس مناسبترین راهحل را انتخاب میکند.
در پارت آخر چه خواهیم دید؟
در بخش پایانی مقاله، تمام مطالب را در قالب یک سناریوی واقعی کنار هم قرار میدهیم.
یک صفحه فروشگاه را از نسخهای که دارای N+1 Query است شروع میکنیم و مرحلهبهمرحله آن را بهینه میکنیم.
در نهایت نیز یک چکلیست کاربردی خواهیم داشت که قبل از انتشار پروژه Django میتوانیم برای بررسی Queryها و Performance از آن استفاده کنیم.
یک مثال واقعی؛ از N+1 Query تا Query بهینه
تا اینجا تمام ابزارهای اصلی برای شناسایی و حل مشکل N+1 Query را بررسی کردیم. حالا بیایید تمام این مفاهیم را در یک سناریوی واقعی کنار هم قرار دهیم.
فرض کنید یک فروشگاه اینترنتی با Django داریم. در صفحه محصولات باید اطلاعات زیر نمایش داده شود:
- نام محصول
- قیمت محصول
- دستهبندی محصول
- برند محصول
- Reviewهای محصول
مدلها را میتوانیم به شکل زیر در نظر بگیریم:
class Category(models.Model):
name = models.CharField(max_length=100)
class Brand(models.Model):
name = models.CharField(max_length=100)
class Product(models.Model):
name = models.CharField(max_length=200)
price = models.DecimalField(
max_digits=10,
decimal_places=2
)
category = models.ForeignKey(
Category,
on_delete=models.CASCADE
)
brand = models.ForeignKey(
Brand,
on_delete=models.CASCADE
)
class Review(models.Model):
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
related_name="reviews"
)
text = models.TextField()
rating = models.PositiveSmallIntegerField()
نسخه اولیه؛ ساده اما مشکلدار
ممکن است در اولین نسخه View را اینگونه بنویسیم:
def product_list(request):
products = Product.objects.all()
return render(
request,
"products/list.html",
{"products": products}
)
و Template:
{% for product in products %}
<h2>{{ product.name }}</h2>
<p>
قیمت:
{{ product.price }}
</p>
<p>
دستهبندی:
{{ product.category.name }}
</p>
<p>
برند:
{{ product.brand.name }}
</p>
{% for review in product.reviews.all %}
<p>
{{ review.text }}
</p>
{% endfor %}
{% endfor %}
کد کاملاً قابل اجرا است.
اما از نظر Performance میتواند مشکل داشته باشد.
اگر 100 محصول داشته باشیم، ممکن است تقریباً با این وضعیت روبهرو شویم:
1 Query → Products
100 Queries → Category
100 Queries → Brand
100 Queries → Reviews
یعنی مجموعاً چیزی نزدیک به:
301 Queries
البته تعداد دقیق Queryها به شرایط مختلف بستگی دارد، اما الگوی N+1 کاملاً قابل مشاهده است.
مرحله اول؛ حل ForeignKeyها
Category و Brand هر دو ForeignKey هستند.
بنابراین از select_related() استفاده میکنیم:
products = Product.objects.select_related(
"category",
"brand"
)
حالا Queryهای مربوط به Category و Brand دیگر به شکل قبلی برای هر محصول تکرار نمیشوند.
مرحله دوم؛ حل Reverse ForeignKey
Review رابطه Reverse ForeignKey با Product دارد.
برای آن از:
prefetch_related()
استفاده میکنیم:
products = Product.objects.select_related(
"category",
"brand"
).prefetch_related(
"reviews"
)
حالا Queryset ما بسیار بهتر شده است.
در یک حالت ساده میتوانیم چیزی شبیه این داشته باشیم:
Query 1 → Products + Category + Brand
Query 2 → Reviews
در نتیجه بهجای صدها Query، تعداد Queryها میتواند به چند Query محدود شود.
مرحله سوم؛ فقط داده موردنیاز را دریافت کنیم
فرض کنید صفحه فقط به اطلاعات زیر نیاز دارد:
- id محصول
- name
- price
- Category name
- Brand name
در چنین شرایطی میتوانیم بررسی کنیم آیا استفاده از only() برای Query ما منطقی است یا خیر.
products = (
Product.objects
.select_related(
"category",
"brand"
)
.only(
"id",
"name",
"price",
"category__id",
"category__name",
"brand__id",
"brand__name"
)
.prefetch_related(
"reviews"
)
)
اما اینجا باید دقت کنیم که Reviewهای Template نیز تمام Fieldهایی را که استفاده میکنند، داشته باشند.
اگر مثلاً فقط text و rating لازم باشد، میتوانیم Prefetch را نیز دقیقتر کنیم.
Prefetch پیشرفته با Prefetch Object
Django اجازه میدهد کنترل بیشتری روی prefetch_related() داشته باشیم.
برای این کار میتوانیم از Prefetch استفاده کنیم.
from django.db.models import Prefetch
مثلاً اگر فقط Reviewهای با Rating بالا را نیاز داشته باشیم:
reviews = Review.objects.filter(
rating__gte=4
)
products = Product.objects.prefetch_related(
Prefetch(
"reviews",
queryset=reviews
)
)
در این حالت بهجای Prefetch کردن تمام Reviewها، فقط Reviewهایی که شرط موردنظر را دارند دریافت میشوند.
حتی میتوانیم نتیجه Prefetch را در یک Attribute جدا ذخیره کنیم:
products = Product.objects.prefetch_related(
Prefetch(
"reviews",
queryset=Review.objects.filter(
rating__gte=4
),
to_attr="high_rated_reviews"
)
)
حالا در Python میتوانیم از:
product.high_rated_reviews
استفاده کنیم.
این روش در پروژههای بزرگ میتواند کنترل بسیار بیشتری روی دادههای Prefetch شده ایجاد کند.
مرحله چهارم؛ Pagination
حتی اگر Queryهای ما کاملاً بهینه باشند، نباید هزاران محصول را یکباره به کاربر ارسال کنیم.
فرض کنید 100,000 محصول داریم.
صفحه را به 20 محصول در هر صفحه محدود میکنیم:
from django.core.paginator import Paginator
products = (
Product.objects
.select_related(
"category",
"brand"
)
.prefetch_related(
"reviews"
)
)
paginator = Paginator(products, 20)
page_number = request.GET.get("page")
page = paginator.get_page(page_number)
حالا بهجای پردازش حجم بسیار بزرگی از داده، هر Request فقط بخشی از دادهها را دریافت میکند.
مرحله پنجم؛ بررسی Index
فرض کنید کاربران میتوانند محصولات را بر اساس Category و وضعیت فعال بودن فیلتر کنند:
products = Product.objects.filter(
category_id=category_id,
is_active=True
)
اگر این Query در پروژه بسیار پرتکرار باشد، باید Execution Plan را بررسی کنیم.
ممکن است یک Composite Index مناسب باشد:
class Meta:
indexes = [
models.Index(
fields=[
"category",
"is_active"
]
)
]
اما Index را نباید صرفاً به خاطر اینکه «ممکن است سریعتر شود» اضافه کنیم.
ابتدا Query واقعی را بررسی کنید، سپس Execution Plan را ببینید و بعد تصمیم بگیرید.
مرحله ششم؛ دوباره Performance را اندازه بگیریم
حالا زمان آن است که نتیجه را اندازهگیری کنیم.
فرض کنید قبل از بهینهسازی داشتیم:
SQL Queries: 301
Total SQL Time: 220ms
بعد از استفاده صحیح از select_related() و prefetch_related() ممکن است نتیجه به چیزی شبیه این برسد:
SQL Queries: 3
Total SQL Time: 18ms
و بعد از بهینهسازی Index:
SQL Queries: 3
Total SQL Time: 5ms
اعداد بالا فقط یک مثال هستند و نباید آنها را بهعنوان نتیجه تضمینی در هر پروژه در نظر گرفت.
نکته مهم، فرآیند است:
Measure
↓
Find Bottleneck
↓
Optimize
↓
Measure Again
اشتباهاتی که نباید انجام دهیم
در پایان بهتر است چند اشتباه رایج را مرور کنیم.
اشتباه اول: استفاده از select_related روی همه چیز
اینکه select_related() مفید است، به این معنی نیست که باید تمام Relationshipها را با آن دریافت کنیم.
فقط Relationshipهایی را انتخاب کنید که واقعاً به آنها نیاز دارید.
اشتباه دوم: استفاده بیش از حد از prefetch_related
Prefetch کردن دادههای بسیار زیاد میتواند باعث مصرف Memory بیشتر شود.
اگر صفحه فقط چند اطلاعات محدود نیاز دارد، تمام Relationshipها را Prefetch نکنید.
اشتباه سوم: استفاده کورکورانه از only()
اگر بعداً به Fieldهایی که Load نشدهاند دسترسی پیدا کنید، ممکن است Queryهای اضافی ایجاد شوند.
بنابراین استفاده از only() باید بر اساس نحوه واقعی استفاده از Object انجام شود.
اشتباه چهارم: Index روی همه Fieldها
Index بیشتر همیشه بهتر نیست.
Index فضای بیشتری مصرف میکند و هزینه نوشتن داده را افزایش میدهد.
اشتباه پنجم: فقط به تعداد Query نگاه کردن
ممکن است یک Query داشته باشیم اما همان یک Query بسیار سنگین باشد.
همیشه زمان اجرا، حجم داده و Execution Plan را نیز بررسی کنید.
اشتباه ششم: بهینهسازی بدون اندازهگیری
اگر قبل و بعد از تغییرات چیزی را اندازهگیری نکنیم، نمیدانیم واقعاً چه اتفاقی افتاده است.
چکلیست بررسی N+1 Query در Django
قبل از انتشار یک صفحه مهم در پروژه Django، میتوانید این موارد را بررسی کنید:
- آیا برای Relationshipها Query اضافی اجرا میشود؟
- آیا ForeignKeyها نیاز به
select_related()دارند؟ - آیا ManyToManyها نیاز به
prefetch_related()دارند؟ - آیا Reverse ForeignKeyها بهدرستی Prefetch شدهاند؟
- آیا تعداد Queryها را با Debug Toolbar بررسی کردهاید؟
- آیا SQL واقعی تولیدشده توسط Django را بررسی کردهاید؟
- آیا Queryهای سنگین را با
EXPLAINبررسی کردهاید؟ - آیا Indexهای موردنیاز وجود دارند؟
- آیا دادههای اضافی دریافت میکنید؟
- آیا Pagination دارید؟
- آیا Performance قبل و بعد از تغییرات اندازهگیری شده است؟
جمعبندی
N+1 Query یکی از مشکلات رایج Performance در پروژههای Django است که ممکن است در ابتدا کاملاً نامحسوس باشد.
کدی که از نظر منطقی کاملاً درست کار میکند، ممکن است در پشت صحنه صدها Query غیرضروری به دیتابیس ارسال کند.
مهمترین ابزارهایی که در این مقاله یاد گرفتیم عبارتاند از:
- select_related() برای رابطههایی مانند ForeignKey و OneToOne
- prefetch_related() برای ManyToMany و Reverse ForeignKey
- only() برای محدود کردن Fieldهای موردنیاز
- defer() برای به تعویق انداختن Fieldهای غیرضروری
- values() و values_list() برای دریافت دادههای محدود
- Pagination برای جلوگیری از دریافت حجم عظیم داده
- EXPLAIN برای بررسی Execution Plan
- Index برای بهبود Queryهای پرتکرار
- Django Debug Toolbar برای مشاهده و بررسی Queryها
اما مهمتر از همه این ابزارها، طرز فکر ما در هنگام بهینهسازی است.
اول اندازهگیری کن، بعد مشکل را پیدا کن، سپس بهینهسازی کن و در نهایت دوباره اندازهگیری کن.
Performance خوب نتیجه استفاده از یک تکنیک خاص نیست؛ نتیجه ترکیب درست Query، Database، Index، حجم داده و معماری مناسب است.
اگر یک پروژه Django دارید، لازم نیست از همان ابتدا تمام Queryها را پیچیده و بیش از حد بهینه کنید. ابتدا برنامه را درست طراحی کنید، سپس با ابزارهای مناسب Bottleneckهای واقعی را پیدا کنید و دقیقاً همان قسمتها را بهینه کنید.
نتیجه نهایی
اگر بخواهیم کل مقاله را در یک جمله خلاصه کنیم:
در Django فقط مهم نیست چه دادهای دریافت میکنیم؛ مهم است بدانیم چند بار، با چه Queryای و با چه هزینهای آن داده را از دیتابیس دریافت میکنیم.
شناخت N+1 Query و ابزارهایی مثل select_related() و prefetch_related() یکی از اولین قدمهای مهم برای تبدیل شدن از یک Django Developer معمولی به Developerای است که واقعاً به Performance و مقیاسپذیری پروژه اهمیت میدهد.
حالا اگر دفعه بعد دیدید یک صفحه Django بدون هیچ خطایی کار میکند اما به شکل عجیبی کند است، قبل از اینکه سراغ افزایش RAM یا CPU سرور بروید، یک سؤال ساده از خودتان بپرسید:
دیتابیس برای همین صفحه چند بار صدا زده میشود؟