Po co nam Doctrine Extra Lazy mode?

gildia-developerow.pl 1 rok temu

We wpisie o Lazy Loadingu wspomniałem, aby pobierać wszystko, czego potrzebujemy, na raz. Słowem-kluczem tutaj są słowa „czego potrzebujemy”. Z perspektywy działania aplikacji wydaje nam się, iż do pewnych operacji potrzebujemy pełnego zestawu danych. O tym, iż czasami można taniej – jest dzisiejszy wpis.

Zapytanie zapytaniu nierówne

Nie od dzisiaj wiadomo, iż dwa różne zapytania do bazy danych potrafią mieć różny efekt wydajnościowy w stosunku do aplikacji oraz bazy danych. Na standardowe zapytanie wysłane przez Doctrine składa się na:

  • Czas potrzebny na przesłanie zapytania do serwera bazodanowego
  • Czas trwania przeszukiwania bazy danych
  • Czas zebrania i wysłania danych do aplikacji
  • Czas potrzebny na alokowanie pamięci i hydrację do postaci encji

Dodatkowo, im większa odpowiedź z serwera, tym więcej pamięci będzie zajmowała aplikacja w konkretnych punktach czasowych. Dlatego właśnie powinniśmy bardzo ostrożnie wybierać zapytania tak, aby spełniały nasze oczekiwania biznesowe.

Wygoda programisty vs spokój bazy danych

Wyobraźmy sobie sytuację, kiedy chcemy dodać do kolekcji nowy element, który ma w niej występować tylko raz. Wygodnym dla nas – programistów – rozwiązaniem będzie pobranie wszystkich elementów kolekcji, a następnie sprawdzenie, czy dany element już istnieje. o ile nie istnieje, to dorzucamy go do kolekcji. I w zasadze takie jest domyślne zachowanie Doctrine.

Kod, o którym mówię wygląda mniej więcej następująco:

// src/Entity/Category.php public function addProduct(ProductInterface $product): void { if (false === $this->products->contains($product)) { $this->products->add($product); $product->setCategory($this); } }

W sytuacji, kiedy kategoria liczy kilka-kilkadziesiąt prostych produktów, jest to sytuacja raczej do zaakceptowania. Problem powstaje, kiedy encja Product zawiera mnóstwo danych tekstowych, a sama kolekcja zawiera tych produktów kilka tysięcy.

Czy zamiast tego, nie byłoby bardziej optymalnie wysłać proste zapytanie typu SELECT 1 WHERE? Dlaczego musimy zmuszać bazę danych do przesłania, a następnie ładowania do pamięci wszystkich tych krótkich i długich opisów produktu?

Rozwiązaniem, które na pierwszy rzut wydaje się rozsądne (ale i niewygodne), to wyciągnięcie sprawdzenia poza encję, gdzie następnie skonstruujemy to konkretne zapytanie. No ale, będzie z tym trochę roboty. No i w procesie Code Review będziemy się musieli tłumaczyć, dlaczego tak „nieładnie” piszemy. A gdyby tak istniał złoty środek…?

Doctrine Extra Lazy Loading

Twórcy Doctrine przewidzieli tę sytuację i dali nam narzędzie, które nie narusza naszych dobrych praktyk obiektowych, a które pozwala odpocząć bazie nieco. Narzędzie to nazywa się Extra Lazy Loading.

Okazuje się, iż podczas regularnej pracy z relacjami, nie zawsze potrzebujemy pracować na pełnym zestawie danych. Wg dokumentacji Doctrine, takimi operacjami są:

  • Collection::contains($entity)
  • Collection::count()
  • Collection::get($key)
  • Collection::containsKey($key)
  • Collection::slice($offset, $length = null)

Aby „przełączyć” Doctrine w tryb oszczędnego gospodarowania danymi, należy skonfigurować opcję EXTRA_LAZY w definicji relacji:

<?xml version="1.0" encoding="UTF-8"?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> <entity name="App\Entity\Category" table="product_category"> <id name="id" type="integer"> <generator strategy="AUTO" /> </id> <one-to-many field="products" target-entity="App\Entity\Product" fetch="EXTRA_LAZY" /> </entity> </doctrine-mapping>

albo ustawić to per zapytanie Query Buildera:

// src/Repository/CategoryRepository.php final class CategoryRepository extends ServiceEntityRepository { // ... public function findWithProducts(): array { return $this->createQueryBuilder('o') ->getQuery() ->setFetchMode(Category::class, "products", ClassMetadata::FETCH_EXTRA_LAZY) ->getResult(); } }

