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...