janigo — docker compose up --build
BUILD
$

Django · Python · Docker · Deploy

قصد همکاری داری؟ تماس بگیر

N+1 در لیست‌های جنگو؛ قبل از خرید سرور، این کد رو بهینه کن

امیرحسین علیجانی 31 مرداد، 1405 0 دیدگاه
N+1 در لیست‌های جنگو؛ قبل از خرید سرور، این کد رو بهینه کن

وقتی پروژه کند میشه، اول سراغ سرور نرو

اگر اولین راه حلت برای کند شدن پروژه‌ات خرید سرور قوی‌تره، این متن احتمالاً چند میلیون تومن برات صرفه‌جویی می‌کنه. من هم یه زمانی فکر می‌کردم اگه RAM رو دو برابر کنم، مشکل لگ شدن پنل ادمین حل میشه، ولی اشتباه از آب در اومد. آخرش فقط Debug Toolbar رو باز کردیم و دیدیم صفحه‌ای که باید در ۰.۱ ثانیه لود بشه، بالای ۲ ثانیه طول میکشه

قبل از اینکه برای پرفورمنس پول خرج کنی، برای پیدا کردن دلیلش وقت خرج کن. اکثر وقت‌ها مشکل از سخت‌افزار نیست، بلکه از یه الگوی اشتباه در کوئری‌های دیتابیس است که بهش میگیم N+1. اگه هنوز نمیدونی چیه، نگران نباش، توی مراحل بعدی با مثال‌های واقعی کامل باز می‌کنیمش

N+1 چیه و چرا دیتابیس رو خفه می‌کنه؟

فرض کن یه فروشگاه آنلاین داری و توی صفحه لیست محصولات، اسم نویسنده هر کتاب رو هم نشون میدی ظاهر کار که می‌بینی، فقط یک درخواست HTTP از مرورگر به سرورت اومده و همه چیز عالیه اما پشت صحنه داستان چیز دیگه‌ست! دیتابیس در واقع اول یه کوئری میزنه تا لیست محصولات رو بیاره و بعد برای هر تکی از اون محصولات، یک کوئری جداگانه اجرا می‌کنه تا اسم نویسنده رو از جدول دیگه بکشه بیرون

تصور کن می‌خوای لیست ۱۰۰ تا محصول رو نمایش بدی. جنگو اول کار یه کوئری میزنه تا لیست این ۱۰۰ تا محصول رو بگیره (۱). ولی وقتی توی حلقه (Loop) برای هر محصول میری سراغ دیتابیس تا اسم دسته‌بندیش رو بگیری، برای تک‌تک این ۱۰۰ تا محصول یه کوئری جدید می‌فرسته (N)

نتیجه؟ ۱۰۰ + ۱ درخواست به دیتابیس؛ این دقیقاً همون مشکل معروف N+1 هست

این همه درخواست تکراری، پهنای باند و منابع دیتابیس رو می‌بلعه و باعث میشه حتی روی قوی‌ترین سرورها هم تأخیر رو حس کنی پس قبل از اینکه بری سراغ خرید یه سرور قوی‌تر، باید بدونی خیلی وقت‌ها همین کوئری‌زدن‌های غیربهینه‌ان که عملاً دارن پینگت رو بالا می‌برن

یه بار داشتم لیست ادمین رو چک می‌کردم...

قبلاً برای من پیش اومده که روی یه پروژه‌ی فروشگاهی کار می‌کردم و یه روز صبح با صد تا نوتیفیکیشن توی تلگرام بیدار شدم که همگی می‌گفتن سایت زیر ۲ ثانیه لود نمیشه. من که تازه‌کار بودم فکر کردم احتمالاً دیتابیس پر شده یا رم سرور کمه و گشتم دنبال راه‌حل‌های سخت مثل خرید VPS جدید یا تغییر تنظیمات MySQL. بعدش یه رفیقم اومد کنارم نشست، Django Debug Toolbar رو فعال کرد و گفت: «بیا ببینیم چه خبره جا اینکه حدس بزنیم»

وقتی نوار کناری باز شد و من ردیف کوئری‌ها رو دیدم، پرچام ریخت 😐صفحه‌ای که فقط قرار بود لیست محصولات رو نشون بده، داشت بیش از ۱۵۰ تا کوئری SQL به دیتابیس می‌زد! آخرش فقط فهمیدیم که هر بار که حلقه‌ی تکرار برای رندر کردن قالب اجرا می‌شد، یک کوئری جداگانه برای گرفتن نام فروشنده محصول به دیتابیس می‌رفت این یعنی به جای یه درخواست برای کل لیست، ما داشتیم برای هر آیتم تکی با دیتابیس حرف می‌زدیم و همین باعث می‌شد فشار وحشتناکی به کانتینر دیتابیس بیاد

