HEAPS

Wydajność Doctrine w Symfony: napraw wolne zapytania zanim skalujesz

Praktyczne wskazówki Doctrine ORM dla aplikacji Symfony: koniec z N+1, rozsądne DQL i QueryBuilder, indeksy oraz pomiar zanim przepiszesz warstwę danych.

Wolne aplikacje Symfony to często wolne bazy w przebraniu

Gdy strona w Symfony wydaje się ciężka, zespoły dokupują workery FPM, większy serwer albo warstwę cache. To może pomóc. Często prawdziwy koszt siedzi w Doctrine: za dużo zapytań na request, zbyt duża hydratacja, brakujące indeksy albo repozytoria, które ładują pół katalogu, żeby wyrenderować dziesięć produktów.

Ten przewodnik skupia się na zmianach, które zwykle dają najlepszy zwrot, zanim wprowadzisz Elasticsearch, CQRS albo przepisanie.

Najpierw mierz w Symfony profilerze

Zgadywanie jest drogie. Otwórz Symfony profiler na wolnej stronie i sprawdź:

  1. Ile zapytań SQL poszło.
  2. Które zapytanie trwało najdłużej.
  3. Czy ten sam SELECT powtarza się w pętli.
  4. Jak duże są hydratowane obiekty.

W testach automatycznych albo na stagingu logowanie SQL Doctrine oraz profile Blackfire / Tideways łapią regresje wcześnie. Najpierw napraw największych winowajców. Jedno N+1 na liście często bije tydzień mikrooptymalizacji gdzie indziej.

Zabij wzorzec N+1

Klasyczna pułapka wygląda niewinnie:

$orders = $orderRepository->findBy(['status' => 'paid']);

foreach ($orders as $order) {
    echo $order->getCustomer()->getEmail();
}

Jeśli customer jest lazy loaded, każda iteracja pętli może odpalić kolejne zapytanie. Napraw to jawnym joinem i addSelect:

public function findPaidWithCustomer(): array
{
    return $this->createQueryBuilder('o')
        ->addSelect('c')
        ->innerJoin('o.customer', 'c')
        ->andWhere('o.status = :status')
        ->setParameter('status', 'paid')
        ->getQuery()
        ->getResult();
}

Dla kolekcji działa ten sam pomysł z leftJoin i ostrożnym DISTINCT tylko wtedy, gdy duplikaty naprawdę się pojawiają.

Preferuj konkretne zapytania zamiast nawyków findAll()

Repozytoria zwracające pełne encje wszędzie stają się trudne do optymalizacji. Na ekranach list pobieraj tylko to, czego widok potrzebuje:

public function listProductCards(int $limit = 20): array
{
    return $this->createQueryBuilder('p')
        ->select('p.id', 'p.name', 'p.slug', 'p.price')
        ->andWhere('p.active = true')
        ->orderBy('p.createdAt', 'DESC')
        ->setMaxResults($limit)
        ->getQuery()
        ->getArrayResult();
}

getArrayResult() albo hydrator DTO omija budowanie ciężkiego grafu obiektów, gdy renderujesz same karty. Pełne encje zostaw ścieżkom zapisu i stronom szczegółów, gdzie naprawdę potrzebujesz modelu domenowego.

Używaj indeksów dopasowanych do realnych filtrów

Mapowania Doctrine nie zastępują projektowania bazy. Jeśli filtrujesz zamówienia po statusie i dacie, dodaj indeks złożony odwzorowujący ten wzorzec dostępu:

#[ORM\Entity]
#[ORM\Table(name: 'orders')]
#[ORM\Index(columns: ['status', 'created_at'], name: 'idx_orders_status_created')]
class Order
{
    // ...
}

Potem sprawdź to przez EXPLAIN. Indeks, który wygląda dobrze w teorii, ale nie pasuje do WHERE i ORDER BY, nadal zostawia skany sekwencyjne na dużych tabelach.

Zapisuj wsadowo i unikaj puchnięcia identity map

Importy i masowe update’y nie powinny trzymać tysięcy encji w pamięci:

$batchSize = 100;
$i = 0;

foreach ($rows as $row) {
    $product = new Product($row['sku'], $row['name']);
    $em->persist($product);

    if (++$i % $batchSize === 0) {
        $em->flush();
        $em->clear();
    }
}

$em->flush();
$em->clear();

clear() resetuje identity map, więc pamięć zostaje płaska. Bez tego długie komendy CLI rosną, aż worker padnie.

Second level cache i HTTP cache to różne narzędzia

Doctrine second level cache pomaga przy danych referencyjnych rzadko zmienianych (kraje, stawki VAT, flagi funkcji). Nie uratuje źle zaprojektowanej listy produktów.

Dla stron preferuj HTTP caching, Symfony HttpCache albo reverse proxy, gdy HTML lub JSON jest już tani w budowie. Warstwy cache wzmacniają dobre zapytania. Wzmacniają też złe, jeśli cache’ujesz stampede missy.

Praktyczna checklista przed każdym releasem

  1. Profiluj najgorętsze strony i endpointy API.
  2. Upewnij się, że listy używają joinów albo dedykowanych zapytań odczytu.
  3. Potwierdź, że indeksy istnieją dla nowych filtrów i sortowań.
  4. Ogranicz paginację i zabroń nieograniczonych eksportów w żądaniach web.
  5. Przenieś ciężkie raporty do workerów Messenger albo na replikę odczytu.

Podsumowanie

Doctrine nie jest wolne domyślnie. Wolne są nieograniczone repozytoria, lazy loading w pętlach i brakujące indeksy. Traktuj Symfony profiler jako część pracy nad funkcją, nie jako narzędzie awaryjne. Większość aplikacji Symfony dostaje widoczny zysk szybkości z czystszych zapytań na długo przed potrzebą nowej architektury.