Asosiy kontentga o‘tish

Maqolalar 6 daqiqa 108 ko‘rish

Django ORM'da N+1: sahifa nega birdan sekinlashib qoladi

Lokalda uchib turgan sahifa serverda sudralib qoladi, chunki ORM har bir yozuv uchun bazaga alohida borib keladi. N+1 qanday tug‘iladi, uni qanday ushlash va select_related bilan prefetch_related orasidagi farq nimada.

performance django backend

Mundarija

Loyihada hammasi joyida edi. Lokal bazada yigirmata yozuv bilan sahifa bir zumda ochilardi, serverga chiqargandan keyin ham bir necha hafta hech kim shikoyat qilmadi. Keyin ma'lumot to‘plana boshladi va bir kuni o‘sha sahifa sekundlab ochiladigan bo‘lib qoldi. Kodda esa hech narsa o‘zgarmagan.

Bunday holatda birinchi gumon qiladigan narsam — N+1. Bu Django loyihalarida eng ko‘p uchraydigan tezlik muammosi va uning ayyorligi shundaki, kod mutlaqo bexato ko‘rinadi.

Muammo qanday tug‘iladi#

Faraz qilaylik, maqolalar ro‘yxatini mualliflari bilan chiqaryapmiz:

Python
posts = Post.objects.all()  # 1 so‘rov
for post in posts:
    print(post.author.name)  # HAR post uchun +1 so‘rov!

Tashqaridan qaraganda ikki qator kod. Aslida esa bu yerda yuzta bazaga murojaat yashiringan: bittasi maqolalar ro‘yxati uchun, keyin har bir maqola aylanishida post.author chaqirilganda yana bittadan. Yuzta maqola bo‘lsa, jami 101 ta so‘rov. Nomi ham shundan: bitta so‘rov ustiga yana N ta.

Buning sababi ORM'ning "kerak bo‘lganda olib kelaman" tamoyilida. Django Post.objects.all() yozganingizda mualliflar haqida hech narsa so‘ramaydi, chunki siz ularni so‘raganingiz yo‘q. post.author ga birinchi marta tegilganda esa u jimgina bazaga borib keladi. Bitta murojaat bir necha millisekund, o‘zi bilinmaydi ham. Yuztasi bo‘lsa — sahifa sudraladi.

ForeignKey va OneToOne bog‘lanishlar uchun select_related ishlatiladi. U SQL darajasida JOIN qiladi va hamma narsani bitta so‘rovda olib keladi:

Python
posts = Post.objects.select_related("author")  # 1 so‘rov, JOIN bilan
for post in posts:
    print(post.author.name)  # qo‘shimcha so‘rov yo‘q

101 ta so‘rov bittaga tushdi. Zanjir bo‘ylab ham yurish mumkin: agar muallifning shahri kerak bo‘lsa, select_related("author__city") deb yozasiz va u ham o‘sha JOIN ichiga qo‘shiladi.

ManyToMany yoki teskari ForeignKey bilan JOIN yaramaydi, chunki bitta maqolaga o‘nlab teg to‘g‘ri kelishi mumkin va JOIN natijasi ko‘payib ketadi. Bu holat uchun boshqa usul bor:

Python
posts = Post.objects.prefetch_related("tags")
for post in posts:
    print([tag.name for tag in post.tags.all()])  # oldindan yuklangan

Bu yerda Django ikkita so‘rov yuboradi: birinchisi maqolalarni, ikkinchisi o‘sha maqolalarga tegishli barcha teglarni oladi. Keyin ularni Python tomonida o‘zi birlashtiradi. Ikki so‘rov — yuz bittadan ancha yaxshi.

Endi tuzoqlar haqida#

Yuqoridagi ikki usulni bilish yetarli emas, chunki ular kutilmaganda ishdan chiqishi mumkin. Men eng ko‘p duch kelgan uchtasini aytib beray.

Birinchisi: aylanish ichida filtrlash. prefetch_related("tags") deb yozib qo‘yasiz, keyin siklda post.tags.filter(is_active=True) deysiz. Django oldindan yuklab qo‘ygan ro‘yxatni tashlab yuboradi va yana har maqola uchun yangi so‘rov yuboradi. Filtrni oldindan aytib qo‘yish kerak:

Python
from django.db.models import Prefetch

posts = Post.objects.prefetch_related(
    # filtr shu yerda aytiladi — sikl ichida emas
    Prefetch("tags", queryset=Tag.objects.filter(is_active=True))
)

