Co je problém N+1 dotazů?
Představme si aplikaci, která zobrazuje seznam uživatelů a jejich objednávky. Aby získala seznam uživatelů, provede jeden databázový dotaz. Potom ale načítá objednávky každého uživatele zvlášť.
Pokud máme 100 uživatelů, může výsledek vypadat takto:
- 1 dotaz pro získání všech uživatelů.
- 100 dalších dotazů pro načtení objednávek jednotlivých uživatelů.
Celkem tedy 101 databázových dotazů místo několika optimalizovaných dotazů, které by potřebná data dokázaly získat efektivněji.
Problém se nazývá N+1, protože aplikace nejprve provede jeden úvodní dotaz a poté dalších N dotazů – zpravidla jeden pro každý záznam ve výsledku.
A co když má aplikace 1 000 uživatelů? Místo jednoho dotazu může provést 1 001 dotazů. Každý z nich může být rychlý, ale jejich celkový počet prodlužuje dobu načítání a zbytečně zatěžuje databázi i komunikaci mezi aplikací a databázovým serverem.
Jak problém N+1 vzniká v Djangu?
Django používá ORM (Object-Relational Mapper), který umožňuje pracovat s databázovými záznamy prostřednictvím objektů v Pythonu, aniž by bylo nutné psát SQL dotazy pro každou operaci.
ORM vývoj zjednodušuje, ale ne vždy je na první pohled zřejmé, kolik databázových dotazů se ve skutečnosti provede.
Podívejme se na zjednodušený příklad. Předpokládejme, že máme model User a model Order. Každá objednávka patří konkrétnímu uživateli a vztah je nastaven pomocí related_name="orders".
users = User.objects.all()
for user in users:
orders = user.orders.all()
print(user.username, list(orders))
Kód vypadá jednoduše. Nejprve získáme uživatele a potom načteme objednávky každého z nich.
Háček spočívá v tom, že user.orders.all() může pro každého uživatele spustit samostatný databázový dotaz. Pokud seznam obsahuje 100 uživatelů, aplikace může provést 101 dotazů.
Takové chování může snadno uniknout pozornosti v šablonách, serializérech nebo vnořených cyklech. Vývojář pracuje s objekty a jejich vztahy, zatímco databáze na pozadí opakovaně provádí další operace.
Jak problém vyřešit: select_related() a prefetch_related()
Django nabízí dvě důležité metody pro optimalizaci načítání souvisejících objektů: select_related() a prefetch_related().
Obě pomáhají omezit zbytečné databázové dotazy, ale fungují odlišně. Výběr závisí na typu vztahu mezi modely.
1. select_related(): Když jsou záznamy propojené cizím klíčem
Metoda select_related() se používá především u vztahů typu ForeignKey a OneToOneField. Django v takovém případě využije SQL JOIN a načte související data v rámci jednoho dotazu.
Představme si opačnou situaci: zobrazujeme seznam objednávek a u každé chceme uvést jméno uživatele, který ji vytvořil.
Neoptimalizovaný kód může vypadat takto:
orders = Order.objects.all()
for order in orders:
print(order.user.username)
Pokud máme 100 objednávek, Django může provést jeden dotaz pro načtení objednávek a dalších 100 dotazů pro získání příslušných uživatelů.
Optimalizovaná verze vypadá takto:
orders = Order.objects.select_related("user")
for order in orders:
print(order.user.username)
Django načte objednávky i související uživatele společně. Místo samostatného načítání uživatele pro každou objednávku tak může stačit jeden SQL dotaz.
Kdy použít select_related()?
Použijte ho, když potřebujete načíst související objekt přes vztah, ve kterém má každý záznam nejvýše jeden související objekt. Příkladem je uživatel přiřazený k objednávce nebo profil patřící jednomu uživateli.
2. prefetch_related(): Když potřebujete více souvisejících záznamů
Co když ale chceme zobrazit uživatele a všechny jejich objednávky? Jeden uživatel může mít žádnou, jednu nebo desítky objednávek. V takovém případě 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 nejprve načte uživatele a poté provede samostatný dotaz, kterým získá jejich objednávky. Následně objednávky přiřadí ke správným uživatelům v Pythonu.
V běžném případě tak mohou stačit dva databázové dotazy místo 101: jeden pro uživatele a druhý pro jejich objednávky.
prefetch_related() se hodí především pro vztahy typu ManyToManyField a opačné vztahy ForeignKey, ve kterých může jeden objekt souviset s více dalšími objekty.
Na rozdíl od select_related() používá prefetch_related() samostatné dotazy a výsledky propojuje až v Pythonu. Hodí se proto i v situacích, kdy by běžný SQL JOIN vedl k opakování dat ve výsledné množině.
Je důležité si uvědomit, že méně dotazů nemusí být automaticky lepší v každé situaci. Cílem je omezit zbytečnou práci a zvolit způsob načítání, který odpovídá datům i konkrétnímu případu použití.