کد بدهی‌کار: وقتی حلقه for کوئری میزنه

یه صحنه‌ی آشنا؛ تو یه ویو ساده داری لیست پست‌ها رو برای یه بلاگر نمایش میدی و کدت دقیقاً همین شکلیه که تو بلوک زیر می‌بینی، کاملاً تمیز و بصری هم هست

# views.py - نمونه کدی که دیتابیس رو می‌کشه رو زمین

def blog_list(request):
    posts = Post.objects.filter(is_published=True).order_by('-created_at')
    return render(request, 'blog/list.html', {'posts': posts})

مشکل از اینجا شروع نمیشه، جایی که توی Template تیکه تیکه می‌کنی رو می‌بینی و فکر می‌کنی جنگو سیفه ولی در واقع داری یه بمب ساعتی نصب می‌کنی💣

<!-- list.html - کدی که ارتباط رو خراب می‌کنه -->
{% for post in posts %}
  <h2>{{ post.title }}</h2>
  <p>نویسنده: {{ post.author.name }}</p>  <!-- کوئری شماره ۲ -->
  <p>دسته‌بندی: {{ post.category.name }}</p> <!-- کوئری شماره ۳ -->
  <p>تعداد کامنت‌ها: {{ post.comments.count }}</p> <!-- کوئری شماره ۴ -->
  {% for comment in post.comments.all %}
    <li>{{ comment.text }}</li> <!-- کوئری‌های بیشتر برای هر کامنت -->
  {% endfor %}
{% endfor %}

هر بار که حلقه for روی یک پست جدید تکرار میشه، دیتابیس مجبور میشه دوباره و دوباره اطلاعات نویسنده، دسته‌بندی و کامنت‌ها رو بکشه بیرون ، حتی اگر این اطلاعات برای پست قبلی هم کاملاً مشابه بوده باشن

تعداد کوئری‌هایی که همزمان اجرا می‌شن، با زیاد شدن تعداد رکوردها بیشتر و بیشتر میشه. مثلاً اگه ۵۰ تا پست داشته باشی، به‌جای اینکه با یه کوئری کار رو جمع کنی، ممکنه ۲۰۰ یا حتی ۳۰۰ تا کوئری سمت دیتابیس بفرستی. همین حجم از درخواست‌ها هم می‌تونه فشار زیادی به دیتابیس بیاره و باعث بشه CPU درگیر بشه و دیتابیس عملاً کم بیاره و فریز کنه

نکته‌ی ترسناک اینجاست که تو این مرحله هیچ اروری نمی‌بینی، همه‌چی درست کار می‌کنه، سرعت رو هم چون هنوز دیتابیس کوچک هست متوجه نمیشی ولی وقتی دیتابیس رشد کنه، همون کدی که «تمیز» به نظر میاد تبدیل میشه به بدترین دشمن آپ‌تایم سایتت

راه حل ساده: select_related برای روابط ManyToOne

وقتی توی لیست ادمین یا صفحه اصلی پروژه‌ت داری اشیایی رو نمایش میدی که به یه مدل دیگه لینک خارجی (ForeignKey) دارن، دیتابیس به صورت پیش‌فرض فقط ستون‌های خود اون مدل رو میاره و اطلاعات مدلی که بهش اشاره کرده رو نمیاره. اینجاست که اگه توی تمپلیت یا ویو بخوای به اون رابطه دسترسی پیدا کنی، جنگو باید یک کوئری جداگانه برای هر رکورد اجرا کنه و سریعاً به همون مشکل N+1 معروف برمی‌خوری

برای حل این مشکل خاص از select_related استفاده می‌کنی که دقیقاً برای روابط One-to-One و Many-to-One طراحی شده و کارش یه Join ساده روی SQL هست تا تمام اطلاعات لازم رو در یک کوئری بزرگتر تحویل بده

# ❌ بد: هر پست یک کوئری برای author اضافه می‌شود
Post.objects.all()

# ✅ خوب: یک کوئری با JOIN برای author
Post.objects.select_related('author').all()

توی کد بالا، وقتی از select_related('author') استفاده می‌کنی، جنگو یک SQL با JOIN می‌سازه و هم اطلاعات پست و هم اطلاعات نویسنده رو با هم برمی‌گردونه. نتیجه‌ش اینه که حتی اگه توی تمپلیت ده‌ها بار post.author.name رو صدا بزنی، هیچ کوئری جدیدی به دیتابیس نمی‌ره و بار اضافه‌ای روی دیتابیس نمی‌ذاری.

