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.

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.

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.

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.

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.

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.

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 3.x introduce miglioramenti per la gestione degli EntityGraph e delle query ottimizzate.

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.

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.

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

Tag

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

Condividi

Articoli correlati