Blog
Jak wykrywać i naprawiać problem N+1 zapytań w Doctrine
Problem N+1 może zamienić niewinną pętlę Doctrine w setki zapytań do bazy danych. Ten przewodnik pokazuje, jak wykryć, odtworzyć i naprawić problem bez zbyt dużego JOIN-a lub nadmiernej hydratacji encji.
N+1 to problem projektu dostępu do danych
Lista może zwracać poprawną treść, a mimo to wykonywać zbyt wiele zapytań do bazy. N+1 opisuje częsty mechanizm: jedno zapytanie pobiera rekordy, a przetwarzanie każdego z nich uruchamia kolejne zapytanie o powiązane dane. Skrót 1 + N pomaga rozpoznać problem, ale nie przewiduje dokładnej liczby zapytań w każdej aplikacji.
W Doctrine często oznacza to rozbieżność między zapytaniem repozytorium a potrzebami kodu, który korzysta z jego wyniku. Dodatkowy SQL może pojawić się dopiero w serwisie, mapperze, szablonie Twig lub serializerze. Leniwe ładowanie jest przydatne. Koszt łatwo przeoczyć wtedy, gdy nikt nie sprawdza całej ścieżki odczytu.
Posty, autorzy i tłumaczenia
Rozważmy przykładową listę opublikowanych postów. Każdy post ma jednego wymaganego autora i kolekcję tłumaczeń. Fragment encji pokazuje tylko te relacje:
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
class Post
{
#[ORM\ManyToOne(targetEntity: User::class, fetch: 'LAZY')]
#[ORM\JoinColumn(nullable: false)]
private User $author;
/** @var Collection<int, PostTranslation> */
#[ORM\OneToMany(
mappedBy: 'post',
targetEntity: PostTranslation::class,
fetch: 'LAZY',
)]
private Collection $translations;
public function __construct(User $author)
{
$this->author = $author;
$this->translations = new ArrayCollection();
}
}Pominięto atrybut encji, identyfikator, pozostałe pola, metody dostępu oraz mapowanie strony właścicielskiej PostTranslation::$post. Konstruktor inicjalizuje kolekcję dla nowego obiektu. Klasa Post nie jest final, dzięki czemu przykład pasuje również do konfiguracji z generowanymi klasami proxy dziedziczącymi po encji; natywne obiekty leniwe mają inne ograniczenia.
Załóżmy, że translationFor($locale) przeszukuje kolekcję tłumaczeń w pamięci i zwraca tłumaczenie albo null. Poniższy celowo uproszczony odczyt zakłada, że każdy wybrany post ma tłumaczenie w żądanym języku:
$posts = $postRepository->findBy(
['published' => true],
['publishedAt' => 'DESC', 'id' => 'DESC'],
);
$result = [];
foreach ($posts as $post) {
$result[] = [
'title' => $post->translationFor($locale)->getTitle(),
'author' => $post->getAuthor()->getDisplayName(),
];
}Odczyt nazwy niezaładowanego autora może zainicjalizować jego obiekt. Przeszukanie niezaładowanej kolekcji może pobrać tłumaczenia danego posta. Dla poniższego zestawienia przyjmijmy świeży EntityManager, innego autora dla każdego posta, brak wyników w pamięci podręcznej i po jednym zapytaniu na każdy taki odczyt relacji:
Przykładowa liczba zapytań SQL, nie wynik pomiaru
Posty Lista Autorzy Tłumaczenia Razem
5 1 5 5 11
50 1 50 50 101
500 1 500 500 1001Stąd bierze się przykład „około 101 zapytań dla 50 postów”. Nie jest to wynik gwarantowany. Doctrine może ponownie wykorzystać wspólnych autorów dzięki mapie tożsamości, czyli rejestrowi zarządzanych obiektów według ich identyfikatorów w obrębie EntityManagera. Znaczenie mają też wcześniej załadowane relacje, mapowania, sposób pobierania danych oraz cache wyników lub drugiego poziomu. Cache metadanych i zapytań DQL nie przechowuje natomiast samych zwróconych danych biznesowych.
Pętla zakłada również obecność tłumaczenia i pobiera wszystkie opublikowane posty. To uproszczenia diagnostyczne, nie gotowy kontrakt publicznej listy.
Najpierw potwierdź, co się powtarza
W Symfony Profiler sprawdź reprezentatywne żądanie: liczbę zapytań Doctrine, łączny czas bazy oraz powtarzające się kształty SQL. Sygnatura zapytania, nazywana też fingerprintem, grupuje instrukcje o tej samej strukturze, lecz różnych parametrach. Stos wywołań, jeśli jest dostępny, pomaga powiązać zapytanie z metodą odczytującą relację.
Mała baza lokalna, niskie opóźnienia i obiekty pozostałe w EntityManagerze po przygotowaniu danych testowych mogą ukryć problem. Porównaj na przykład 5, 50 i 500 rekordów w obsługiwanym zakresie obciążenia, przy powtarzalnym stanie EntityManagera i cache. Szukaj zapytań dodawanych dla każdego elementu. Nie każdy scenariusz wymaga bezwzględnie stałej liczby instrukcji: przy dzieleniu danych na partie kolejne zapytanie może pojawić się na granicy partii.
Mechanizm profilowania lub logowania musi odpowiadać wersjom DBAL i DoctrineBundle w projekcie. W nowoczesnym DBAL służy do tego middleware; stare przykłady z SQLLogger nie są przenośne do DBAL 4. Kolektor testowy powinien obejmować badaną operację, a nie przygotowanie danych czy uruchamianie frameworka.
Sprawdź także miejsce wywołania. Te operacje mogą odpytać bazę, gdy odpowiednie obiekty lub kolekcje nie zostały jeszcze załadowane:
$post->getAuthor()->getDisplayName();
$post->getTranslations()->first();
count($post->getComments());Samo zwrócenie proxy przez getAuthor() nie musi go inicjalizować; odczyt pola innego niż identyfikator zwykle już tak. Przy zwykłej leniwej kolekcji first() lub count() mogą wczytać jej zawartość. getComments() odnosi się tu do dodatkowej, przykładowej relacji, której nie pokazano w encji. Taki sam efekt może wywołać mapper, debugger albo formatowanie wpisu w logu, jeśli odczytuje te wartości.
Trzy sposoby przygotowania danych listy
Fetch join: encje z wybranymi relacjami
Ta metoda repozytorium nadal zwraca encje, ale pobiera od razu autora i tłumaczenia. Pominięto konfigurację repozytorium i importy:
/**
* @return list<Post>
*/
public function findPublishedWithAuthorAndTranslations(): array
{
return $this->createQueryBuilder('post')
->addSelect('author')
->addSelect('translation')
->innerJoin('post.author', 'author')
->leftJoin('post.translations', 'translation')
->andWhere('post.published = :published')
->setParameter('published', true)
->orderBy('post.publishedAt', 'DESC')
->addOrderBy('post.id', 'DESC')
->getQuery()
->getResult();
}Przy hydratacji obiektowej wybranie aliasów przez addSelect() sprawia, że są to fetch joiny: Doctrine uzupełnia relacje podczas tworzenia wyniku. Sama relacja użyta w JOIN-ie do filtrowania nie musi zostać załadowana. Wewnętrzne złączenie autora odpowiada założeniu, że autor jest wymagany; przy autorze opcjonalnym trzeba dostosować złączenie i kod korzystający z wyniku. Lewe złączenie tłumaczeń zachowuje posty bez tłumaczenia, więc obsługa takiego braku nadal jest potrzebna.
Metoda celowo nie ma limitu strony i pobiera wszystkie tłumaczenia pasujących postów. Nadaje się do znanego, małego zbioru albo jako punkt wyjścia do stronicowania opisanego dalej, nie do nieograniczonej listy publicznej. Sortowanie po publishedAt, a następnie id ustala jednoznaczną kolejność postów. Nie ustala kolejności tłumaczeń wewnątrz kolekcji.
Ograniczenie tego fetch joina do jednego języka sprawiłoby, że załadowana kolekcja zawierałaby tylko wybrany podzbiór. Kod oczekujący wszystkich tłumaczeń mógłby błędnie uznać ją za kompletną. Lista w jednym języku często czytelniej wyraża swoje potrzeby przez projekcję.
Projekcja DTO: tylko dane potrzebne na liście
Jeżeli widok potrzebuje czterech wartości, nie musi dostawać edytowalnego grafu encji. To kompletna deklaracja DTO dla PHP 8.2 lub nowszego:
namespace App\ReadModel;
final readonly class PostListItem
{
public function __construct(
public int $id,
public string $title,
public string $authorName,
public \DateTimeImmutable $publishedAt,
) {
}
}W repozytorium potrzebny jest import App\ReadModel\PostListItem. Argumenty wyrażenia konstruktorowego DQL odpowiadają kolejności parametrów DTO:
/**
* @return list<PostListItem>
*/
public function findPublishedList(
string $locale,
int $limit,
): array {
if ($limit < 1) {
throw new \InvalidArgumentException('limit >= 1');
}
return $this->createQueryBuilder('post')
->select(sprintf(
'NEW %s(
post.id,
translation.title,
author.displayName,
post.publishedAt
)',
PostListItem::class,
))
->innerJoin('post.author', 'author')
->innerJoin(
'post.translations',
'translation',
'WITH',
'translation.locale = :locale',
)
->andWhere('post.published = :published')
->setParameter('published', true)
->setParameter('locale', $locale)
->orderBy('post.publishedAt', 'DESC')
->addOrderBy('post.id', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}Przykład zakłada całkowitoliczbowe post.id, tytuły i nazwy autorów bez wartości null oraz mapowanie publishedAt jako datetime_immutable z datą dla każdego wybranego posta. Inne mapowanie wymaga zgodnych typów konstruktora; DTO nie zamienia dowolnego tekstu na obiekt daty.
Warunek języka wybiera odpowiednie tłumaczenie. Ograniczenie unikalności pary post–język w bazie musi zapewnić najwyżej jeden pasujący rekord. Przy tym założeniu i jednym autorze zapytanie daje jeden DTO na pasujący post, więc limit dotyczy elementów listy. Bez tej reguły powielone tłumaczenia mogą powielić DTO i zmniejszyć liczbę różnych postów na stronie.
Wewnętrzne złączenie celowo pomija posty bez tłumaczenia w żądanym języku. Jeżeli produkt wymaga języka zastępczego albo pozycji bez tytułu, trzeba zdefiniować to osobno: samo lewe złączenie wymagałoby dopuszczenia null w tytule lub odpowiedniego wyrażenia zastępczego. Metoda zwraca pierwszy ograniczony zestaw. Kolejne strony potrzebują przesunięcia lub kursora oraz tej samej jednoznacznej kolejności.
Projekcja tworzy DTO z wybranych wartości pól, zamiast hydratować encje. Odczyt tych właściwości nie uruchomi ładowania relacji. Mniej kolumn i brak zarządzanego grafu encji mogą ograniczyć pamięć oraz koszt hydratacji, ale złączenia nadal kosztują. DTO nie gwarantuje szybszego zapytania i nie zastępuje encji potrzebnych do wykonywania operacji domenowych.
Ładowanie partiami: dane powiązane dla wybranej strony
Jeśli łączenie kolekcji nadmiernie powiększa wynik, można najpierw pobrać stronę, a następnie dane powiązane dla jej identyfikatorów. W tym przykładzie projektowa metoda findPublishedPage() musi zwracać uporządkowane list<Post> z autorami już pobranymi przez złączenie relacji do jednego obiektu. $page i $limit są zwalidowane, a getId() dla zapisanych postów zwraca int.
$posts = $postRepository->findPublishedPage($page, $limit);
$postIds = array_map(
static fn (Post $post): int => $post->getId(),
$posts,
);
$translations = $postIds === []
? []
: $translationRepository->findIndexedByPostIds(
postIds: $postIds,
locale: $locale,
);
$result = [];
foreach ($posts as $post) {
$result[] = [
'title' => $translations[$post->getId()] ?? null,
'author' => $post->getAuthor()->getDisplayName(),
];
}findIndexedByPostIds() to przykładowa metoda repozytorium, nie API Doctrine. Jej kontrakt to tutaj array<int, string>: identyfikator posta i tytuł w żądanym języku, pobrane jednym zapytaniem dla przekazanej grupy identyfikatorów. Muszą obowiązywać te same ograniczenia dostępu i unikalność tłumaczeń. Końcowa pętla czyta mapę, a nie wywołuje translationFor(); brak tytułu reprezentuje null.
Przy tych założeniach niepusta strona wymaga dwóch zapytań o dane. Osobne liczenie wyników, inne relacje lub podział dużej listy parametrów na partie mogą zwiększyć tę liczbę. Zapytanie dla każdego identyfikatora wewnątrz drugiej metody tylko przeniosłoby N+1 w inne miejsce.
Strategie ładowania, mnożenie wierszy i paginacja
LAZY odracza ładowanie relacji do momentu, gdy jest potrzebna. EAGER żąda wcześniejszego pobrania, ale nie oznacza „jednego JOIN-a”: zależnie od mapowania i sposobu odczytu Doctrine może użyć złączeń lub dodatkowych zapytań. Ustawienie wszystkich relacji jako eager może obciążyć komendy i ekrany, które ich nie potrzebują.
Dla kolekcji EXTRA_LAZY pozwala wykonywać obsługiwane operacje, takie jak count(), contains() czy slice(), bez pobierania wszystkich elementów. Nie dotyczy to dowolnej metody kolekcji. Iteracja nadal ją ładuje, a policzenie 50 niezaładowanych kolekcji może wykonać 50 zapytań. Zakres obsługiwanych operacji należy sprawdzić dla używanej wersji ORM.
Złączenie kilku niezależnych kolekcji niesie inne ryzyko. Dla posta z 4 tłumaczeniami, 20 komentarzami i 8 tagami połączenie wszystkich kombinacji może dać 4 × 20 × 8 = 640 wierszy SQL. Wynik zależy od filtrów, rodzajów złączeń i rzeczywistej liczebności relacji. To nie jest 640 różnych postów: zwykła hydratacja obiektowa scala encje główne według tożsamości, ale praca bazy, transfer wierszy i hydratacja nadal zużywają zasoby.
DISTINCT usuwa identyczne wybrane wiersze SQL tam, gdzie takie występują. Wiersze z różnymi tłumaczeniami, komentarzami czy tagami nie są identyczne, więc nie znika przez to koszt ich mnożenia.
Bezpośredni limit i przesunięcie przy fetch joinie do kolekcji ograniczają wiersze SQL, nie kompletne posty wraz z kolekcjami. Strona może zawierać mniej postów lub niepełne dane kolekcji. Potrzebny jest mechanizm paginacji zgodny z wersją Doctrine, odpowiednie ustawienie pobierania kolekcji i właściwe dla zapytania mechanizmy przekształcania go, czyli query walkery. Dodatkowe zapytania liczące i wybierające identyfikatory też należy uwzględnić w pomiarze.
Można też jawnie wybrać na przykład 25 unikalnych identyfikatorów postów w kolejności strony, a potem pobrać ich szczegóły. Obie fazy muszą zachować spójne filtry i ograniczenia dostępu. IN (:ids) nie zachowuje kolejności przekazanych wartości: trzeba ponownie zastosować sortowanie lub ułożyć wynik według listy identyfikatorów. Jeśli znaczenie mają zmiany danych między odczytami, należy określić wymagany poziom izolacji transakcji lub spójność migawki.
Złączenie relacji do jednego obiektu zwykle nie mnoży wierszy, lecz inne złączenia w tym samym zapytaniu nadal mogą to robić. Sortowanie po kolekcji wymaga wcześniejszego ustalenia reguły, na przykład daty najnowszego komentarza. Paginacja kursorowa oparta na kluczu, czyli keyset, może pomóc przy przeglądaniu odległych fragmentów dużej listy, ale nie zapewnia wprost skoku do dowolnego numeru strony ani liczby wszystkich wyników. Wybór zależy także od sposobu korzystania z produktu.
Twig i serializacja należą do ścieżki odczytu
Szablon może ujawnić brak odpowiedniego ładowania danych, choć nie zawiera żadnego SQL:
{% for post in posts %}
{{ post.author.displayName }}
{{ post.translationFor(app.request.locale).title }}
{% endfor %}Twig odczytuje te wartości przez metody obiektu. Obowiązują więc te same założenia o załadowanych relacjach i dostępności tłumaczenia co w pętli PHP. Dane warto przygotować przed renderowaniem: jako świadomie załadowane encje albo model widoku. DTO pomaga ograniczyć zakres dostępnych danych, ale nie jest obowiązkowe dla każdego szablonu.
Kodowanie JSON wymaga osobnego doprecyzowania:
json_encode($entity);Domyślnie json_encode() uwzględnia publiczne właściwości. Nie przechodzi automatycznie po wszystkich prywatnych relacjach Doctrine ani nie wywołuje ich getterów. Relacje może natomiast odczytywać JsonSerializable::jsonSerialize() lub inny własny kod przygotowujący reprezentację. Symfony Serializer używa normalizerów: normalizer obiektów może czytać gettery lub właściwości dostępne zgodnie z konfiguracją i grupami serializacji. Znaczenie mają więc konkretny normalizer i kontekst, nie tylko kontroler.
Pomiar zapytań dla odpowiedzi HTTP powinien obejmować także renderowanie lub normalizację. Test samego repozytorium może nie zauważyć zapytań wykonanych później.
Testy regresji z określonym zakresem pomiaru
Poniższe przykłady Pest są szkicami testów integracyjnych. createPublishedPosts(), createPostFixtures(), entityManager(), doctrineQueryCollector(), service() i repository() to pomocnicze metody projektu. reset(), start(), stop() oraz queryCount() opisują przykładowy kontrakt kolektora, a nie wbudowane metody Pest czy Doctrine. Kolektor ma liczyć wykonane instrukcje na właściwym połączeniu. Przy implementacji opartej na middleware pomiar trzeba skonfigurować przed utworzeniem połączenia.
Środowisko testowe musi izolować zestawy danych, kontrolować cache i przygotowywać serwisy bez wcześniejszego pobrania listy. Zapisanie danych testowych oraz wyczyszczenie tego samego EntityManagera, którego używa serwis, zapobiega wykorzystaniu obiektów z przygotowania testu. Samo clear() nie czyści cache drugiego poziomu ani aplikacyjnego.
Tutaj getPublishedPosts() ma zwracać gotową listę, a nie iterator odraczający odczyt. Budżet trzech zapytań jest przykładowym ustaleniem dla tej usługi:
it('ogranicza liczbę zapytań', function (int $count): void {
$this->createPublishedPosts(count: $count, locale: 'pl');
$entityManager = $this->entityManager();
$service = $this->service();
$collector = $this->doctrineQueryCollector();
$entityManager->flush();
$entityManager->clear();
$collector->reset();
$collector->start();
try {
$result = $service->getPublishedPosts(
locale: 'pl',
limit: $count,
);
} finally {
$collector->stop();
}
expect($result)->toHaveCount($count)
->and($collector->queryCount())->toBeLessThanOrEqual(3);
})->with([5, 50]);Zestaw danych uruchamia test dla 5 i 50 postów. Różni autorzy i kilka tłumaczeń pomagają uniknąć ukrycia regresji przez ponowne użycie relacji; warto też sprawdzić reprezentatywne przypadki wspólnych autorów. Wynik odroczony, szablon lub serializer trzeba przetworzyć wewnątrz mierzonego zakresu. Limit zapytań chroni przed powtarzanymi wywołaniami bazy, ale nie jest testem czasu odpowiedzi ani pamięci.
Kolejny test rozdziela utworzenie wyniku repozytorium od późniejszego odczytu właściwości:
it('odczytuje DTO bez dodatkowego SQL', function (): void {
$this->createPostFixtures(count: 20, locale: 'pl');
$entityManager = $this->entityManager();
$repository = $this->repository();
$collector = $this->doctrineQueryCollector();
$entityManager->flush();
$entityManager->clear();
$items = $repository->findPublishedList('pl', 20);
expect($items)->toHaveCount(20)
->each->toBeInstanceOf(PostListItem::class);
$collector->reset();
$collector->start();
try {
foreach ($items as $item) {
expect($item->title)->toBeString()
->and($item->authorName)->toBeString();
}
} finally {
$collector->stop();
}
expect($collector->queryCount())->toBe(0);
});Sprawdza liczbę i typ DTO, a następnie potwierdza, że odczyt tytułu i autora nie wykonuje kolejnego SQL. Nie mierzy kosztu samego findPublishedList(). Osobnych asercji ze znanymi wartościami wymagają wybór języka, brak tłumaczenia, kolejność, granice stron i unikalność identyfikatorów. Sam typ obiektu nie potwierdza tych zachowań.
Kontrakty typów i przegląd zmian proponowanych przez AI
PHPStan może sprawdzać zgodność implementacji i wywołań z zadeklarowanym typem wyniku. Taki fragment deklaracji należy umieścić w interfejsie:
/**
* @return list<PostListItem>
*/
public function findPublishedList(
string $locale,
int $limit,
): array;Typy elementów kolekcji i jawne dopuszczenie null ułatwiają wykrywanie pomyłek. PHPStan nie wykonuje SQL i nie wykrywa N+1 bezpośrednio. Dowolne reguły architektury wymagają osobnych kontroli projektu. Analiza statyczna i pomiar podczas wykonania odpowiadają na różne pytania.
Zmianę przygotowaną przez developera lub asystenta AI warto oceniać przez zestawienie dostępu do relacji w pętlach z rzeczywistym mapowaniem i zapytaniem repozytorium. Odczyt w pętli nie jest błędem, jeśli dane zostały już poprawnie załadowane. Zespół powinien umieć wskazać, które zapytanie pobiera potrzebne wartości, czy mapper lub serializer dodaje pracę i czy zachowano sortowanie oraz paginację. Szacowana liczba zapytań to hipoteza; testy i pomiary dostarczają dowodów. Lokalna optymalizacja nie uzasadnia zmiany wszystkich mapowań ładowania.
Sprawdź również zachowanie po wdrożeniu
W produkcji zestawiaj percentyle czasu odpowiedzi z liczbą zapytań, łącznym czasem bazy, powtarzającymi się sygnaturami SQL, wielkością strony i częstością wolnych zapytań. Jeśli narzędzia na to pozwalają, uwzględnij liczbę analizowanych wierszy, hydratację i pamięć. Wiele krótkich instrukcji może łącznie kosztować dużo, dlatego sam dziennik wolnych zapytań nie zawsze ujawni N+1.
Dobierz próbkowanie śladów wykonania i nie zapisuj bez potrzeby wrażliwych parametrów SQL ani danych klientów. Znormalizowany SQL również może zawierać literały lub informacje identyfikujące; sygnatura zapytania nie oznacza automatycznej anonimizacji.
Cache bywa przydatny po zrozumieniu ścieżki odczytu. Trzeba sprawdzić zachowanie przy pustej pamięci podręcznej, unieważnianie wpisów oraz klucze uwzględniające właściwego użytkownika lub organizację. Przeniesienie tej samej pętli do innego serwisu nie zmienia SQL. Globalna zmiana leniwego ładowania może zepsuć istniejący kod, nie definiując lepszego sposobu pobierania danych.
Diagnozę powinno kończyć porównanie: odtworzenie odczytu, wskazanie powtarzanej pracy, wybór najmniejszej uzasadnionej zmiany i ponowna kontrola treści, kolejności, języka oraz paginacji. Liczbę zapytań, czas bazy i pamięć porównuj w podobnych warunkach, a po wdrożeniu obserwuj wynik. Dobra poprawka obsługuje konkretny odczyt przy akceptowalnym łącznym koszcie, nie tylko mniejszej liczbie instrukcji.
Dokumentacja techniczna
- Doctrine ORM: DQL, fetch joiny i wyrażenia konstruktorowe — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/dql-doctrine-query-language.html
- Doctrine ORM: paginacja — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/tutorials/pagination.html
- Doctrine ORM: kolekcje extra-lazy — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/tutorials/extra-lazy-associations.html
- PHP: kodowanie JSON — https://www.php.net/manual/en/function.json-encode.php
