Hai mai notato che una semplice lista di entità in JPA impiega secondi a caricarsi, e dopo aver attivato i log SQL scopri che per ogni riga principale vengono eseguite decine di query identiche? Benvenuto all’N+1 problem, il problema di performance più subdolo e comune in qualsiasi applicazione Java che usa Hibernate. Noi, di Meteora Web, lo abbiamo visto su progetti di clienti che credevano di aver scritto codice pulito — e invece stavano pagando il conto delle query senza saperlo.
Questa guida ti mostra da dove nasce l’N+1, come diagnosticarlo con strumenti concreti e, soprattutto, come risolverlo con le best practices che usiamo nei nostri progetti Laravel e Spring Boot. Perché un ORM non è magia: è uno strumento che, se non capito, ti fa perdere performance e soldi.
Cosa causa l'N+1 Problem in JPA e Hibernate
L’N+1 si verifica quando, per recuperare N entità figlie associate a un’entità principale, vengono eseguite prima una query per la lista principale, poi N query separate per ogni figlio. Il risultato? Tempi di risposta che crescono linearmente con i dati — e su volumi reali diventano ingestibili.
Esempio concreto: supponiamo di avere Autore e Libro.
@Entity
public class Autore {
@Id
private Long id;
private String nome;
@OneToMany(mappedBy = "autore", fetch = FetchType.LAZY)
private List<Libro> libri;
}
@Entity
public class Libro {
@Id
private Long id;
private String titolo;
@ManyToOne
@JoinColumn(name = "autore_id")
private Autore autore;
}
Ora, quando fai autore.getLibri() per ogni autore, Hibernate esegue una query per ogni autore. Con 100 autori avrai 1 (lista autori) + 100 (libri per autore) = 101 query.
Sponsored Protocol
Come diagnosticare l'N+1 problem con i log SQL
Attiva i log delle query in application.properties:
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
Se vedi decine di SELECT identiche per la stessa tabella dopo la query principale, hai l’N+1. Puoi anche usare strumenti come Hibernate Statistics o il plugin JPA Buddy in IntelliJ per contare le query.
Come risolvere l'N+1 problem usando Fetch Join in JPQL
La soluzione più diretta è usare una fetch join nella query JPQL. Invece di caricare lazy, carichi tutto in una sola query con un JOIN FETCH.
@Query("SELECT a FROM Autore a JOIN FETCH a.libri")
List<Autore> findAllWithLibri();
Questa query esegue un singolo SELECT con un join SQL. Hibernate carica in un colpo solo autori e libri. Zero query extra. Attenzione: se usi DISTINCT per evitare duplicati (nel caso di molti libri per autore), aggiungilo:
Sponsored Protocol
@Query("SELECT DISTINCT a FROM Autore a JOIN FETCH a.libri")
List<Autore> findAllWithLibri();
Quando non usare fetch join
Se hai più di una collezione da caricare (es. libri e articoli), un singolo join multiplo può causare un prodotto cartesiano. In quel caso meglio usare @EntityGraph o più query separate con batch fetching.
Qual è la differenza tra FetchType.LAZY e EAGER per l'N+1
FetchType.LAZY (di default per @OneToMany) è la scelta giusta: carica i figli solo quando vengono effettivamente acceduti. Ma se accedi a tutti i figli per ogni entità principale, scatta l’N+1.
FetchType.EAGER carica sempre tutto, anche quando non serve. Non risolve l’N+1: causa lo stesso problema ma sempre, anche per pagine che non hanno bisogno dei figli. Inoltre, con EAGER su più relazioni, Hibernate deve eseguire query extra o join multipli che possono degradare le performance.
La regola che seguiamo noi: usa sempre LAZY come default, e poi ottimizza con fetch join o @EntityGraph dove serve.
Sponsored Protocol
Come usare @EntityGraph per risolvere l'N+1 senza scrivere JPQL
@EntityGraph è un’annotazione di Spring Data JPA che permette di specificare quali associazioni caricare eager per una query, senza modificare il fetch globale.
@EntityGraph(attributePaths = {"libri"})
@Query("SELECT a FROM Autore a")
List<Autore> findAllWithLibri();
Spring Data JPA genera automaticamente una fetch join per le relazioni indicate. Meno codice da scrivere, stesso risultato. Funziona anche con metodi derivati dal nome:
@EntityGraph(attributePaths = {"libri"})
List<Autore> findByNomeContaining(String nome);
Attenzione: gli attributePaths possono includere percorsi annidati, es. "libri.editore". Ma evita di caricare troppe relazioni in un solo graph — rischi di nuovo il problema del prodotto cartesiano.
Il batch fetching: un’alternativa elegante per ridurre l'N+1 senza cambiare query
Se non puoi modificare ogni query (es. perché usi un repository generico), puoi configurare batch fetching. Hibernate raccoglie in un unico WHERE IN le chiavi primarie di più entità, riducendo le query da N a N/batch.
Sponsored Protocol
Nel application.properties:
spring.jpa.properties.hibernate.default_batch_fetch_size=10
Oppure a livello di entità con @BatchSize:
@Entity
@BatchSize(size = 10)
public class Autore {
// ...
}
Con batch size 10, 100 autori generano 10 query invece di 100. Non è perfetto come un singolo join, ma è un compromesso efficace quando non hai controllo sulle query.
Come evitare l'N+1 con le Named Entity Graph e le sottoclassi
Per usi avanzati, Spring Data JPA supporta @NamedEntityGraph a livello di entità. Definisci un graph riutilizzabile e applicalo in più repository.
@Entity
@NamedEntityGraph(name = "Autore.libri", attributeNodes = @NamedAttributeNode("libri"))
public class Autore {
// ...
}
// Repository
@EntityGraph("Autore.libri")
List<Autore> findAll();
Questa tecnica centralizza la logica di caricamento e la rende più manutenibile.
Strumenti per individuare l'N+1 in produzione
Oltre ai log, ci sono strumenti che ti salvano la vita:
- Hibernate Statistics: abilita con
spring.jpa.properties.hibernate.generate_statistics=true. Mostra il numero di query eseguite, tempi medi e flush. - JPA Inspector (plugin IntelliJ): durante lo sviluppo evidenzia le query lente.
- Hypersistence Optimizer: analizza il codice e suggerisce correzioni.
- Spring Boot Actuator con metriche Hibernate.
Noi usiamo sempre le statistiche nei nostri ambienti di staging prima di andare in produzione. Un’applicazione che in locale carica 10 righe non mostra il problema — con 10.000 righe diventa evidente.
Sponsored Protocol
Best practices finali per performance ORM con JPA e Hibernate
- Mai usare
FetchType.EAGERsulle associazioni di collezione. Preferisci LAZY e ottimizza a punto. - Usa
JOIN FETCHo@EntityGraphper ogni query che ha bisogno di caricare figli. - Imposta un
default_batch_fetch_sizeragionevole (10-20) per coprire i casi non ottimizzati. - Logga le query in sviluppo e usa le statistiche in staging.
- Evita di caricare due collezioni diverse in un’unica query — usa
@BatchSizeo esegui query separate.
Ricorda: un ORM non è una bacchetta magica. È un potente automatismo che va controllato. Se lo lasci fare, ti riempie di query. Se lo guidi, ti dà performance da codice SQL fatto a mano ma con la flessibilità dell’oggetto.
Vuoi approfondire il tema della gestione delle performance in un’applicazione Spring Boot? Leggi il nostro articolo sul pillar Java e JVM Moderno.