Čo je N+1 Query Problem?
Predstavme si aplikáciu, ktorá zobrazuje zoznam používateľov a počet alebo prehľad ich objednávok. Na získanie zoznamu používateľov vykoná jeden databázový dotaz. Následne však pri každom používateľovi samostatne načíta jeho objednávky.
Ak máme 100 používateľov, výsledkom môže byť:
- 1 dotaz na získanie všetkých používateľov,
- 100 ďalších dotazov na načítanie objednávok jednotlivých používateľov.
Spolu teda 101 databázových dotazov namiesto niekoľkých optimalizovaných dotazov, ktoré by dokázali získať potrebné údaje naraz.
Problém sa nazýva N+1 preto, že aplikácia vykoná jeden úvodný dotaz a potom ďalších N dotazov – spravidla jeden pre každý záznam vo výsledku.
A čo ak má aplikácia 1 000 používateľov? Namiesto jedného dotazu môže vykonať 1 001. Každý jednotlivý dotaz pritom môže byť rýchly. Ich množstvo však zvyšuje celkový čas načítania a zbytočne zaťažuje databázu aj komunikáciu medzi aplikáciou a databázovým serverom.
Ako N+1 problém vzniká v Djangu?
Django používa ORM (Object-Relational Mapper), ktorý umožňuje pracovať s databázovými záznamami prostredníctvom Python objektov namiesto písania SQL dotazov pri každej operácii.
ORM zjednodušuje vývoj, ale nie vždy je na prvý pohľad zrejmé, koľko databázových dotazov sa v skutočnosti vykoná.
Pozrime sa na zjednodušený príklad. Predpokladajme, že máme model User a model Order, pričom objednávka patrí konkrétnemu používateľovi a vzťah je nastavený cez related_name="orders".
users = User.objects.all()
for user in users:
orders = user.orders.all()
print(user.username, list(orders))
Kód vyzerá jednoducho. Najprv získame používateľov a potom pre každého z nich načítame objednávky.
Háčik je v tom, že user.orders.all() môže pri každom používateľovi spustiť samostatný databázový dotaz. Ak zoznam obsahuje 100 používateľov, aplikácia môže vykonať 101 dotazov.
Takéto správanie sa môže ľahko skryť v šablónach, serializéroch alebo vo vnorených cykloch. Vývojár pracuje s objektmi a ich vzťahmi, zatiaľ čo databáza pod povrchom vykonáva opakované operácie.
Ako problém vyriešiť: select_related() a prefetch_related()
Django ponúka dve dôležité metódy na optimalizáciu načítavania súvisiacich objektov: select_related() a prefetch_related().
Obe pomáhajú obmedziť zbytočné databázové dotazy, ale fungujú odlišne. Výber závisí od typu vzťahu medzi modelmi.
1. select_related(): Keď súvisia záznamy cez cudzí kľúč
Metóda select_related() sa používa najmä pri vzťahoch typu ForeignKey a OneToOneField. Django v takom prípade využije SQL JOIN a načíta súvisiace údaje v rámci jedného dotazu.
Predstavme si opačný prípad: zobrazujeme zoznam objednávok a pri každej chceme meno používateľa, ktorý ju vytvoril.
Neoptimalizovaný kód môže vyzerať takto:
orders = Order.objects.all()
for order in orders:
print(order.user.username)
Ak máme 100 objednávok, môže sa vykonať jeden dotaz na objednávky a ďalších 100 dotazov na používateľov.
Optimalizovaná verzia:
orders = Order.objects.select_related("user")
for order in orders:
print(order.user.username)
Django načíta objednávky aj súvisiacich používateľov spoločne. Namiesto opakovaného získavania používateľa pre každú objednávku tak môže stačiť jeden SQL dotaz.
Kedy použiť select_related()?
Keď potrebujeme načítať súvisiaci objekt cez vzťah, pri ktorom má každý záznam najviac jeden súvisiaci objekt – napríklad používateľa pri objednávke alebo profil patriaci jednému používateľovi.
2. prefetch_related(): Keď potrebujeme viac súvisiacich záznamov
Čo ak však chceme zobraziť používateľov a všetky ich objednávky? Jeden používateľ môže mať nula, jednu alebo desiatky objednávok. V takom prípade nám pomôže prefetch_related().
users = User.objects.prefetch_related("orders")
for user in users:
print(user.username)
for order in user.orders.all():
print(order.id)
Django najprv načíta používateľov a potom samostatným dotazom získa objednávky, ktoré k nim patria. Následne ich priradí k príslušným používateľom v aplikácii.
Pri bežnom príklade tak môže stačiť dvojica databázových dotazov namiesto 101: jeden na používateľov a druhý na ich objednávky.
prefetch_related() sa hodí najmä pre vzťahy typu ManyToManyField a opačné vzťahy ForeignKey, pri ktorých jeden objekt súvisí s viacerými ďalšími objektmi.
Rozdiel oproti select_related() spočíva v tom, že prefetch_related() používa samostatné dotazy a výsledky spája až v Pythone. Je preto vhodný aj v situáciách, keď by obyčajný SQL JOIN viedol k opakovaniu údajov vo výslednej množine.
Dôležité je pamätať na to, že nejde o univerzálne pravidlo, podľa ktorého je vždy lepší jeden dotaz než dva. Cieľom je znížiť zbytočnú prácu a zvoliť spôsob načítania, ktorý zodpovedá dátam a konkrétnemu použitiu.

