wtorek, 28 lutego 2012

Automatyczne wykrywanie n+1 Select Problem w EJB (ale niekoniecznie)

N+1 Select Problem to... problem. Poważny problem:)
Temat jest stary jak Hibernate, ale w swej pracy wciąż spotykam zespoły, które nie zdają sobie z niego sprawy. Tak więc jeżeli nie wiesz czy masz n+1 SP to znaczy, że go masz.

Definicja problemu

Idea problemu jest prosta: załóżmy, że mamy encję User, która zawiera w sobie listę encji Address.
@Entity
public class User{
  @OneToMany
  @JoinColumn("user_id")
  List<Address> addresses;
}
Teraz jeżeli pobierzemy listę użytkowników a następnie iterując po tej liście dla każdego użytkownika zaczniemy przeglądać jego adresy, to wówczas możemy spodziewać się następującej interakcji z bazą danych:
1 x SELECT ... FROM Users - zwróci nam n użytkowników
N x SELECT ... FROM Address

Wykrywanie białkowe

Jeżeli w firmie gdzie pracujesz porządku w bazie pilnuje DB-Nazi, to możesz spodziewać się nagłej wizyty tego smutnego Pana...
Jeżeli nie masz takiego szczęścia (nie jest to sarkazm) to problem wykryjesz obserwując konsolę.
A jeżeli w swojej bazie developerskiej posiadasz 1 Adres (generalnie: operujesz na bardzo małych n) to zapewne problem uświadczysz dopiero na produkcji.

Wykrywanie automatyczne

Możemy stosunkowo łatwo zbadać ilość poleceń SQL wysyłanych do bazy danych. W tym celu posłużymy się klasą Statistics.
Aby zdiagnozować ilość operacji wykonywanych przez nasze komponenty biznesowe (serwisy, nie oszukujmy się;) możemy posłużyć się technikami AOP. Przykładowo w Springu mamy możliwość wpięcia Porady (Advice). Natomiast w EJB możemy skorzystać z Interoceptorów ("poor man's AOP" w wydaniu EE):
package pl.com.bottega.common.support.interceptors;

import javax.interceptor.AroundInvoke;
import javax.interceptor.InvocationContext;
import javax.persistence.EntityManagerFactory;
import javax.persistence.PersistenceUnit;

import org.hibernate.SessionFactory;
import org.hibernate.ejb.EntityManagerFactoryImpl;
import org.hibernate.event.EventListeners;
import org.hibernate.stat.Statistics;
import org.jboss.jpa.injection.InjectedEntityManagerFactory;

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;
 }
}

Nasz interoceptor jest stosunkowo prosty: sprawdza ilość Prepare Statement przed i po wywołaniu EJB. Jeżeli różnica przekracza badany pułap, wówczas "wiedz, że coś się dzieje".

Przechwycenie wszystkich EJB w pliku ejb-jar.xml:

 
  
   pl.com.bottega.common.support.interceptors.NPlusOneSelectProblemDetectingInterceptor
   
  
 
 
  
   *
    pl.com.bottega.common.support.interceptors.NPlusOneSelectProblemDetectingInterceptor
  
 

Problem wyższej warstwy

Drzewiej bywało tak, że transakcje opiewały jedynie warstwę logiki (nazwa umowna). W warstwie prezentacji Transakcje były niedostępne, przez co Entity Manager (Session w Hibernate) nie wspierał Lazy Loadingu. Dzięki temu programista dostawał wyjątek gdy LazyInitializationException gdy chciał "dociągać" dane z warstwy prezentacji. Było to dobre, ponieważ skłaniało do zastanowienia: co ja właściwie chcę zrobić, jakich danych potrzebuję...
Obecnie niemal standardem jest (anty) pattern Open Session in View, który daje możliwość naszym kontrolkom GUI na dociąganie danych. I tak na przykład tabelka renderująca listę użytkowników, może w jednej z kolumn renderować listę adresów. N+1 SP zapewniony...

Aby wykryć problem powodowany przez warstwę prezentacji należało by stworzyć odpowiedni Filtr na poziomie Servlet API.

Naprawa N+1 SP

Istnieje kilka szybkich "obejść" oraz jedno rzetelne, prawdziwe rozwiązanie:
  • Rozproszony cache Encji - ja podaję go w formie żartu, ale czasem widuje się to rozwiązanie. Być może jest ono uzasadnione, ale warto się zastanowić dlaczego go potrzebuję i jaką złożoność przypadkową ono wprowadza...
  • @BatchSize - redukuje problem, działa na ślepo konsumując pamięc, ale daje szybki efekt, można ustawić defaultowy, globlany w XML
  • @Fetch - wyspecyfikowanie w jaki sposób życzymy sobie pobierać chciwie/łapczywie/gorliwie (piękne tłumaczenia słowa EAGER) kolekcje. Uwaga: HQL ignoruje wszystko oprócz Subselect, Criteria API respektuje wszystko

Rozwiązanie rzetelne

Dedykowane zapytania "szyte na miarę" danego przypadku:
SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.addresses

Warto wiedzieć, że w tym wypadku setMaxResult działa w sposób niezdefiniowany oraz, że nie da się chciwie pobrać 2 Toreb (Bag). Bag to pojęcie w Hibernate, które oznacza kolekcję charakteryzującą się brakiem porządku (tak jak Set) ale zezwoleniem na duplikaty. Bag w Hibernate to na poziomie Javy List bez @IndexColumn... ehhh smaczki JPA...:P

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

W przypadku gdy zależy nam na wydajności nic nie zastąpi czystego SQLa tuningowanego przez starego, dobrego DB-Nazi, który zna się na swoim fachu...

