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; }
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; } }
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:
Czy taki akademicki żółtodziób jak ja mógłby się dowiedzieć co robi/może zrobić DB-Nazi i dlaczego??
Tak, żeby nudno nie było - problemu możemy w ogóle nie uświadczyć wykorzystując Event Sourcing. ;)
...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ę.
Właśnie nigdy nie wiem, czy lepiej w zapytaniu zrobić joina, czy wybrać interesujące mnie kolumny i zwracać DTO?
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...
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. :)
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.
@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ś.
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.
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.
Prześlij komentarz