Dzięki powyższemu, Doctrine będzie wysyłał „łagodniejsze” zapytania do bazy danych wtedy, kiedy nadarzy się ku temu okazja.

Z moich testów wynika, iż dla poszczególnych operacji będą to następujące zapytania:

-- Collection::contains() SELECT 1 FROM product t0 WHERE t0.id = ? AND t0.category_id = ? -- Collection::count() SELECT COUNT(*) FROM product t0 WHERE t0.category_id = ? -- Collection::get() SELECT t0.name AS name_1, t0.id AS id_2, t0.category_id AS category_id_3 FROM product t0 LIMIT 1 -- Collection::containsKey() SELECT COUNT(*) FROM product t0 WHERE (t0.category_id = ? AND t0.id = ?) -- Collection::slice() SELECT t0.name AS name_1, t0.id AS id_2, t0.category_id AS category_id_3 FROM product t0 WHERE t0.category_id = ? LIMIT 5 OFFSET 2

Jak widać, są to bardzo proste zapytania, które w znacznym stopniu pozwolą odetchnąć bazie danych.

Nie wszystko zadziała by default…

Niestety, samo włączenie opcji Extra Lazy Loadingu nie wszędzie zadziała od razu. Wszędzie tam, gdzie podajemy klucz (a zatem metody containsKey() oraz get() ) – będzie konieczność skorzystania z dodatkowego ustawienia noszącego nazwę Index By. Z grubsza chodzi o to, aby Doctrine wiedział, po którym polu powinien odpytywać bazę danych w celu dosięgnięcia odpowiedniej informacji.

Poniżej przedstawiam kawałek kodu ze środka Doctrine, który jest odpowiedzialny za decyzję, czy skorzystać z Extra Lazy Loadingu, czy nie:

// https://github.com/doctrine/orm/blob/2.14.x/lib/Doctrine/ORM/PersistentCollection.php#L392 public function containsKey($key): bool { if ( ! $this->initialized && $this->association['fetch'] === ClassMetadata::FETCH_EXTRA_LAZY && isset($this->association['indexBy']) ) { $persister = $this->getUnitOfWork()->getCollectionPersister($this->association); return $this->unwrap()->containsKey($key) || $persister->containsKey($this, $key); } return parent::containsKey($key); }

Jak wyraźnie widzimy, kolekcja musi korzystać z opcji Index By.

Czy jest tu jakiś haczyk?

Zdawać by się mogło, iż Extra Lazy Loading jest opcją doskonałą. W końcu możemy tylko oszczędzić. Wydaje się, iż zasadnym staje się pytanie: dlaczego to nie jest domyślną opcją w Doctrine? Myślę, iż odpowiedź na to pytanie znam

Niezależnie od tego, którą opcję zaciągania relacji wybierzemy, to ma ona swoje wady. Nie inaczej jest z Extra Lazy Loadingiem. Ale po kolei:

  • Lazy Loading – jak nam wiadomo, opcja domyślna, która nierzadko skutkuje wywołaniem problemu N+1 – wysyłki zbyt dużej ilości zapytań do bazy, podczas, kiedy dużo iterujemy po encjach.
  • Eager Loading – gdyby to była opcja domyślna, to mielibyśmy bliżej niekontrolowaną liczbę zapytań w celu uzupełnienia danymi relacji, których nie będziemy używać.
  • Extra Lazy Loading – może skutkować dużą ilością prostych zapytań, których lista koniec końców i tak może zostać domknięta – albo zapytaniem pobierającym wszystkie dane – albo pojedynczymi zapytaniami o konkretne (ale faktycznie wszystkie) wiersze. Bo duża część pracy aplikacji bazodanowej opiera się o przetwarzanie tych danych.

Niestety, w tej sytuacji nie ma wygranego. Każda opcja ma swoje wady i nie można jednoznacznie stwierdzić, iż jedno jest lepsze od drugiego. I w sumie, to dobrze. Bo my – programiści – powinniśmy znać każdą z opcji i roztropnie wybierać to, czego w danej chwili potrzebujemy. Niestety, twórcy Doctrine zdecydowali się na opcję domyślną, co powoduje, iż zapominamy o pozostałych dwóch opcjach. A za brakiem naszej wiedzy idzie, niestety, bardzo kiepski performance.

Tak więc jeszcze raz – pamiętajmy o association fetch mode i wybierajmy każdą z opcji świadomie.

Idź do oryginalnego materiału