Praktický návod
Jak vyřešit N+1 problém v Doctrine
Nezačínej slepým přidáváním JOINů. Nejdřív změř dotazy a potom načti přesně ta data, která daný případ použití potřebuje.
Nejdřív stručně
Proč jeden seznam spouští desítky dotazů?
Doctrine ORM načte hlavní entity jedním dotazem. Když potom v cyklu sáhneš na lazy vazbu každé entity, spustí další dotaz pro každý řádek. Jeden dotaz plus N dalších je problém N+1.
Správná oprava závisí na tvaru dat. Vazbu na jednu entitu často vyřeší fetch JOIN, ale kolekce mohou násobit řádky a rozbít stránkování. U nich bývá bezpečnější stránkovat hlavní entity a související data načíst druhým dávkovým dotazem.
Připrav si
Co budeš potřebovat
Potřebuješ konkrétní pomalou stránku nebo use case. Bez měření snadno optimalizuješ vztah, který problém nezpůsobuje.
- Symfony projekt s Doctrine ORM a reprezentativními testovacími daty.
- Symfony profiler nebo SQL logger, ve kterém uvidíš počet a podobu dotazů.
- Konkrétní repository metodu a místo, které prochází entity nebo jejich kolekce.
- Test očekávaného výsledku; optimalizace nesmí změnit oprávnění, pořadí ani počet položek.
Kroky 1 až 3
Najdi a odstraň nadbytečné dotazy
Cílem není jediný dotaz za každou cenu. Cílem je malý a předvídatelný počet dotazů bez zbytečně velkého výsledku.
1. Potvrď N+1 měřením
- Otevři problémovou stránku v dev prostředí a v profileru projdi Doctrine dotazy.
- Hledej jeden dotaz na seznam a potom opakovaný dotaz se stejným SQL, ve kterém se mění jen identifikátor.
- Najdi řádek aplikace, který lazy loading spouští. Často je to getter asociace uvnitř šablony, serializeru nebo mapování na DTO.
- Zapiš si výchozí počet dotazů a velikost výsledku. Bez této hodnoty opravu spolehlivě neověříš.
php bin/console debug:config doctrine dbal Oficiální Symfony dokumentace k profileru 2. Zvol načtení podle typu vazby
- Pro vazbu many-to-one nebo one-to-one přidej JOIN a zahrň alias vazby do SELECTu. Doctrine ji pak načte jako fetch JOIN bez dalšího dotazu.
- Pro čtecí obrazovku, která nepotřebuje celé entity, zvaž DTO projekci. Vybere jen potřebné sloupce a neaktivuje lazy vazby později.
- U kolekce one-to-many nepovažuj fetch JOIN za univerzální řešení. Násobí řádky hlavní entity, zvyšuje paměť a komplikuje LIMIT i OFFSET.
- Stránkuj nejdřív identifikátory hlavních entit. Kolekce pro danou stránku načti druhým dotazem s IN (:ids) a seskup je v aplikaci.
SELECT o, c FROM App\Entity\Order o JOIN o.customer c WHERE o.id = :id Oficiální Doctrine dokumentace k JOINům v DQL 3. Uzavři načítání do repository
- Vytvoř repository metodu pojmenovanou podle use case, například findOrdersForOverview(). Tvar načtení tak nebude schovaný v šabloně.
- Vrať entity s explicitně načtenými vazbami nebo samostatné read DTO. Nekombinuj oba přístupy bez jasného důvodu.
- Pro stránkovaný fetch JOIN kolekce použij Doctrine Paginator a ověř jeho nastavení; u složitých dotazů raději použij dvoufázové načtení.
- Přidej integrační test repository a rozumný limit počtu dotazů v testu obrazovky, která už jednou regresi způsobila.
php bin/phpunit --filter OrderOverview Oficiální Doctrine dokumentace ke stránkování Krok 4
Ověř počet dotazů i správnost dat
Rychlejší SQL nestačí. Výsledek musí zůstat úplný, seřazený a správně stránkovaný.
-
Porovnej profiler před a po změně
Pro stejnou stránku a stejná data ověř, že zmizela opakovaná SQL a počet dotazů už neroste s počtem položek.
-
Otestuj více velikostí stránky
Vyzkoušej první, prostřední i poslední stránku. Žádná hlavní entita nesmí kvůli JOINu zmizet ani se objevit dvakrát.
php bin/phpunit --filter Pagination -
Změř objem a čas dotazů
Sleduj nejen počet SQL, ale i počet vrácených řádků, paměť a čas hydratace. Jeden obrovský JOIN může být horší než dva malé dotazy.
Když to zlobí
Nejčastější chyby
JOIN je v DQL, ale další dotazy stále vznikají
Samotný JOIN nemusí asociaci načíst. U fetch JOINu přidej alias asociace do SELECTu a ověř v profileru skutečný výsledek.
Po JOINu chybí položky nebo nesedí stránkování
To-many kolekce násobí SQL řádky před aplikací limitu. Stránkuj hlavní entity odděleně a kolekce načti dávkově, případně správně použij Doctrine Paginator.
Jeden dotaz spotřebuje příliš paměti
Nenačítej celý graf entit. Použij menší DTO projekci nebo dva cílené dotazy místo širokého JOINu několika kolekcí.
N+1 vzniká až při serializaci
Serializer prochází lazy vazby, které repository nepřipravilo. Vrať explicitní výstupní DTO a mapuj jen pole patřící do kontraktu odpovědi.
Hotovo
Počet dotazů je teď předvídatelný.
Doctrine ORM načítá data podle konkrétního use case, ne náhodou při průchodu objekty. Stejné měření přidej ke každé důležité seznamové obrazovce.