poniedziałek, 20 kwietnia 2009

Lazy Loading a sprawa wydajności




Podążając za ciągiem skojarzeń: od przedwczorajszego DAO przez wczorajsze Lazy Loading dziś czas na aspekt wydajności podczas korzystania z maperów relacyjno-obiektowych. Właściwie to główne niebezpieczeństwo tkwi w tytułowym Lazy Loadingu.

Sprawa jest w sumie prosta - trzeba uważać co się robi i mniej więcej orientować się jak działa mapper. Nie trzeba wnikać nawet w aspekty implementacyjne Lazy Loadingu: klasy pośredników, które mogą być podstawiane do encji czy to poprzez manipulację byte codem czy w czasie generowania kodu źródłowego (jednak zainteresowanym polecam zgłębienie tematu - warto wiedzieć "jak oni to zrobili").

Niestety na żadnej ze stron domowych maperów zgodnych z JPA (Hibernate, OpenJPA, Kodo, TopLink) nie dopatrzymy się ani na głównej stronie, ani na żadnej innej wielkiego czerwonego napisu "UWAGA NA WYDAJNOŚĆ PODCZAS STOSOWANIA LAZY LOADING!". Nie uraczymy również zestawu paru prostych porad, które uchroniły by nas przed dramatycznym spadkiem wydajności. Czyżby nie wyglądało to zbyt marketingowo? Może autorzy sądzą, że ludzie tego nie zauważą i jakoś to będzie - kolejny bubel się przyjmie w społeczności:)

Problem wydajności jest stary jak Hibernate ale niestety jeszcze nie wszędzie uświadomiony. Jako przykład podam tę oto przypowieść.



Niezbyt_dawno, niezbyt_dawno temu w pewnej dużej organizacji strasznie mulił system wykorzystujący Hibernate. Ludzie ze swą wrodzoną skłonnością do tworzenia mitów szybko poradzili sobie z dysonansem poznawczym trapiącym ich mózgi: stworzyli sobie prosty system mitologiczny, który nakazywał im wierzyć, że ORMy tak po prostu mają. Po prostu ich magiczne właściwości powodują, że systemy spowalniają a odczyt danych z bazy jest hamowany jakąś niepojętą siłą. Ciemnotę dodatkowo potęgowała Kasta Adminów Baz Danych, mówiąc: "tak oto kończy się sprzeniewierzanie prastarej składni SQL. Nie chciało się wam niewierni pisać procedur i kwerend to teraz sczeźnijcie w piekle!".


Po wstępnych oględzinach okazało się, że w większości przypadków obarczonych bardzo niską wydajnością mamy do czynienia z klasycznym problemem występującym w ORMach z Lazy Loadingiem: n+1 Select Problem.

Istotę problemy wyjaśnię na przykładzie: Wyobraźmy sobie, że pobieramy listę obiektów klasy Person. Następnie iterujemy po tej liście i dla każdej osoby wołamy getAddresses() po to aby coś tam z adresami każdej z osób zrobić. Jeżeli zapytanie wygląda mniej więcej tak: SELECT p FROM Person p to mam rzeczony problem. Najpierw silnik persystencji wysyła do bazy jedno zapytanie w celu pobrania listy osób. Następnie dla każdej z n osób wysyła zapytanie o jej adresy. W sumie n+1 zapytań:) Pięknie nieprawdaż? Niestety musimy pofatygować się i napisać porządne zapytanie HQL (lub odpowiednie Criteria w Hibernate), które spowoduje dodżojnowanie adresów do osób tak aby wysłane zostało do bazy jedno zapytanie SQL. Niestety nieodzowne będzie dopisanie w HQL paru JOIN FETCH - a co za tym idzie korzystanie z Entity Managera przestaje być takie trywialne jak w tutorialach, pojawia się złożoność, którą warto hermetyzować w DAO/Repozytorium.

Należy uważać również na "utajony" wariant n+1 select problem. W powyższym przykładzie mamy sytuację, w której radośnie iterujemy sobie przy pomocy własnego kodu po osobach i grzebiemy w ich adresach. Może być też tak, że nasz własny kod jest OK, ale niefortunnie podepniemy naszą listę osób pod np jakiś komponent GUI (np h:dataTable w JSF), który ma wyświetlić osoby wraz z adresami i nieszczęście gotowe. Logika renderingu tabelki przeiteruje po osobach aby wyrenderować jej adresy. Wówczas mogą zdarzyć się 2 rzeczy:
- aplikacja się wywali z powodu LazyInitializastionException (lub jakiegoś innego wyjątku w zależności od dostawcy JPA - co zresztą uważam za skandaliczne) ponieważ najpewniej w warstwie widoku sesja będzie już dawno zamknięta
- jeżeli jednak stosujemy jakiś trik typu Open Session In View to wówczas ORM radośnie wygeneruje nam n+1 zapytań do bazy:)))