Lazy loading: Proč se dotaz provede až později?
Jedním z důvodů, proč lze problém N+1 snadno přehlédnout, je takzvaný lazy loading, tedy odložené načítání dat.
QuerySety v Djangu se zpravidla vyhodnocují až ve chvíli, kdy aplikace skutečně potřebuje jejich výsledky. Dochází k tomu například při procházení QuerySetu, jeho převodu na seznam nebo při některých dalších operacích.
Podobně se související objekty přístupné přes cizí klíče často načítají až ve chvíli, kdy k nim kód přistoupí. Pokud aplikace v cyklu opakovaně přistupuje ke vztahům, které nebyly načteny předem, může neúmyslně vytvářet další databázové dotazy.
Proto nemusí být problém viditelný přímo na řádku, který získává seznam uživatelů. Může se projevit až později, například v šabloně, která zobrazuje jméno uživatele, jeho objednávky nebo další související informace.
Lazy loading sám o sobě není chyba. Pomáhá načítat pouze data, která aplikace potřebuje. Problém vzniká tehdy, když se odložené načítání opakuje mnohokrát a vytváří zbytečné databázové operace.
Jak odhalit problém N+1 dotazů?
Prvním krokem je zjistit, kolik databázových dotazů aplikace skutečně provádí. Samotný pohled na kód v Pythonu často nestačí.
Existuje několik způsobů, jak problém odhalit.
Django Debug Toolbar je užitečný nástroj při lokálním vývoji. Dokáže zobrazit SQL dotazy provedené při načítání stránky, jejich počet i dobu vykonávání. Pokud se stejný nebo velmi podobný dotaz opakuje desítky či stovkykrát, může to signalizovat problém.
Logování SQL dotazů pomáhá při podrobnější diagnostice. Vývojář může sledovat, které dotazy se provádějí, jak často a jak dlouho trvají. V produkčním prostředí je potřeba logování nastavit rozumně, aby samo nevytvářelo zbytečnou zátěž a neodhalovalo citlivé údaje.
Testy počtu dotazů umožňují odhalit regresi výkonu ještě před nasazením. Django například nabízí metodu assertNumQueries(), pomocí které lze v testech ověřit, že konkrétní operace neprovádí neočekávaně velké množství databázových dotazů.
Profilování pomalých stránek a API endpointů pomáhá určit, kde bude mít optimalizace největší přínos. U API se vyplatí zkontrolovat také serializéry, protože při zpracování jednotlivých objektů mohou přistupovat k souvisejícím záznamům a spouštět další dotazy.
Ne každý opakovaný dotaz automaticky znamená problém N+1 a ne každý pomalý endpoint jím trpí. Důležité je posuzovat počet dotazů v kontextu množství zpracovávaných dat a sledovat, kolik času skutečně zabírají.
Jak problémům N+1 předcházet?
Nejlepší optimalizace je ta, kterou není potřeba řešit až poté, co si uživatelé začnou stěžovat na pomalou aplikaci. Několik dobrých návyků dokáže riziko problémů N+1 výrazně snížit.
- Myslete na vztahy v databázi už při návrhu funkce. Pokud víte, že stránka bude zobrazovat objednávky i jména uživatelů, naplánujte předem, jak potřebná související data načíst efektivně.
- Používejte select_related() a prefetch_related() tam, kde to dává smysl. Nespoléhejte na to, že ORM automaticky zvolí nejefektivnější způsob načítání všech potřebných dat. Zároveň ale tyto metody nepřidávejte bez rozmyslu – načítání velkého množství nepotřebných dat může být stejně neefektivní.
- Kontrolujte šablony a serializéry. Problém N+1 nemusí vznikat přímo ve view. Může se objevit také v kódu, který zpracovává jednotlivé objekty při vykreslování stránky nebo při vytváření odpovědi API.
- Testujte s realistickým množstvím dat. U pěti uživatelů může být rozdíl mezi šesti a dvěma dotazy téměř nepostřehnutelný. U stovek nebo tisíců záznamů už může být významný. Vyplatí se sledovat nejen počet dotazů, ale také jejich dobu vykonávání a objem načtených dat.
- Průběžně sledujte výkon. Kontrola počtu dotazů v testech, profilování a pravidelná analýza pomalých endpointů pomáhají odhalit problém dříve, než se projeví při vyšší zátěži.
Závěr: Méně dotazů může znamenat rychlejší aplikaci
Problém N+1 dotazů je dobrým příkladem toho, jak se může nenápadný detail v kódu postupně změnit ve výkonnostní problém. Aplikace může fungovat správně, ale s rostoucím množstvím dat vykonávat zbytečně mnoho práce.
V Djangu často stačí pochopit, kdy se data načítají, a správně použít select_related() nebo prefetch_related(). Místo jednoho úvodního dotazu a desítek či stovek dalších tak aplikace dokáže získat potřebná data pomocí několika efektivnějších operací.
Optimalizace databáze totiž není jen o tom, aby byl každý dotaz rychlý. Stejně důležité je omezit počet dotazů, které aplikace vůbec potřebuje provést.