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.
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:
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.
select_related: bitta obyektga bog‘langanda#
ForeignKey va OneToOne bog‘lanishlar uchun select_related ishlatiladi.
U SQL darajasida JOIN qiladi va hamma narsani bitta so‘rovda olib keladi:
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.
prefetch_related: ko‘plik bog‘lanishlarda#
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:
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:
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:
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:
# 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.