Soluzioni N+1 in Spring Data JPA: Fetch Join, EntityGraph e Hibernate 7.4

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.

Risolvere il problema N+1 con Spring Data JPA usando fetch join e EntityGraph

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.

Impatto Reale dell'N+1

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.

Order.javajava
@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
}
Customer.javajava
@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.

OrderService.java - Problematic codejava
@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.

sql
-- 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 queries

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

yaml
# 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: TRACE

Le statistiche di Hibernate forniscono un riepilogo prezioso al termine di ogni transazione.

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

Disattivare in Produzione

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.

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

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

Il servizio ora utilizza il metodo ottimizzato senza modificare il codice di business.

OrderService.java - Optimized codejava
@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. Per una panoramica più ampia delle best practice JPA, consultare il modulo sui fondamenti di Spring Data JPA.

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

Order.javajava
@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.

Hibernate 7 introduce una sintassi testuale semplificata per @NamedEntityGraph che riduce la verbosità delle annotazioni:

Order.java - Hibernate 7 text-based syntaxjava
@Entity
@Table(name = "orders")
@org.hibernate.annotations.NamedEntityGraph(
    name = "Order.full",
    graph = "customer, items(product)"
)
public class Order {
    // Fields unchanged
}

Questa definizione a singola riga sostituisce le annotazioni verbose annidate @NamedAttributeNode e @NamedSubgraph.

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.

Order.javajava
@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.

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

yaml
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        # Global default batch size
        default_batch_fetch_size: 25
Batch vs Fetch Join

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

Paginazione con Fetch Join in Hibernate 7.4

Una limitazione storica dei fetch join era la loro incompatibilità con la paginazione. Combinare setMaxResults() con fetch join su collezioni costringeva Hibernate a caricare tutte le righe corrispondenti e poi applicare i limiti in memoria. L'avviso firstResult/maxResults specified with collection fetch; applying in memory segnalava questo problema di prestazioni.

Hibernate 7.4 risolve questo problema. La paginazione con fetch join su collezioni ora funziona a livello di database utilizzando query annidate. Hibernate determina prima l'insieme limitato degli identificatori delle entità parent, poi recupera le collezioni associate solo per quelle righe parent.

OrderRepository.java - Safe pagination with Hibernate 7.4java
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o JOIN FETCH o.items")
    Page<Order> findAllWithItems(Pageable pageable);

    @Query("SELECT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.items")
    Slice<Order> findAllWithCustomerAndItems(Pageable pageable);
}

L'SQL generato ora utilizza una subquery per la paginazione a livello di database:

sql
-- Hibernate 7.4: database-level pagination with fetch join
SELECT o.*, i.*
FROM orders o
JOIN order_items i ON o.id = i.order_id
WHERE o.id IN (
    SELECT id FROM orders
    ORDER BY created_at DESC
    LIMIT 10 OFFSET 0
)

Questo miglioramento riduce drasticamente l'utilizzo di memoria per gli endpoint paginati che mostrano relazioni parent-child. Spring Boot 4.1 include Hibernate 7.4.5, rendendo questa ottimizzazione disponibile di default.

Per le applicazioni che utilizzano ancora versioni precedenti di Hibernate, il pattern di fetching a due fasi rimane l'approccio più sicuro: recuperare prima gli ID parent con paginazione, poi caricare le associazioni per quegli ID specifici.

Confronto delle Strategie di Caricamento

Ogni strategia offre vantaggi a seconda del contesto d'uso. La seguente tabella riassume i casi d'uso ottimali.

StrategiaCaso d'UsoVantaggiSvantaggi
Fetch JoinRelazioni @ManyToOneQuery SQL singolaProdotto cartesiano con collezioni
@EntityGraphCaricamento dichiarativoRiutilizzabile, leggibileMeno flessibile di JPQL
Batch FetchingCollezioni @OneToManyEvita il prodotto cartesianoQuery multiple
SubselectCollezioni accedute raramenteCarica solo quando necessarioSubquery correlata

La strategia subselect carica l'intera collezione al primo accesso a un qualsiasi elemento.

Order.javajava
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();
sql
-- 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. Questo pattern è particolarmente rilevante per gli endpoint a lettura intensiva, come trattato nel modulo sulle query JPA.

OrderSummaryDto.javajava
public record OrderSummaryDto(
    Long orderId,
    String orderNumber,
    String customerName,
    String customerEmail
) {}
OrderRepository.javajava
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à.

sql
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 in Spring Boot 4.x offre una gestione migliorata degli EntityGraph e ottimizzazione delle query. Comprendere la propagazione delle transazioni diventa essenziale quando si combinano queste strategie di fetching con logica di business complessa.

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

OrderService.javajava
@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. Strategie di test complete sono trattate nella guida Testcontainers per Spring Boot.

OrderRepositoryPerformanceTest.javajava
@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.

Sources

Checklist di Prevenzione N+1 per Spring Data JPA

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. Con Hibernate 7.4, la paginazione con fetch join funziona finalmente a livello di database.

Raccomandazioni per la produzione:

  • Attivare i log SQL in sviluppo per rilevare query multiple
  • Usare JOIN FETCH per relazioni @ManyToOne accedute frequentemente
  • Applicare @EntityGraph per un caricamento dichiarativo riutilizzabile
  • Configurare @BatchSize per 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
  • Aggiornare a Spring Boot 4.1+ per beneficiare dei miglioramenti alla paginazione di Hibernate 7.4
Sfida del giorno

Sapresti trovare il bug in Spring Boot?

Uno snippet reale, un bug nascosto, un tentativo al giorno. Senza account per provare.

Anthony Fillion-Maillet

Scritto da

Anthony Fillion-Maillet

Fondatore di SharpSkill

Sviluppatore fullstack da oltre 10 anni. Guida SharpSkill e risponde di tutto ciò che vi viene pubblicato.

Aggiornato il 2 settembre 2026

Tag

#spring data jpa
#n+1 problem
#fetch join
#entitygraph
#performance

Condividi

Articoli correlati