Doctrine – problem n+1 i możliwe rozwiązania

doctrine problem n plus 1 i możliwe rozwiązania

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

Zanim Doctrine, zrozumienie tytułowego problemu ułatwi ten sam przykład, ale oparty 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.

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.