Pierwszy wariant (wyjątek) jest o tyle dobry, że przynajmniej zwróci czyjąś uwagę podczas devlopmentu i skłoni do napisania porządnego zapytania, które ładuje wszystkie dane w jednym podejściu. Drugi wariant działa więc jest szansa, że nitk nie sprawdzi co się dzieje po stronie bazy:/



Kolejna przypowieść. W tejże samej organizacji istniał inny system, który również niemiłosiernie mulił. Tym razem był to gruby klient do serwera więc lazy loading nie miał zastosowania. Encje przesłane na klienta nijak nie chcą nawiązać kwantowego kanału z procesorem serwera:/ W tym systemie również panowała mitologia. Kiedyś pewien magik podobno zrobił czary mary i LazyInitalizationException nie pojawiał się gdy na kliencie wołano łańcuszki get get get.


Po krótkiej analizie problemu okazało się, że tym razem przedobrzono w inną stronę. Prawie wszystkie powiązania pomiędzy obiektami były EAGER - czyli chciwe lub jak kto woli łapczywe. Skutkowało wyciąganiem połowy bazy przy każdym zapytaniu. O ile serwer bazodanowy dawał radę to wąskim gardłem okazała się transmisja zserializowanych danych. Wniosek znowu ten sam: niestety na skróty się nie da - trzeba się pofatygować i napisać porządne zapytania specyfikując co w jakim Use Case ma być podciągnięte. Niestety zakorzeniona mitologia i szargana opinia o ORMach pozostanie w organizacji na parę następnych pokoleń.

Przy okazji warto wspomnieć o kolejnej pułapce jaką szykuje na nas maper. Uwaga na powiązania typu n-1 lub 1-1 - czyli takie gdzie w klasie encji mamy pole klasy innej encji (np Person ma jeden Adres). Standard JPA zakłada, że wówczas powiązanie jest typu EAGER. Czyli pobierając z bazy osobę zawsze od razu wyciągniemy jej adres. Niestety odbywa się to zazwyczaj w osobnym zapytaniu. Czyli w przypadku pobierania listy osób mamy n+1 Select Problem:)


Żeby nie było, Lazy Loading może również przydać się w celu optymalizacji systemu.
Wyobraźmy sobie taki przypadek: Mamy klasę dokument, która zawiera szereg pół - w tym pole zawierając treść dokumentu: kilkadziesiąt stron A4 (pomińmy kwestię sensowności takiego projektu). Gdy chcemy zaprezentować listę dokumentów na GUI, która zawiera dajmy na to tytuł i datę to oczywiście zupełnie niepotrzebnie będziemy pobierać z bazy i przechowywać w aplikacji (choćby chwilowo) dziesiątki megabajtów tekstu. Jednym z paru rozwiązań ale najprostszym jest ustawienie leniwego ładowania kolumny z treścią. Gdy nie będzie potrzebne wówczas nie będzie ładowane z bazy.


//============================

Podstawą pracy z ORM jest konsola z logami. Podczas developmentu należy włączyć wyświetlanie generowanych poleceń SQL i nieustannie obserwować oczami swymi logi silnika ORM czy przypadkiem nie pojawia się na niej w pewnym momencie kilka/-dziesiąt/-set/-tysięcy zapytań. Oczywiście n+1 Select Problem możemy zauważyć na konsoli praktycznie tylko wówczas gdy napełnimy bazę większą ilością danych. Dla n=1 problemu raczej nie uraczymy.

W dobrym procesie produkcji softu jest czas na takie coś jak kontrola kodu - przeglądanie nowego kodu przez bardziej doświadczonych członków teamu (lub chociaż przez drugą parę oczu). Co powiecie na kontrolę konsoli? Pokaż misiu jakie zapytania do bazy generuje Twój kod.

Podsumowując dwie przytoczone przypowieści pozwolę sobie zacytować Franka Zappa: "Głupota ma pewien urok, ignorancja nie".

9 komentarzy:

iirekm pisze...

Gwoli ścisłości: oprócz eager i lazy loading, Hibernate ma też opcję batch-size="N", dzięki której zamiast n+1 selektów mamy około n/N selektów. Ale jak w wyniku zagnieżdżenia tych selektów zamiast n^k mamy n^k/N selektów, co już nie jest takim zyskiem.

Pomóc tu mogłyby tylko jakieś bardziej inteligentne algorytmy śledzące i przewidujące żądania do bazy. Po stronie serwera bazy takowe do optymalizacji złożonych selectów są stosowane od lat: http://www.postgresql.org/docs/6.3/static/c49.htm (ten przykładowy używa algorytmu genetycznego!)

milus pisze...

"Podstawą pracy z ORM jest konsola z logami" - to chyba jakiś żart