نکته‌ای که خیلی‌ها فراموش می‌کنن اینه که select_related فقط برای روابطی کار می‌کنه که «یک» مقدار دارند؛ یعنی هر پست فقط یک نویسنده داره یا هر کاربر فقط یک پروفایل داره اگر رابطه چندتایی باشه (مثل ManyToMany یا Reverse FK)، جنگو ازت اجازه نمی‌ده از این متد استفاده کنی و باید بری سراغ پادشاه بهینه‌سازی بعدی

مقایسه مشکل N+1 و بهینه‌سازی کوئری با select_related در جنگو

 

در کل، select_related مثل یه جرثقیل هوشمنده که بارهای سنگین (JOINهای ساده) رو با یه حرکت جابجا می‌کنه و اگر فیلدِ رابطه‌ت تک‌مقداره، اولویت با اونه؛ چون کارش با SQL نیتیو انجام می‌شه و سرعتش از هر روش دیگه‌ای بالاتره

پادشاه بهینه‌سازی: prefetch_related برای ManyToMany و Reverse FK

ببین، وقتی رابطه‌ت از نوع OneToMany یا ManyToMany باشه یا حتی بخوای از سمت یک مدل به مدل‌های مرتبط در سمت دیگر (Reverse Relation) دسترسی داشته باشی، select_related دیگه جواب نمیده و حتی ارور هم میده. اینجا جاییه که prefetch_related مثل یه قهرمان وارد میدان میشه و دقیقاً همون کاری رو می‌کنه که ما از نظر منطقی انتظار داریم ولی دیتابیس از ما نمی‌خواد

تفاوت اصلیش اینجاست که select_related یک SQL JOIN میزنه تا همه چیز رو در یه کوئری بیاره، اما prefetch_related در عوض دو تا کوئری مجزا اجرا می‌کنه: اول لیست اصلی رو میاره، و بعد همهٔ آبجکت‌های مرتبط رو توی یه کوئری جداگانه Fetch می‌کنه و بعدش توی حافظه (Memory) با استفاده از ORM پیوندشون میده. این روش برای روابط چندتایی (مثل کامنت‌های هر پست یا تگ‌های هر مقاله) خیلی بهینه‌تره چونJOIN زدن روی جداول بزرگ با تعداد رکورد بالا، دیتابیس رو سنگین و کند می‌کنه که اصلاً مقرون‌به‌صرفه نیست

برای درک بهتر، فرض کن می‌خوای لیست بلاگ‌ها رو بیاری و برای هر کدوم، تعداد کامنت‌ها رو هم نشون بدی؛ بدون prefetch_related، جنگو برای هر پست یه کوئری جداگانه میزنه تا تعداد کامنت‌ها رو حساب کنه، اما با این متد، همهٔ کامنت‌ها در یه حرکت بارگذاری میشن

# مدل‌های فرضی برای درک بهتر
class Post(models.Model):
    title = models.CharField(max_length=100)
    tags = models.ManyToManyField('Tag')  # رابطه ManyToMany
    category = models.ForeignKey('Category', on_delete=models.CASCADE)

class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
    text = models.TextField()

# ❌ کد قدیمی و کند (N+1 Problem در حلقه for)
def post_list_view(request):
    # این کوئری فقط پست‌ها رو میاره و کامنت‌ها یا تگ‌ها رو لود نمی‌کنه
    posts = Post.objects.all() 

    context = {}
    for post in posts:
        # ⚠️ اینجا برای هر پست، یک کوئری جداگانه به دیتابیس می‌خوره!
        # اگر ۱۰۰ پست داشته باشیم، ۱۰۰ کوئری اضافه اجرا میشه
        comments_count = post.comments.count() 
        tags_list = list(post.tags.all())
        context[f'post_{post.id}'] = {
            'title': post.title,
            'comments': comments_count,
            'tags': tags_list
        }
    return render(request, 'posts.html', context)

# ✅ کد بهینه شده با prefetch_related
def optimized_post_list_view(request):
    # اینجا دو تا کوئری اصلی اجرا میشه:
    # 1. انتخاب تمام پست‌ها
    # 2. انتخاب تمام کامنت‌های مربوطه و تمام تگ‌های پست‌ها (در یک کوئری optimize شده)
    # و بقیه کارها توی حافظهٔ پایتون انجام میشه، نه دیتابیس!
    posts = Post.objects.prefetch_related('comments', 'tags').all()

    # حالا حلقه for فوق‌العاده سریعه چون داده‌ها قبلاً لود شدن
    # و هیچ کوئری اضافی به دیتابیس ضربه نمی‌زنه
    context = {}
    for post in posts:
        # دسترسی به comments.count() یا tags.all() اینجا هزینهٔ دیتابیس نداره
        context[f'post_{post.id}'] = {
            'title': post.title,
            'comments': post.comments.count(), # از کش حافظه میخونه
            'tags': list(post.tags.all())     # از کش حافظه میخونه
        }
    return render(request, 'posts.html', context)