Lazy loading: Prečo sa dotaz spustí až neskôr?
Jedným z dôvodov, prečo sa N+1 problém ľahko prehliadne, je takzvaný lazy loading, teda odložené načítavanie údajov.
Django QuerySety sa spravidla vyhodnocujú až vtedy, keď aplikácia skutočne potrebuje ich výsledky. Napríklad pri iterovaní cez QuerySet, jeho prevode na zoznam alebo pri niektorých ďalších operáciách.
Podobne sa súvisiace objekty cez cudzie kľúče často načítajú až vtedy, keď k nim pristúpime. Ak aplikácia v cykle opakovane pristupuje k vzťahom, ktoré neboli vopred načítané, môže tak nevedomky vytvárať ďalšie dotazy.
Práve preto nemusí byť problém viditeľný na riadku, ktorý získava zoznam používateľov. Objaví sa až neskôr, napríklad v šablóne, ktorá zobrazuje meno používateľa, jeho objednávky alebo ďalšie súvisiace údaje.
Lazy loading sám osebe nie je chyba. Pomáha načítavať iba údaje, ktoré aplikácia potrebuje. Problém vzniká vtedy, keď sa odložené načítavanie opakuje mnohokrát a vytvára zbytočné databázové operácie.
Ako odhaliť N+1 Query Problem?
Prvým krokom je zistiť, koľko databázových dotazov aplikácia v skutočnosti vykonáva. Samotný pohľad na Python kód často nestačí.
Existuje niekoľko spôsobov, ako problém odhaliť.
Django Debug Toolbar je užitočný nástroj počas lokálneho vývoja. Dokáže zobraziť SQL dotazy vykonané pri načítaní stránky, ich počet aj čas vykonávania. Ak sa podobný dotaz opakuje desiatky či stovky ráz, môže to byť signál problému.
Logovanie SQL dotazov pomáha pri detailnejšej diagnostike. Vývojár môže sledovať, ktoré dotazy sa vykonávajú, ako často a ako dlho trvajú. V produkcii treba logovanie nastaviť rozumne, aby samo nevytváralo zbytočnú záťaž a neodhaľovalo citlivé údaje.
Testy počtu dotazov umožňujú odhaliť regresie ešte pred nasadením. Django napríklad poskytuje assertNumQueries(), pomocou ktorého možno v testoch overiť, že konkrétna operácia nevykonáva nečakane veľa databázových dotazov.
Profilovanie pomalých stránok a API endpointov pomáha určiť, kde má optimalizácia najväčší význam. Pri API sa oplatí skontrolovať aj serializéry, pretože pri spracovaní každého objektu môžu pristupovať k súvisiacim záznamom a vyvolávať ďalšie dotazy.
Nie každý opakovaný dotaz automaticky znamená N+1 problém a nie každý pomalý endpoint ho obsahuje. Dôležité je pozerať sa na počet dotazov v kontexte množstva spracovaných dát a na to, koľko času skutočne zaberajú.
Ako N+1 problémom predchádzať?
Najlepšia optimalizácia je tá, ktorú netreba robiť až po tom, čo sa používatelia začnú sťažovať na pomalú aplikáciu. Niekoľko návykov dokáže riziko N+1 problému výrazne znížiť.
- Myslite na databázové vzťahy už pri návrhu funkcie. Ak viete, že stránka bude zobrazovať objednávky aj mená používateľov, naplánujte si načítanie potrebných súvisiacich údajov vopred.
- Používajte select_related() a prefetch_related() tam, kde dávajú zmysel. Nespoliehajte sa na to, že ORM automaticky zvolí najefektívnejší spôsob načítania všetkých údajov. Zároveň ich nepridávajte bez rozmyslu – načítanie veľkého množstva nepotrebných dát môže byť rovnako neefektívne.
- Kontrolujte šablóny a serializéry. N+1 problém nemusí vzniknúť priamo vo view. Môže sa objaviť aj v kóde, ktorý spracúva jednotlivé objekty pri vykresľovaní stránky alebo vytváraní odpovede API.
- Testujte s realistickým množstvom dát. Pri piatich používateľoch môže byť rozdiel medzi šiestimi a dvoma dotazmi takmer nepostrehnuteľný. Pri stovkách alebo tisícoch záznamov už môže byť významný. Zároveň sa oplatí sledovať nielen počet dotazov, ale aj ich trvanie a objem načítaných dát.
- Sledujte výkon priebežne. Kontrola počtu dotazov v testoch, profilovanie a pravidelná analýza pomalých endpointov pomáhajú odhaliť problém skôr, než sa prejaví pri väčšom zaťažení.
Záver: Menej dotazov môže znamenať rýchlejšiu aplikáciu
N+1 Query Problem je dobrým príkladom toho, ako môže nenápadný detail v kóde postupne prerásť do výkonového problému. Aplikácia môže fungovať správne, no s rastúcim množstvom dát vykonávať zbytočne veľa práce.
V Djangu pritom často stačí pochopiť, kedy sa údaje načítavajú, a správne použiť select_related() alebo prefetch_related(). Namiesto jedného úvodného dotazu a desiatok či stoviek ďalších tak aplikácia dokáže získať potrebné údaje v niekoľkých efektívnejších operáciách.
Pri optimalizácii databázy totiž nejde iba o to, aby bol každý dotaz rýchly. Dôležité je aj to, koľko dotazov aplikácia vôbec potrebuje.