Nello sviluppo di applicazioni enterprise con Spring Data JPA e Hibernate, esiste un confine sottile tra la comodità dell’astrazione e il disastro prestazionale. Questo confine è spesso marcato dall’N+1 Select, un problema che non genera errori di compilazione, non rompe i test unitari, ma può portare al collasso di un database in produzione sotto carichi reali.
Hibernate nasce per mappare oggetti su tabelle. Per efficienza, le relazioni (specialmente le collezioni come @OneToMany) sono impostate di default su FetchType.LAZY. Ciò significa che i dati correlati non vengono caricati finché non vi si accede esplicitamente.
Esempio
Immaginiamo un’entità Order che ha una lista di prodotti acquistati ItemOrder. Quindi nel codice di Order avremo annotazione @OneToMany e in ItemOrder @ManyToOne, con fetch type lazy di default, per non appesantire il sistema.
Se proviamo a recuperare gli ordini e accedere ai loro item, generiamo l’N+1:
// Query 1: Recupera tutti gli ordini (es. 10 ordini)
List<Order> orders = entityManager.createQuery(“SELECT o FROM Order o”, Order.class).getResultList();
for (Order order : orders) {
// Query +N: Ogni volta che chiamiamo size() o iteriamo, Hibernate lancia una nuova query
System.out.println(“Ordine: ” + order.getId() + ” – Item: ” + order.getItems().size());
}
A livello SQL, quello che accade dietro le quinte è disastroso:
- Query 1: SELECT * FROM Orders; (Restituisce 10 righe)
- Query 2: SELECT * FROM ItemOrder WHERE OrderId = 1;
- Query 3: SELECT * FROM ItemOrder WHERE OrderId = 2;
- … e così via fino alla decima query.
In totale abbiamo eseguito 1 + 10 = 11 query. Se avessi 1.000 ordini, l’applicazione eseguirebbe 1.001 query! Se avessi N ordini avrei N+1 query ( da qui il nome del problema).
Ogni query al database comporta un “round-trip” (viaggio di andata e ritorno) sulla rete. Anche se la query singola è veloce, sommare centinaia di ritardi di rete crea una latenza enorme, saturando le connessioni disponibili e rallentando l’intera applicazione.
Soluzione 1: JOIN FETCH
La soluzione più comune e pulita in JPA è l’utilizzo della clausola JOIN FETCH. Questa istruzione dice a Hibernate di eseguire una JOIN SQL e di popolare immediatamente la collezione degli item.
String jpql = “SELECT o FROM Order o JOIN FETCH o.items”;
List<Order> orders = entityManager.createQuery(jpql, Order.class).getResultList();
// Ora questo ciclo NON genera altre query, i dati sono già in memoria
for (Order order : orders) {
System.out.println(“Item caricati: ” + order.getItems().size());
}
In genere, viene creata un query apposita in JPQL nel repository.
Soluzione 2: Entity Graph (JPA 2.1+)
Se non vuoi scrivere query JPQL personalizzate, puoi usare gli Entity Graphs. Questo approccio permette di definire “cosa” caricare a seconda del caso d’uso, mantenendo la query principale semplice.
EntityGraph<Order> graph = entityManager.createEntityGraph(Order.class);
graph.addSubgraph(“items”); // Specifichiamo di voler caricare anche la lista items
List<Order> orders = entityManager.createQuery(“SELECT o FROM Order o”, Order.class)
.setHint(“javax.persistence.loadgraph”, graph)
.getResultList();
Anche qui spesso, viene creata una query specifica con @EntityGraph nel repository