یه نکتهٔ خیلی مهم که خیلیا اشتباه میکنن اینه که فکر کنند prefetch_related فقط برای ManyToMany یا Reverse ForeignKey کار می‌کنه، در حالی که برای فوروارد فوروین کی (Forward FK) مثل category هم می‌تونی ازش استفاده کنی، ولی اونجا select_related هنوز هم سریع‌تره چون با JOIN یه کوئری کار رو راه میندازه. پس یادت باشه: اگر رابطه‌ت از نوع "یک به چند" یا "چند به چند" هست و در جهت عکس (Reverse) بهش دسترسی داری، prefetch_related پادشاه بی‌رقیبه

اشتباهات رایج که باعث میشه کنتاکتت خراب بشه

خیلی‌ها فکر می‌کنن اضافه کردن select_related یا prefetch_related مثل دکمه‌ی جادویی عمل می‌کنه و با یه خط کد کل سیستم رو نجات میده، ولی قضیه اون‌قدرها هم ساده نیست اگر بدونی چطور این‌ها کار می‌کنن. فرض کن تو داری یه لیست از «مقالات» رو به همراه «نویسنده‌ها» نمایش میدی و اشتباهاً همزمان از select_related برای رابطه‌ی ManyToMany استفاده می‌کنی؛ نتیجه‌ش یه کوئری پیچیده‌ی JOIN خواهد بود که عملاً هیچ کمکی به رفع مشکل N+1 نمی‌کنه و فقط دیتابیس رو با ابرکوئری سنگین و بیهوده گیج می‌کنه

یه اشتباه رایج دیگه اینه که prefetch_related رو روی روابطی اعمال می‌کنی که اصلاً در آن لحظه نیاز نداریشون یا تعداد اشیای برگشتی انقدر زیاده که عملاً حافظه‌ی سرور رو می‌بلعه؛ اینجاست که فکر می‌کنی بهینه‌سازی کردی ولی با یه مصرف رم وحشتناک و کندی در پردازش پایتون طرف میشی. همچنین فراموش نکن که select_related فقط برای Foreign Key کار می‌کنه و اگر سعی کنی بدون در نظر گرفتن نوع رابطه ازش استفاده کنی، Django یا ارور میده یا بدتر از اون، کوئری‌های غیرمنطقی تولید می‌کنه که هیچ‌کس از سرعتش راضی نیست

اگر اولین راه حلت برای کند شدن پروژه خرید سرور قوی‌تره، این متن احتمالاً چند میلیون تومن برات صرفه‌جویی می‌کنه. قبل از اینکه برای Performance پول خرج کنی، برای پیدا کردن دلیلش وقت خرج کن و از این چک‌لیست استفاده کن :

اول از همه، Debug Toolbar رو همیشه در دسترس نگه دار و هر بار که یه صفحه کند میشه، قبل از هر کاری تب SQL رو چک کن. اگر تعداد کوئری‌ها بیشتر از ده‌تا بود، یعنی داری به دیتابیس حمله می‌کنی و نه به سرور

برای روابط تک‌تک (مثل author در Post) حتماً از select_related استفاده کن، ولی برای روابط لیستی (مثل tags یا comments) prefetch_related پادشاه این کاره اشتباه نکن و prefetch_related رو جایی که فقط به یکی نیاز داری استفاده نکن، چون هم کد رو پیچیده می‌کنه و هم گاهی منطقی نیست

نکته طلایی اینه که prefetch_related رو توی view اصلی و نزدیک به کویری اصلی بنویس، نه توی مدل‌ها یا template‌ها. اینجوری ارتباط با دیتابیس‌هات مینیمم می‌شه و پروژه‌ت بدون هیچ آپگرید سخت‌افزاری، سریع‌تر از قبل ریسپانس میده

سرور قوی، مشکل کد بد رو پنهان می‌کنه، حذفش نمی‌کنه؛ پس اول کدت رو تمیز کن، بعد بریم سراغ سخت‌افزار

امیرحسین علیجانی

امیرحسین علیجانی

برنامه‌نویس Django و Python

درباره نویسنده · نمونه کارها

ارسال دیدگاه

نام
ایمیل
متن دیدگاه