Nie wyobrazam sobie w dużym systemie śledzenie zaytań, które pojawiają się na konsole...
Nawet jeśli jestem w stanie się przeczołgac i śledzic jakieś sql jakiś tam funkcjonalności to i tak nie mam pewności, że nawet jak uda mi się coś naprawic (napisac lepszego HQL, poprawic mapowanie) to w przyszłości ktoś nie namiesza w zapytaniach/mapowaniach ponownie.

Roziwązaniem tego problemu jest bardzo proste a co więcej dostępne od dawna out-of-box w Hibernate, a mianowicie sessionFactory.getStatistics();
Niestety sama w sobie dokumentacja jest uboga ale najwazniejsza metoda to getPreparedStatementsCount().

Jeśli jeszcze do tego korzystam z SpringFramework, a szczególnie Spring Test ContextFramework(polecam moją prezentacje na Javarsovia 2009) to mamy możliwosc testowania naszych zapytan/mapowac bezpośrednio z JUNIT.

Sławek Sobótka pisze...

Nie, to nie był żart. Żartować owszem sobie można, ale z systemów, gdzie jeden user zarzyna bazę danych;P

Nie chodziło mi o śledzenie wszystkiego. Z czasem nabywa się intuicji, które operacje mogą coś zamulić. Chodzi o to aby po prostu zawsze upewnić się ile zapytań idzie do bazy. I nie chodzi mi też o paranoję, gdzie zamiast 1 prostego idą 3 proste - dajmy sobie spokój, to nie lata 80.

Ale masz rację w sprawie namieszania w przyszłości. Ten argument jak najbardziej mnie przekonuje. Jestem w stanie wyobrazić sobie, ze jedna zmiana mapowania burzy kruchą równowagę.

Idea testów, które zliczają PreparedStatements jest jak najbardziej ok. Pytanie tylko kto będzie miał czas aby je pisać?

Powiem tak: najlepiej byłoby testować ilość tych zapytań - tak jak piszesz, ale jeżeli nie to warto przynajmniej spojrzeć na konsolę:)

milus pisze...

Kto będzie miał czas je pisac?

No oczywiscie powinno to byc obowiązkiem developera. Ale tak jak pisałem robi sie to stosunkowo prosto a co najwazniejsze przy korzystaniu z istniejacych juz rozwiazan: Spring TestContextFramework i Statistics bardzo szybko

Anonimowy pisze...

A ja jestem początkującym Javovcem-Hibernetowcem i wreszcie piękny, obrazowy artykuł o niuansach z LL itp. W żadnym tutorialu inni nie piszą o tym na co uważać, że o braku przykładów nie wspomnę. A tu przystępnie i miło się czyta. I komentarze fachowców też konkretne.

Podoba mi się!

Sławek Sobótka pisze...

Minęło ponad 5 lat, mój preferowany styl architektoniczny i styl programowania zmienił się mocno pod wpływem DDD, tak więc teraz w nietrywialnych przypadkach unikał bym LL. Mówię tym w prezentacji http://art-of-software.blogspot.com/2014/12/video-z-prezentacji-na-jdd-2014.html i piszę w artykule http://bottega.com.pl/pdf/materialy/receptury/orm.pdf

Co do testów o jakich pisał milus to popełniłem aspekt w springu i interceptor w ejb, który wykrywa takie zjawiska, gdyby ktoś potrzebował to mogę wydłubać.

Anonimowy pisze...

@SławiekSobótka,
Czy jesteś jeszcze w stanie wydłubać to?

Sławek Sobótka pisze...

Wersja w EJB 3.0 na interceptorach

public class NPlusOneSelectProblemDetectingInterceptor {

@PersistenceUnit
private EntityManagerFactory entityManagerFactory;

@AroundInvoke
public Object countStatements(InvocationContext invContext)
throws Exception {
InjectedEntityManagerFactory iemf = (InjectedEntityManagerFactory)
entityManagerFactory;
EntityManagerFactoryImpl hemf = (EntityManagerFactoryImpl)
iemf.getDelegate();

SessionFactory sessionFactory = hemf.getSessionFactory();
Statistics statistics = sessionFactory.getStatistics();
statistics.setStatisticsEnabled(true);

long before = statistics.getPrepareStatementCount();

Object result = invContext.proceed();

long count = statistics.getPrepareStatementCount() - before;
if (count > 30){
String message = invContext.getTarget().getClass()
+ "->" + invContext.getMethod().getName() + " statements: " + count;
//TODO wysłać maila do db-nazi
}
return result;
}
}


W drugiej linijce krytyczne jest rzutowanie. Z tego co pamiętam, to różne wersje tego samego serwera mogą mieć inne klasy, a nawet zagnieżdżenie obiektów prowadzących do EMF.

W Springu będzie analogicznie na Aspektach.

Sławek Sobótka pisze...

A tutaj moje odświeżone spojrzenie na temat LL: robić tak aby było zbędne: https://www.youtube.com/watch?v=uj25PbkHb94