Doctrine ORM to potężne i świetne narzędzie, które wykorzystuję na co dzień w prawie każdym projekcie opartym o PHP. Zanim jednak poznałem wiele jego tajników, popełniałem sporo błędów, których nie do końca byłem świadom. Pisząc tajniki mam na myśli w większości proste usprawnienia, o których najczęściej znaleźć można informacje nawet w samej dokumentacji. Problem w tym, że kto rozpoczynając przygodę z narzędziem, czyta całe docsy od deski do deski.
Tak jak wspomniałem, ja sam wielu tych usprawnień nie znałem przez długi czas. Na niektóre trafiłem przez przypadek, a z niektórymi musiałem się zmierzyć w celu zażegnania problemu. Dzisiaj podam Wam rozwiązanie na jeden z problemów w IT, który występuje również w najpopularniejszym phpowym ORM. I to w dodatku jako domyślna strategia wyciągania danych.
n+1 w Doctrine ORM
Zrozumienie tytułowego problemu ułatwi analiza przykładu w oparciu o proste zapytania SQL. W bazie są dwie tabele połączone relacją jeden-do-jednego: user i role. Wyobraźcie sobie, że istnieje potrzeba wyświetlenia wszystkich użytkowników z ich rolami. No dobra, może nie wyświetlenia, bo zaraz się okaże, że przecież wszyscy robią CQRS i w modelu odczytu nie zapina się Doctrine…
Zatem jeszcze raz. Trzeba dezaktywować wszystkich użytkowników z rolą gościa w systemie. Normalnie poleciałby grupowy update, ale można założyć, że trzeba pobrać te dane do kodu PHP. Przykład jest jak zwykle uproszczony, a zazwyczaj do wykonania będą trudniejsze operacje.
No to co, proste zapytanie do bazy wyciągające wszystkich użytkowników, przelecenie po nich w pętli i sprawdzenie roli. Jeśli jej nazwa to guest – active na 0. Co lepszy developer to jeszcze zawęzi już na poziomie zapytania tylko do gości i oszczędzi sobie ifowania. Jako, że rola jest w osobnej tabelce, wydaje się, że są dwa znane rozwiązania:
1. Wyciągnięcie użytkowników z bazy od razu z rolą używając złączenia tabel (joinów).
SELECT * FROM user u JOIN role r ON u.role_uuid = r.uuid;
2. Wyciągnięcie samych użytkowników z bazy i już w pętli zapytanie o rolę konkretnego użytkownika uderzając z jego id.
SELECT * FROM user;
SELECT * FROM role r WHERE r.uuid = $roleUuid;
Wierzę, że mało jest osób, które zastosują rozwiązanie numer dwa. To właśnie popularny problem n+1. Przy tysiącu użytkowników do bazy danych poleci 1001 zapytań. Gdzie w podejściu numer jeden niezależnie od liczby rekordów zawsze uda się wyciągnąć wszystkie dane jednym zapytaniem.
I teraz sedno tego wpisu. Doctrine domyślnie działa przy użyciu rozwiązania numer 2… Ten mechanizm nazywany jest Lazy Loadingiem, czyli w momencie pobierania danych, do relacyjnych obiektów tworzone są proxy klasy. Dopiero, kiedy występuje odwołanie do obiektu, to Doctrine uderza do bazy po odpowiedni obiekt.
Poniżej prezentuję ten sam przykład, ale oparty o encje i repozytorium. Prosty model użytkownika i roli oraz jedna metoda w repozytorium do wyciągnięcia wszystkich aktywnych użytkowników. Dla jasności nie prezentuję tu kodu z pętlą, ale myślę że sam opis nie sprawi problemu. W momencie użycia metody $user->isGuest()
pobierana jest rola tym samym leci zapytanie do bazy. I znowu dla 1000 rekordów będzie 1001 zapytań.
<?php
declare(strict_types=1);
namespace Application\Entity;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class User
{
public const STATUS_INACTIVE = 0;
public const STATUS_ACTIVE = 1;
/**
* @ORM\Id
* @ORM\Column(name="uuid", type="uuid", unique=true)
*/
private UuidInterface $id;
/** @ORM\Column(name="email", type="string", nullable=false, length=255) */
private string $email;
/** @ORM\Column(name="status", type="integer", nullable=false) */
private int $status;
/**
* @ORM\OneToOne(targetEntity="Role", cascade={"persist"})
* @ORM\JoinColumn(name="role_uuid", referencedColumnName="uuid")
*/
private Role $role;
public function __construct(string $email, Role $role)
{
$this->id = Uuid::uuid4();
$this->email = $email;
$this->status = self::STATUS_INACTIVE;
$this->role = $role;
}
public function isGuest(): bool
{
return $this->role->getName() === 'guest';
}
}
<?php
declare(strict_types=1);
namespace Application\Entity;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class Role
{
public const ROLE_GUEST = 'guest';
/**
* @ORM\Id
* @ORM\Column(name="uuid", type="uuid", unique=true)
*/
private UuidInterface $id;
/** @ORM\Column(name="name", type="string", nullable=false, length=64) */
private string $name;
public function __construct(string $name)
{
$this->id = Uuid::uuid4();
$this->name = $name;
}
public function getName(): string
{
return $this->name;
}
}
<?php
declare(strict_types=1);
namespace Application\Repository;
use Application\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
class UserRepository
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
public function findActiveUsers(): array
{
return $this->entityManager
->createQueryBuilder()
->select('u')
->from(User::class, 'u')
->where('u.status = :status')
->setParameter('status', User::STATUS_ACTIVE)
->getQuery()
->getResult();
}
}
Na szczęście istnieją proste rozwiązania tego problemu. Jedno z nich prezentuje poniższe usprawnienie. Domyślna strategia to wspomniana już wcześniej fetch="LAZY"
. Jak możecie zauważyć, w miejscu opcji relacji dorzuciłem fetch="EAGER"
.
Oznacza to mniej więcej tyle, że teraz za każdym razem podczas wyciągania obiektu User dociągnięty zostanie też obiekt Role. W takim przypadku dla każdej encji w relacji wykona się jeszcze jedno dodatkowe zapytanie, a że w tym przykładzie jest jedna to liczba zapytań wyniesie 2 zamiast 1001 przy tysiącu użytkownikach.
/**
* @ORM\OneToOne(targetEntity="Role", cascade={"persist"}, fetch="EAGER")
* @ORM\JoinColumn(name="role_uuid", referencedColumnName="uuid")
*/
private Role $role;
Spora optymalizacja i zazwyczaj opłacalna. Kiedy zatem nie wyjdzie na plus? Opierając się dalej na tym samym przykładzie, łatwo wyobrazić sobie przypadek, w którym nie potrzebne będą role. Wystarczą same imiona i nazwiska użytkowników, a Doctrine i tak dociągnie role wykonując jedno zapytanie więcej. A tutaj mowa tylko o pojedynczej relacji. W praktyce może się to skończyć na kilku zapytaniach w bonusie. Oczywiście jeżeli wszędzie zastosowana byłaby strategia EAGER.
Od razu powiem, że to rozwiązanie gości w moim kodzie nieco rzadziej. W zasadzie to korzystam z niego tylko wtedy, jeśli obiekty są nierozłączne i wcale albo prawie wcale nie są wykorzystywane bez siebie. Takie przypadki mimo wszystko występują, dlatego wspominam o tym podejściu.
Na szczęście istnieje alternatywa, która jest bardziej konfigurowalna. Powyżej była mowa tylko o zmianie domyślnego działania. Poniżej za to istnieje możliwość swobodnego sterowania przy okazji zostawiając decyzję programiście. Oczywiście trzeba wiedzieć, że taki mechanizm istnieje, bo z jednej strony pozwala na wybór, ale z drugiej niczego nie wymusza przez co nieświadome osoby mogą łatwo konstruować słabo wydajne zapytania.
public function findActiveUsers(): array
{
return $this->entityManager
->createQueryBuilder()
->select('u')
->addSelect('r')
->join('u.role', 'r')
->from(User::class, 'u')
->where('u.status = :status')
->setParameter('status', User::STATUS_ACTIVE)
->getQuery()
->getResult();
}
Bohaterem jest tutaj metoda addSelect()
, która pozwala wskazać jakie obiekty dodatkowo mają być załadowane od razu. Domyślnie oczywiście dla wszystkich zostaną stworzone proxy, chyba że korzystają z fetch eager.
Potrzebne tylko imiona i nazwiska? Nie ma kłopotu – bez dodatkowych selectów. Wymagane też role – dodatkowy select. I to jest pożądany efekt – wskazać, co dokładnie ma zostać wyciągnięte z bazy – tak jak w SQL. Dodatkowo tutaj nie ma żadnego extra zapytania, więc dla 1000 rekordów wykona się dokładnie 1.
W bonusie dodam, że można również w konkretnej metodzie do Query Buildera dorzucić metodę setFetchMode()
i zmienić domyślną strategię tylko dla wybranego zapytania. Szczerze mówiąc nigdy tego nie użyłem, gdyż wolę powyższe podejście.
Czy Lazy Loading w Doctrine jest zły?
Jednym słowem: nie! Jak najbardziej rozumiem takie podejście jako domyślne. Tak samo zresztą jest w SQL. Gwiazdka wyciąga dane z jednej tabeli, a nie wszystko z pełnymi relacjami (chyba, że są joiny). Bardzo często się okaże, że po prostu potrzebny jest sam obiekt główny bez relacji.
Mimo wszystko nie będę ukrywał, że gdyby nie addSelect()
to chyba częściej decydowałbym się na fetch="EAGER"
. Szczególnie, że do operacji biznesowych często potrzebne są całe agregaty. Zdecydowanie więcej zagrożeń widzę w n+1, niż wyciąganiu nadmiarowych danych. Na szczęście w łatwy sposób można nad tym panować. Jedyną trudnością jaką dostrzegam to znajomość tego mechanizmu. I z tym Was zostawiam.
Trochę nie na temat, ale boli mnie tworzenie UUID w konstruktorze – wolałbym je tam wstrzyknąć.
Kiedyś się nad tym zastanawiałem i ostatecznie postawiłem na tworzenie w konstruktorze. Oczywiście domena jest uzależniona od zewnętrznej biblioteki do generowania UUID, ale oczywiście jest to łatwo wymienialne bez koniecznej migracji w razie zmiany. A masz jakieś argumenty za podejściem, które proponujesz?
+1 do wstrzykiwania zależności. Użyteczne chociazby wtedy, kiedy UUID jest wygenerowany od razu na requescie (kontrakt oddany autorowi requestu) ale faktyczny zapis do bazy dzieje się asynchroniczne (np. jakiś command bus, worker, cron etc).
Pozdrawiam, Tomek.
Nie chodzi o wykorzystywanie zewnętrznej biblioteki tylko o samo generowanie uuid w konstruktorze. Prosty przykład – mając komendę na tworzenie danego agregatu nie wiesz jaki id on dostanie do póki go nie stworzysz. To podejście jest trochę podobne do autoincrement – agregat jest niepoprawny do momentu zapisania go w bazie. W mojej opinii jest to kiepskie z tego względu ponieważ zakładając że komendy powinny nic zwracać to nie możesz w łatwy sposób odpytac stworzony agreget.
Ma to sens z tej perspektywy o której mówisz. Dla mnie ważniejsze jest, że encja jest w stanie valid od razu po utworzeniu. Jeżeli mowa o komendach – faktycznie posiadanie ID przed stworzeniem agregatu może być pomocne.
Zapomniałem że komentowałem 🙂
Idąc w lekką „przesadę” można wręcz enkapsulować uuid we własnej klasie Id per typ agregatu, wtedy problem uzależniania agregatu od biblioteki znika. Poprzednicy podali dobre argumenty, dodam jeszcze – testowalność.
Coś mi się wydaje, że Doctrine w ostatnich wersjach (przynajmniej w Symfony 5 więc to może framework robi jakieś opakowanie) wrzuca ID rekordów powiązanych do funkcji IN() i wszystkie relacje pobierane są jednym zapytaniem.
Domyślnie nie, bo domyślna strategia to lazy loading. Takie zachowanie można uzyskać zmianą domyślnej strategi czy to dla wszystkich, czy dla konkretnej relacji. Chyba, że chodzi właśnie o taki przypadek, gdzie nie ma lazy loadingu, ale wtedy nie nazwałbym tego domyślnym.
Kiedyś zastanawiałem się nad rozwiązaniem problemu N+1 zapytań w Doctrine w sposób bardziej selektywny, niż po prostu włączenie eager loading dla konkretnej relacji, głównie na potrzeby zwracania danych w API bazującym na GraphQL. Popełniłem wtedy bibliotekę `malef/associate`, która być może się komuś przyda – pozwala uniknąć dodatkowych zapytań bez konieczności tworzenia query dla każdej kombinacji relacji, wspiera też implementację `Deferred` z `webonyx/graphql-php`.
A nie lepiej robić tak?
public function __construct( ?UuidInterface $id = null) {
$this->id = $id ?? Uuid::uuid4();
}
Najlepiej to w ogóle przekazywać id, a nie generować w konstruktorze. Jest to przydatne np. w CQRS, gdzie potrzebujemy coś jeszcze być może zrobić po komendzie, a nie jesteśmy w stanie zwrócić tego id z handlera. Poza tym, często chcemy mieć własny typ
Id
, tak żeby domena była niezależna i wykorzystamy zewnętrzną bibliotekę do wygenerowania, a do domeny przekazujemy już konkretny obiekt wartości z identyfikatorem uuid.public function __construct(Id $id)
{
$this->id = $id;
}
A jak rozwiązać ten problem n+1 w sytuacji kiedy korzystamy z @ORM\DiscriminatorMap ? Korzystam z wersji 2.17 i w takim przypadku dodatkowe zapytania są wykonywane zawsze. Jest jakieś rozwiązanie na to?
A jesteś w stanie konkretne zapytanie dostarczyć albo lepiej opisać co dokładnie robisz? Bo nie złapałem w którym miejscu dokładnie leci extra zapytanie. 🙂
Poniżej na szybko napisany zarys przykładu o który chodzi. W takiej sytuacji pobranie kolekcji SaleDocumentInvoice zawsze skutkuje pobraniem $items co generuje dodatkowe zapytania, nawet jak zrobię tam @Ignore albo ograniczę to grupami w normalizationContext (ApiPlatform) 🙂
/**
* @ORM\InheritanceType(„SINGLE_TABLE”)
* @ORM\DiscriminatorColumn(name=”type”, type=”string”)
* @ORM\DiscriminatorMap({
* „invoice” = „App\Entity\SaleDocumentInvoice”,
* „receiptFiscal” = „App\Entity\SaleDocumentReceiptFiscal”,
* })
*/
abstract class SaleDocument {
protected $number;
/**
* @ORM\OneToMany
*/
protected $items;
}
class SaleDocumentInvoice extends SaleDocument {}
class SaleDocumentReceiptFiscal extends SaleDocument {}
OK, teraz rozumiem. To targając przez mechanizmy Doctrine (Entity Manager/ zapytania DQL) zawsze jest FETCH = EAGER przy tego rodzaju dziedziczeniu, czyli nie da się tego zmienić. Jedynie czystymi zapytaniami SQL. 🙂 W praktyce jednak nie jest to jeszcze taka turbo zła sytuacja, bo maksymalnie po jednym dodatkowym zapytaniu będzie per obiekt. Nie jest to klasyczne N+1, gdzie przy 10 elementach w kolekcji miałbyś 10 dodatkowych zapytań. Tak samo zadziała EAGER bez dziedziczenia – zawsze jedno dodatkowe zapytanie, dlatego często addSelect jest lepszy, bo tam jest JOIN, a nie dodatkowe zapytanie.
ok, to idąc dalej, potrzebuję do frontu zwrócić listę faktur (SaleDocument) które wystawił użytkownik, ale tak aby nie zaciągało dodatkowo tych pozycji (Item), tak jak piszesz, obciążenie nie jest bardzo duże, ale jest i sprawia problemy. Jakie widzisz na to rozwiązanie? Korzystam z orm 2.17 oraz apiPlatform 2.7. Do tej pory miałem rozwiązanie takie, że stworzyłem nową encję SaleDocumentList która nie miała relacji z $items. Do tego utworzyłem widok po stronie bazy danych (MySQL) z którego pobierałem dane. Projekt dynamicznie rośnie i pojawiają się problemy z wydajnością przy pobieraniu z tak utworzonego widoku (aplikacja to SaaS jako multitenant więc do każdego zapytania dochodzi where tenant_id = X)
Jak pojawiają się problemy wydajnościowe to zacząłbym od przejścia na zapytanie SQL zamiast operować na encjach. Nie robisz żadnej zmiany na encjach, więc spokojnie do widoku/API możesz przekazać dane w postaci DTO tylko do odczytu. A to już otwiera drogę do wielu optymalizacji i tam możesz skonstruować zapytanie w dowolny optymalny sposób. Ogólnie hydracja do encji tylko dla odczytu jest kosztowna.