Ikkinchisi: faqat son kerak bo‘lganda. Sahifada har maqolaning teglari emas, faqat "5 ta teg" degan soni ko‘rsatilsa, siklda post.tags.count() yozish oson — va prefetch_relatedsiz bu yana har maqola uchun alohida COUNT so‘rovi: yuzta maqolaga 101 ta. prefetch_related("tags") qo‘shsangiz, Django count() ni xotiradagi ro‘yxatdan hisoblaydi va so‘rov 2 taga tushadi — lekin buning uchun hamma teglarni bazadan tortib kelishga to‘g‘ri keladi. Faqat son kerak bo‘lsa, uni bazaning o‘zida hisoblatish arzonroq:

Python
from django.db.models import Count

posts = Post.objects.annotate(tag_count=Count("tags"))  # 1 so‘rov, GROUP BY bilan
for post in posts:
    print(post.tag_count)

Uchinchisi: shablon ichidagi yashirin murojaatlar. Kodni tekshirib chiqasiz, hammasi joyida ko‘rinadi, lekin so‘rovlar soni baribir ko‘p. Sabab shablonda bo‘ladi: {{ post.author.city.name }} kabi bitta zanjir ham xuddi shu N+1 ni tug‘diradi, faqat u endi HTML ichida yashiringan.

Muammoni qanday ushlash mumkin#

Taxmin qilib o‘tirmang, o‘lchang. Lokalda eng qulay usul — django-debug-toolbar: u har sahifada nechta so‘rov ketganini va ular qancha vaqt olganini ko‘rsatib turadi. Qoida oddiy: agar ro‘yxatga yana o‘nta yozuv qo‘shganingizda so‘rovlar soni ham o‘nga oshsa, sizda N+1 bor.

Topganingizdan keyin uni testda mustahkamlab qo‘yish kerak, aks holda bir oydan keyin kimdir yana o‘sha joyga sikl yozadi:

Python
# blog/tests/test_posts.py
from blog.models import Author, City, Post, Tag


def test_royxat_sorovlar_soni_maqolalar_soniga_bogliq_emas(db, client, django_assert_num_queries):
    city = City.objects.create(name="Toshkent")
    tag = Tag.objects.create(name="django")
    # N+1 faqat yozuvlar bo'lganda ko'rinadi: bo'sh ro'yxatda sikl umuman aylanmaydi
    for i in range(5):
        author = Author.objects.create(name=f"Muallif {i}", city=city)
        post = Post.objects.create(title=f"Maqola {i}", author=author)
        post.tags.add(tag)

    # maqolalar (muallif JOIN bilan) + teglar — 5 ta maqolada ham, 500 tasida ham 2 ta
    with django_assert_num_queries(2):
        client.get("/posts/")

Bu test kelajakda ogohlantiruvchi qo‘ng‘iroq bo‘lib xizmat qiladi. Kimdir view'dan select_related yoki prefetch_related ni olib tashlasa, u darhol qizaradi — sinovda xabari shunday edi: Expected to perform 2 queries but 11 were done.

Ikki mayda narsa testni ishlaydigan qiladi. Birinchisi — db fixture'i: django_assert_num_queries o‘zi bazaga ruxsat bermaydi, busiz test Database access not allowed bilan yiqiladi. Ikkinchisi — oldindan yaratilgan yozuvlar. Bo‘sh bazada N+1 umuman ko‘rinmaydi: sinovda yozuvsiz xuddi shunday test N+1 li view bilan ham yashil bo‘ldi.

Keshlash N+1 ning davosi emas

Sekin sahifani ko‘rgan odamning birinchi xayoliga keladigan narsa — natijani keshlab qo‘yish. Bu yordam bergandek tuyuladi, lekin aslida muammoni ko‘mib qo‘yadi: kesh eskirganda yoki birinchi marta so‘ralganda o‘sha yuzta so‘rov baribir ketadi. Avval so‘rovlar sonini tushiring, keshlashni esa undan keyin, haqiqatan kerak bo‘lsa qo‘shing.

Amaliy qoida#

Kod yozayotib sikl ichida bog‘langan obyektga murojaat qilayotganingizni sezsangiz, o‘sha yerning o‘zida to‘xtang va queryset'ga qaytib qarang. Bitta obyekt bo‘lsa select_related, ko‘pi bo‘lsa prefetch_related. Bu ikki so‘zni o‘z vaqtida yozish loyihaning umri davomida sizni juda ko‘p "sayt nega sekin" degan suhbatdan qutqaradi.

6 daqiqa 108 marta o‘qildi