Soluzioni N+1 in Spring Data JPA nel 2026: Fetch Join e EntityGraph
Guida completa per rilevare e correggere il problema N+1 in Spring Data JPA. Fetch join, @EntityGraph, batch fetching e strategie di performance delle query.

Il problema N+1 rappresenta una delle trappole di performance più comuni in JPA. Una query innocente per recuperare 100 ordini può scatenare 101 query SQL: una per gli ordini e poi una per ogni cliente associato. Questa moltiplicazione silenziosa di query degrada le prestazioni e sovraccarica il database.
Un endpoint che restituisce 50 articoli con i loro autori può passare da 10ms a 500ms a causa dell'N+1. Una rilevazione precoce previene problemi critici in produzione.
Comprendere il Problema N+1 in JPA
Il problema N+1 si verifica quando JPA carica una collezione di entità e poi esegue una query aggiuntiva per ogni entità per caricare le sue associazioni. Questo comportamento deriva dal lazy loading predefinito delle relazioni @OneToMany e @ManyToMany.
Si consideri un modello classico con ordini e clienti. Ogni ordine appartiene a un cliente, e questa relazione utilizza il lazy loading per impostazione predefinita.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
private LocalDateTime createdAt;
// ManyToOne relationship is lazy by default since JPA 2.0
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// OneToMany relationship lazy by default
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
// Getters and setters omitted
}@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// Getters and setters omitted
}Quando una query recupera gli ordini e poi accede al nome del cliente, Hibernate esegue una query aggiuntiva per ogni ordine.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public List<OrderDto> getAllOrders() {
// 1 query: SELECT * FROM orders
List<Order> orders = orderRepository.findAll();
// For each order, accessing customer triggers a query
return orders.stream()
.map(order -> new OrderDto(
order.getId(),
order.getOrderNumber(),
// N queries: SELECT * FROM customers WHERE id = ?
order.getCustomer().getName()
))
.toList();
}
}Per 100 ordini, questo codice esegue 101 query SQL. I log di Hibernate rivelano questo pattern distruttivo.
-- Query 1: fetch orders
SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o
-- Queries 2-101: fetch each customer
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3
-- ... 97 more identical queriesRilevare il Problema N+1 con i Log di Hibernate
Il primo passo consiste nell'attivare i log SQL per identificare le query problematiche. La seguente configurazione mostra ogni query eseguita da Hibernate.
# application.yml
spring:
jpa:
show-sql: true
properties:
hibernate:
# Format SQL for better readability
format_sql: true
# Display session statistics (queries, time)
generate_statistics: true
logging:
level:
# Detailed SQL query logging
org.hibernate.SQL: DEBUG
# Display prepared statement parameters
org.hibernate.orm.jdbc.bind: TRACELe statistiche di Hibernate forniscono un riepilogo prezioso al termine di ogni transazione.
Session Metrics {
23421 nanoseconds spent acquiring 1 JDBC connection;
0 nanoseconds spent releasing 0 JDBC connections;
1254789 nanoseconds spent preparing 101 JDBC statements;
15478963 nanoseconds spent executing 101 JDBC statements;
0 nanoseconds spent executing 0 JDBC batches;
}Il conteggio di 101 statement JDBC per una semplice lista di ordini segnala chiaramente un problema N+1.
I log SQL e le statistiche impattano sulle prestazioni. Queste opzioni devono rimanere disattivate in produzione e riservate agli ambienti di sviluppo e test.
Soluzione 1: Fetch Join con JPQL
Fetch join carica le associazioni in una sola query SQL tramite un join. Questo approccio esplicito risolve l'N+1 recuperando tutti i dati necessari in una sola volta.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Explicit fetch join to load customers
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
// Multiple fetch join for several associations
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer c " +
"JOIN FETCH o.items i")
List<Order> findAllWithCustomerAndItems();
// Fetch join with WHERE condition
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer c " +
"WHERE o.createdAt > :since")
List<Order> findRecentOrdersWithCustomer(
@Param("since") LocalDateTime since
);
}Fetch join trasforma N+1 query in un'unica query ottimizzata.
-- Single query with join
SELECT o.id, o.order_number, o.created_at, o.customer_id,
c.id, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.idIl servizio ora utilizza il metodo ottimizzato senza modificare il codice di business.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public List<OrderDto> getAllOrders() {
// Single query with join
List<Order> orders = orderRepository.findAllWithCustomer();
// No additional queries
return orders.stream()
.map(order -> new OrderDto(
order.getId(),
order.getOrderNumber(),
order.getCustomer().getName() // Already loaded
))
.toList();
}
}Pronto a superare i tuoi colloqui su Spring Boot?
Pratica con i nostri simulatori interattivi, flashcards e test tecnici.
Soluzione 2: @EntityGraph per il Controllo Dichiarativo
L'annotazione @EntityGraph offre un'alternativa dichiarativa al fetch join. Definisce quali associazioni caricare senza scrivere JPQL personalizzato.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Inline EntityGraph with attributePaths
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll();
// EntityGraph with multiple attributes
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByCreatedAtAfter(LocalDateTime since);
// Named EntityGraph referencing entity definition
@EntityGraph(value = "Order.withCustomerAndItems")
List<Order> findByCustomerId(Long customerId);
// Combination with custom query
@EntityGraph(attributePaths = {"customer"})
@Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%")
List<Order> findByOrderNumberPrefix(@Param("prefix") String prefix);
}Gli EntityGraph denominati sono definiti direttamente sull'entità per essere riutilizzati in più repository.
@Entity
@Table(name = "orders")
@NamedEntityGraph(
name = "Order.withCustomer",
attributeNodes = @NamedAttributeNode("customer")
)
@NamedEntityGraph(
name = "Order.withCustomerAndItems",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode("items")
}
)
@NamedEntityGraph(
name = "Order.full",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode(value = "items", subgraph = "items-product")
},
subgraphs = @NamedSubgraph(
name = "items-product",
attributeNodes = @NamedAttributeNode("product")
)
)
public class Order {
// Fields unchanged
}Il subgraph permette di caricare associazioni annidate. L'esempio sopra carica gli ordini, i loro item e il prodotto di ogni item in un'unica query.
Soluzione 3: Batch Fetching per le Collezioni
Il batch fetching offre un'alternativa al fetch join per le collezioni @OneToMany. Invece di caricare ogni collezione individualmente, Hibernate raggruppa le query in batch.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// Batch fetching on the collection
@OneToMany(mappedBy = "order")
@BatchSize(size = 25)
private List<OrderItem> items = new ArrayList<>();
}Con @BatchSize(size = 25), Hibernate carica gli item per 25 ordini alla volta invece di 1. Per 100 ordini, il numero di query scende da 101 a 5.
-- Without batch fetching: 100 queries
SELECT * FROM order_items WHERE order_id = 1
SELECT * FROM order_items WHERE order_id = 2
-- ... 98 more queries
-- With @BatchSize(size = 25): 4 queries
SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25)
SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50)
SELECT * FROM order_items WHERE order_id IN (51, 52, 53, ..., 75)
SELECT * FROM order_items WHERE order_id IN (76, 77, 78, ..., 100)La configurazione globale della dimensione del batch si applica a tutte le collezioni dell'applicazione.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
default_batch_fetch_size: 25Il batch fetching è adatto ai casi in cui fetch join genera un prodotto cartesiano troppo grande. Per un ordine con 10 item e 5 pagamenti, fetch join restituisce 50 righe. Batch fetching esegue 2 query separate più efficienti.
Confronto delle Strategie di Caricamento
Ogni strategia offre vantaggi a seconda del contesto d'uso. La seguente tabella riassume i casi d'uso ottimali.
| Strategia | Caso d'Uso | Vantaggi | Svantaggi |
|----------|----------|------------|---------------|
| Fetch Join | Relazioni @ManyToOne | Query SQL singola | Prodotto cartesiano con collezioni |
| @EntityGraph | Caricamento dichiarativo | Riutilizzabile, leggibile | Meno flessibile di JPQL |
| Batch Fetching | Collezioni @OneToMany | Evita il prodotto cartesiano | Query multiple |
| Subselect | Collezioni accedute raramente | Carica solo quando necessario | Subquery correlata |
La strategia subselect carica l'intera collezione al primo accesso a un qualsiasi elemento.
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();-- Generated subselect query
SELECT * FROM order_items
WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?)Evitare l'N+1 con le Proiezioni DTO
Le proiezioni DTO offrono un approccio radicale ma efficace. Selezionando solo le colonne necessarie, le proiezioni evitano completamente il caricamento delle entità e delle loro associazioni.
public record OrderSummaryDto(
Long orderId,
String orderNumber,
String customerName,
String customerEmail
) {}public interface OrderRepository extends JpaRepository<Order, Long> {
// DTO projection with constructor
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
"o.id, o.orderNumber, c.name, c.email) " +
"FROM Order o JOIN o.customer c")
List<OrderSummaryDto> findAllOrderSummaries();
// DTO projection with condition
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
"o.id, o.orderNumber, c.name, c.email) " +
"FROM Order o JOIN o.customer c " +
"WHERE o.createdAt > :since")
List<OrderSummaryDto> findRecentOrderSummaries(
@Param("since") LocalDateTime since
);
}Questo approccio genera una query SQL ottimale senza l'overhead della mappatura delle entità.
SELECT o.id, o.order_number, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > ?Configurazione Avanzata con Spring Data JPA
Spring Data JPA 3.x introduce miglioramenti per la gestione degli EntityGraph e delle query ottimizzate.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Dynamic EntityGraph with Specification
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll(Specification<Order> spec);
// Pagination with EntityGraph
@EntityGraph(attributePaths = {"customer"})
Page<Order> findByCustomerNameContaining(
String name,
Pageable pageable
);
// Slice for efficient pagination
@EntityGraph(attributePaths = {"customer"})
Slice<Order> findByCreatedAtBefore(
LocalDateTime date,
Pageable pageable
);
}Il caricamento condizionale permette di applicare diverse strategie a seconda del contesto.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final EntityManager entityManager;
public List<Order> getOrdersWithGraph(String graphName) {
// Dynamic EntityGraph retrieval
EntityGraph<?> graph = entityManager
.getEntityGraph(graphName);
return entityManager
.createQuery("SELECT o FROM Order o", Order.class)
.setHint("jakarta.persistence.loadgraph", graph)
.getResultList();
}
public List<Order> getOrdersForListing() {
// Minimal graph for listing
return getOrdersWithGraph("Order.withCustomer");
}
public List<Order> getOrdersForDetail() {
// Full graph for detail view
return getOrdersWithGraph("Order.full");
}
}Inizia a praticare!
Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.
Test di Performance per Rilevare l'N+1
I test automatizzati verificano l'assenza di problemi N+1 contando le query SQL eseguite.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryPerformanceTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private EntityManager entityManager;
@PersistenceContext
private EntityManager em;
private Statistics statistics;
@BeforeEach
void setUp() {
// Enable Hibernate statistics
Session session = entityManager.unwrap(Session.class);
SessionFactory factory = session.getSessionFactory();
statistics = factory.getStatistics();
statistics.setStatisticsEnabled(true);
statistics.clear();
}
@Test
void findAllWithCustomer_shouldExecuteSingleQuery() {
// Given: 50 orders in database
createTestOrders(50);
entityManager.clear();
statistics.clear();
// When: fetch with fetch join
List<Order> orders = orderRepository.findAllWithCustomer();
// Then: single query executed
assertThat(orders).hasSize(50);
assertThat(statistics.getQueryExecutionCount())
.as("Should execute only 1 query with fetch join")
.isEqualTo(1);
}
@Test
void findAll_withoutOptimization_triggersNPlus1() {
// Given: 50 orders in database
createTestOrders(50);
entityManager.clear();
statistics.clear();
// When: fetch without optimization
List<Order> orders = orderRepository.findAll();
// Access customers to trigger lazy loading
orders.forEach(o -> o.getCustomer().getName());
// Then: N+1 queries (51 instead of 1)
assertThat(statistics.getQueryExecutionCount())
.as("Should detect N+1 problem")
.isGreaterThan(1);
}
private void createTestOrders(int count) {
for (int i = 0; i < count; i++) {
Customer customer = new Customer();
customer.setName("Customer " + i);
customer.setEmail("customer" + i + "@test.com");
entityManager.persist(customer);
Order order = new Order();
order.setOrderNumber("ORD-" + i);
order.setCustomer(customer);
order.setCreatedAt(LocalDateTime.now());
entityManager.persist(order);
}
entityManager.flush();
}
}L'asserzione su getQueryExecutionCount() garantisce che le ottimizzazioni rimangano in atto durante l'evoluzione del codice.
Conclusione
Il problema N+1 rappresenta una sfida importante per le prestazioni in Spring Data JPA, ma esistono diverse soluzioni efficaci. Fetch join e @EntityGraph risolvono la maggior parte dei casi caricando le associazioni in un'unica query. Il batch fetching offre un'alternativa per grandi collezioni dove fetch join genera un prodotto cartesiano.
Checklist per Prevenire l'N+1:
- ✅ Attivare i log SQL in sviluppo per rilevare query multiple
- ✅ Usare
JOIN FETCHper relazioni@ManyToOneaccedute frequentemente - ✅ Applicare
@EntityGraphper un caricamento dichiarativo riutilizzabile - ✅ Configurare
@BatchSizeper grandi collezioni@OneToMany - ✅ Preferire le proiezioni DTO per operazioni di sola lettura
- ✅ Scrivere test che verificano il numero di query eseguite
- ✅ Disattivare log SQL e statistiche in produzione
- ✅ Profilare regolarmente gli endpoint critici con le metriche di Hibernate
Tag
Condividi
Articoli correlati

Colloquio Spring GraphQL: Resolver, DataLoader e Soluzioni al Problema N+1
Preparazione ai colloqui Spring GraphQL con questa guida completa. Resolver, DataLoader, gestione del problema N+1, mutation e migliori pratiche per le domande tecniche.

Spring Boot 3.4 Virtual Threads: Domande di Colloquio e Benchmark di Performance
Padroneggia i Virtual Threads di Java 21 con Spring Boot 3.4: 15 domande da colloquio, benchmark di performance e schemi di migrazione per superare i colloqui tecnici.

Logging in Spring Boot 2026: log strutturati in produzione con Logback e JSON
Guida completa al logging strutturato in Spring Boot. Configurazione Logback JSON, MDC per il tracing, best practice in produzione e integrazione con ELK Stack.