Duże Zbiory Danych i Doctrine. Jak Zmniejszyć Zużycie Pamięci?

programit.pl 2 lat temu

Tworzenie narzędzi do przetwarzania dużych zbiorów danych to nie rzadkość w pracy programisty. Należy przyłożyć do ich implementacji dużo uwagi, ponieważ niewłaściwie napisane mogą skutkować długim czasem działania, lub powodować znaczne zużycie pamięci. O ile czas wykonywania może być akceptowalny, gdyż zwykle takie aplikacje działają w tle, to w przypadku przekroczenia limitu pamięci aplikacja może przerwać swoje działanie. A to może mieć poważne implikacje.

Ostatnio miałem do czynienia z taką komendą. Aplikacja używała Doctrine, tak więc i polecenie wykorzystywało ten popularny ORM do wyciągania danych.
W tym przypadku do przetworzenia było około pół miliona encji.
Problemem było to, iż aplikacja zużywała bardzo dużo pamięci już na starcie. Problematyczny fragment wyglądał mniej więcej tak:

class ProductRepository { public function getEntities(): iterable { $products = $this->createQueryBuilder('x')->getQuery()->toIterable(); foreach($products as $product) { if (!$product->aCondition()) { continue; } yield $product; } } } // print memory usage $products = $this->ProductRepository->getEntities(); // print memory usage foreach ($products as $product) { //do smth $this->entityManager->flush(); $this->entityManager->clear(); }

Zużycie pamięci przed wywołaniem metody getEntities wynosiło 45MB, zaś po wykonanej operacji 900MB.

W powyższym kodzie widzimy pobranie danych poprzez użycie metody toIterable. Zwraca ona generator, który tworzy poszczególną encję w momencie iterowania po kolekcji.
Ma to taki plus, iż gdybyśmy zwrócili kolekcję encji, a następnie wywołali metodę clear na EntityManagerze, zmiany na encjach w kolejnych iteracjach nie byłyby aplikowane.

Następnie iterujemy się po zwróconej kolekcji i sprawdzamy złożony warunek, którego nie dało się sprawdzić z poziomu zapytania do bazy danych. jeżeli warunek jest spełniony, zwracamy taką encję do wykonania na niej operacji. Zapisujemy zmiany i czyścimy unit of work. Skąd więc tak duże zużycie pamięci?

Odpowiedź tkwi w metodzie toIterable. Pomimo tego, iż metoda ta tworzy/hydruje encję dopiero w momencie iteracji surowych danych, to wszystkie dane z bazy są pobierane za jednym razem na samym początku i przechowywane w pamięci – w tym przypadku pół miliona rekordów. Dlaczego tak się dzieje?

PHP do obsługi zapytań domyślnie używa biblioteki mysqlnd, która zastępuje libmysql. Zapytania wykonywane są domyślnie w trybie buffered. Powoduje to, iż wynik pobieranego zapytania przy użyciu domyślnej biblioteki całkowicie wlicza się do wyliczanego zużycia pamięci.

When using libmysqlclient as library PHP's memory limit won't count the memory used for result sets unless the data is fetched into PHP variables. With mysqlnd the memory accounted for will include the full result set.

Jak rozwiązać ten problem?

Innym rozwiązaniem, które zmniejszy zużycie pamięci, może być pobranie samych identyfikatorów. Następnie, na ich podstawie możemy w pętli pobierać encje i zwracać je do przetworzenia. To rozwiązanie zmniejszy zużycie pamięci, ponieważ będziemy przechowywać znacznie mniej danych. Sprawdzi się idealnie w sytuacji, gdy zapytanie wyciągające dane będzie bardziej skomplikowane, ponieważ wykonamy je raz. Spowolnić proces może natomiast wyciąganie poszczególnych encji w oddzielnych zapytaniach.

Z racji tego, iż zapytanie wykonywane w komendzie było proste, zdecydowałem się na jeszcze inne rozwiązanie. Spowodowało ono, iż zużycie pamięci oscylowało na poziomie początkowego przez cały proces. Wyglądało mniej więcej tak:

public function getEntities(): iterable { $id = 0; do { $qb->createQueryBuilder('x') ->select('x') ->andWhere('x.id > :id') ->orderBy('x.id', 'ASC') ->setMaxResults(1000) ->setParameter('id', $id) ; $products = $qb->getQuery()->toIterable(); $hasResults = false; foreach ($products as $product) { $hasResults = true; $id = $product->getId(); if (!$product->aCondition()) { continue; } yield $product; } } while ($hasResults); }

Rozwiązanie to podobne jest do pierwotnego, z tą różnicą, iż w pamięci przechowywane są dane tysiąca rekordów, a nie pół miliona.
Czas wykonania nie różni się znacząco od pierwotnego rozwiązania. Jest wolniej, ale są to różnice rzędu kilku procent. Zużycie pamięci po pobraniu danych wzrosło do ok. 90 MB, co stanowi sporą różnicę w porównaniu do poprzedniego wyniku.

Powyższe rozwiązanie, dzięki temu, iż zwraca generator, ma jeszcze jedną zaletę. Obsługą pobierania kolejnych encji zajmuje się metoda getEntities, a co za tym idzie, jedyne co musimy zrobić to przekazać wynik do iterowania w pętli.

Idź do oryginalnego materiału