11 komentarzy:

Aleksander Wojdyga pisze...

Czy taki akademicki żółtodziób jak ja mógłby się dowiedzieć co robi/może zrobić DB-Nazi i dlaczego??

Sławek Sobótka pisze...
Ten komentarz został usunięty przez autora.
Piotr Wyczesany pisze...

Tak, żeby nudno nie było - problemu możemy w ogóle nie uświadczyć wykorzystując Event Sourcing. ;)

Sławek Sobótka pisze...

...to zależy czego używasz w stosie Read i jak w ogóle jest on zamodelowany:P

bo w stosie Write - tak zgadza się.

Andrzej Ludwikowski pisze...

Właśnie nigdy nie wiem, czy lepiej w zapytaniu zrobić joina, czy wybrać interesujące mnie kolumny i zwracać DTO?

Sławek Sobótka pisze...

Dobre pytanie:) Na liście proponowanych rozwiązań zabrakło tego właśnie podejścia. Ale jego opisanie wymaga osobnego posta,ponieważ zagadnienie jest złożone - ale po jego ogarnięciu staje się proste. Posta popełnię po powrocie z aktualnej "misji" - jutro lub w łikend...

Piotr Wyczesany pisze...

Tak, zgadza się - zależy czego używasz w stosie read. ;) Ale sprytne NoSQLe nie parzą. :)

Co do samego tematu posta - distinct jest imho najczęściej najlepszym rozwiązaniem. :)

Anonimowy pisze...

Podejscie rzetelne z distinct - radze czasem sprawdzic jakie naprawde zapytanie idzie do bazy - jesli uzytkownik ma 100 adresow, to dane z encji uzytkownika powtorza sie 100 razy. Czyli jesli kazdy z uzytkownikow ma duzo adresow, moze okazac sie ze polowa danych lecacych z bazy jest nadmiarowa.

Jest jeszcze jedno podejscie.
Zblizone ideologicznie do 'rzetelnego' fetch-join.

1. Robimy zapytanie bazowe, z join ale bez fetch.
2. Zbieramy id'ki wszystkich dolaczanych encji (tych co maja ochote rzucic lazy exception)
3. Robimy zapytanie dodatkowe pobierajace liste tych brakujacych encji. Nigdzie jej nawet nie przechowujemy.
4. Hibernate (czy cos innego) zadba o to aby w sesji bylo wszystko ladnie uzupelnione w pierwszym zapytaniu.

Efekty:
1. Unikamy pobierana danych jednego uzytkownika n-razy jak przy klasycznym fetch-join w zapytaniu 2. Zawsze wykonamy jedno zapytanie wiecej (czyli mamy 1+1) - jednak zwykle koszt dodatkowego zapytania jest nizszy niz dodatkowe dane
3. Musimy sie wiecej oklepac i pomyslec wczesniej.
4. Wykorzystujemy sesje hibernate - byc moze te dane i tak juz sa pobrane, wiec koszt bedzie zerowy.

Oczywiscie krok dalej jest sql albo iBatis.

Sławek Sobótka pisze...

@Anonimowy: dzięki za komentarz.

- Odnośnie problemu z multiplikacją wyników: załatwia to słówko kluczowe DISTINCT (lub odpowiednia projekcja w Criteria API). Disctinct w JPA znaczy to co w SQL plus redukcję kartezjanu do drzewa na poziomie JPA - choć z bazy pobiera się masa danych, to fakt i dlatego warto rozważyć to o czym piszesz, ale...


- odnośnie proponowanego podejścia to o ile dobrze zrozumiałem jego intencją to w Hibenrate mamy gotowy mechanizm niejako "z pudełka": mam tu na myśli adnotację na kolekcji: @Fetch z wartością SUBSELECT. Efekt w komunikacji z bazą będzie taki jak opisałeś.

Anonimowy pisze...

Rzeczywiscie, przyklad z bloga nie do konca uzasadnia przedstawione podejscie. Moje przemyslenia wynikaly z konkretnego problemu jaki niedawno rozwiazywalem.

1. W przykladzie z userem, i adresami, narzut danych nie jest duzy. Jesli jednak wyobrazimy sobie, ze zlaczenie jest takie, ze encja 'uzytkownik' jest dosc obszerna encja (np. kilka/nascie pol z varchar po 100 znakow), a encja 'adres' jest encja bardzo mala (kilka intow), dla kazdego uzytkownika bedzie ich np. 1000, to okaze sie ze narzut jest gigantyczny. Oczywiscie hibernate to przed nami ukryje, zwroci dokladnie jedna encje, ale zapytanie bedzie sie wykonywac zaskakujaco dlugo.


2. Adnotacji Fetch z SUBSELECT nie da sie zastosowac do zlaczen typu 'toOne'. Robienie zlaczenia i pisanie zapytania w druga strone, tylko po to aby dalo sie ustawic fetch, nie zawsze jest uzasadnione.

Faktycznie, jesli to mozliwe, uzycie adnotacji jest prostsze.

Sławek Sobótka pisze...

Tak, technicznie jest to problem.
Ale ja od strony koncepcyjnej zaproponuję moje ulubione rozwiązanie: "there is no spoon".

Bo generalnie z punktu widzenia modelowania niedobrą praktyką jest modelowanie "dużych" agregatów (gdzie duży to setki lub więcej elementów w kolekcji). Tak więc jeżeli unikniemy tego typu modelu, to techniczne problemy nie będą się pojawiać.

Zawsze przyjmuję ogólną heurystykę: jeżeli agregat jest duży, to model nie jest dobrze przemyślany. Tutaj http://dddcommunity.org/library/vernon_2011 nieco więcej na temat modelowania granic agregatów.