Spring Data JPA N+1 en 2026 : Fetch Join, EntityGraph et Hibernate 7.4

Guide complet pour détecter et corriger le problème N+1 en Spring Data JPA. Fetch join, @EntityGraph, batch fetching et pagination Hibernate 7.4.

Résolution du problème N+1 avec Spring Data JPA, fetch join et EntityGraph

Le problème N+1 représente l'un des pièges de performance les plus fréquents en JPA. Une requête innocente pour récupérer 100 commandes peut déclencher 101 requêtes SQL : une pour les commandes, puis une pour chaque client associé. Cette multiplication silencieuse des requêtes dégrade les performances et surcharge la base de données.

Impact concret du N+1

Un endpoint retournant 50 articles avec leurs auteurs peut passer de 10ms à 500ms à cause du N+1. La détection précoce évite des problèmes critiques en production.

Comprendre le problème N+1 en JPA

Le problème N+1 survient lorsque JPA charge une collection d'entités puis effectue une requête supplémentaire pour chaque entité afin de charger ses associations. Ce comportement découle du chargement paresseux (lazy loading) par défaut des relations @OneToMany et @ManyToMany.

Prenons un modèle classique avec des commandes et des clients. Chaque commande appartient à un client, et cette relation est configurée en lazy loading par défaut.

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
}

Lorsqu'une requête récupère les commandes puis accède au nom du client, Hibernate exécute une requête supplémentaire pour chaque commande.

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();
    }
}

Pour 100 commandes, ce code exécute 101 requêtes SQL. Les logs Hibernate révèlent ce pattern destructeur.

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

Détecter le problème N+1 avec les logs Hibernate

La première étape consiste à activer les logs SQL pour identifier les requêtes problématiques. La configuration suivante affiche chaque requête exécutée par 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

Les statistiques Hibernate fournissent un résumé précieux à la fin de chaque transaction.

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

Le nombre 101 de statements JDBC pour une simple liste de commandes signale clairement un problème N+1.

Désactiver en production

Les logs SQL et les statistiques impactent les performances. Ces options doivent rester désactivées en production et réservées aux environnements de développement et de test.

Solution 1 : Fetch Join avec JPQL

Le fetch join charge les associations en une seule requête SQL grâce à une jointure. Cette approche explicite résout le N+1 en récupérant toutes les données nécessaires d'un coup.

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
    );
}

Le fetch join transforme les requêtes N+1 en une seule requête optimisée.

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

Le service utilise maintenant la méthode optimisée sans modification du code métier.

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();
    }
}

Prêt à réussir tes entretiens Spring Boot ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Solution 2 : @EntityGraph pour un contrôle déclaratif

L'annotation @EntityGraph offre une alternative déclarative au fetch join. Elle permet de définir quelles associations charger sans écrire de JPQL personnalisé. Pour une vue d'ensemble des bonnes pratiques JPA, consultez le module sur les bases de 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);
}

Les EntityGraphs nommés se définissent directement sur l'entité pour une réutilisation dans plusieurs repositories.

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
}

Le subgraph permet de charger des associations imbriquées. L'exemple ci-dessus charge les commandes, leurs items et les produits de chaque item en une seule requête.

Hibernate 7 introduit une syntaxe textuelle simplifiée pour @NamedEntityGraph qui réduit la verbosité des annotations :

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
}

Cette définition en une seule ligne remplace les annotations @NamedAttributeNode et @NamedSubgraph imbriquées.

Solution 3 : Batch Fetching pour les collections

Le batch fetching représente une alternative au fetch join pour les collections @OneToMany. Au lieu de charger chaque collection individuellement, Hibernate regroupe les requêtes par lots.

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<>();
}

Avec @BatchSize(size = 25), Hibernate charge les items de 25 commandes à la fois au lieu de 1. Pour 100 commandes, le nombre de requêtes passe de 101 à 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 configuration globale du batch size s'applique à toutes les collections de l'application.

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

Le batch fetching convient aux cas où le fetch join génère un produit cartésien trop volumineux. Pour une commande avec 10 items et 5 paiements, le fetch join retourne 50 lignes. Le batch fetching exécute 2 requêtes distinctes plus efficaces.

Pagination avec Fetch Join dans Hibernate 7.4

Une limitation historique des fetch joins était leur incompatibilité avec la pagination. Combiner setMaxResults() avec des fetch joins sur des collections forçait Hibernate à charger toutes les lignes correspondantes, puis à appliquer les limites en mémoire. L'avertissement firstResult/maxResults specified with collection fetch; applying in memory signalait ce problème de performance.

Hibernate 7.4 résout ce problème. La pagination avec des fetch joins sur les collections fonctionne désormais au niveau de la base de données grâce à des requêtes imbriquées. Hibernate détermine d'abord l'ensemble limité des identifiants de l'entité parente, puis récupère les collections associées uniquement pour ces lignes parentes.

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);
}

Le SQL généré utilise maintenant une sous-requête pour paginer au niveau de la base de données :

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
)

Cette amélioration réduit considérablement l'utilisation mémoire pour les endpoints paginés qui affichent des relations parent-enfant. Spring Boot 4.1 inclut Hibernate 7.4.5, rendant cette optimisation disponible par défaut.

Pour les applications encore sur des versions antérieures d'Hibernate, le pattern de récupération en deux phases reste l'approche la plus sûre : récupérer d'abord les IDs parents avec pagination, puis charger les associations pour ces IDs spécifiques.

Comparaison des stratégies de chargement

Chaque stratégie présente des avantages selon le contexte d'utilisation. Le tableau suivant résume les cas d'usage optimaux.

StratégieCas d'usageAvantagesInconvénients
Fetch JoinRelations @ManyToOneUne seule requête SQLProduit cartésien avec collections
@EntityGraphChargement déclaratifRéutilisable, lisibleMoins flexible que JPQL
Batch FetchingCollections @OneToManyÉvite le produit cartésienPlusieurs requêtes
SubselectCollections rarement accédéesCharge uniquement si nécessaireRequête corrélée

La stratégie subselect charge la collection complète lors du premier accès à n'importe quel élément.

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

Éviter le problème N+1 avec les projections DTO

Les projections DTO constituent une approche radicale mais efficace. En sélectionnant uniquement les colonnes nécessaires, la projection évite complètement le chargement d'entités et leurs associations. Ce pattern est particulièrement pertinent pour les endpoints en lecture seule, comme détaillé dans le module sur les requêtes 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
    );
}

Cette approche génère une requête SQL optimale sans surcoût de mapping d'entités.

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

Configuration avancée avec Spring Data JPA

Spring Data JPA dans Spring Boot 4.x offre une gestion améliorée des EntityGraphs et de l'optimisation des requêtes. La compréhension de la propagation des transactions devient essentielle lors de la combinaison de ces stratégies de chargement avec une logique métier complexe.

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
    );
}

Le chargement conditionnel permet d'appliquer différentes stratégies selon le contexte.

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");
    }
}

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tests de performance pour détecter le N+1

Les tests automatisés vérifient l'absence de problème N+1 en comptant les requêtes SQL exécutées. Pour des stratégies de test complètes, consultez le guide Testcontainers pour 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'assertion sur getQueryExecutionCount() garantit que les optimisations restent en place lors des évolutions du code.

Sources

Checklist anti-N+1 pour Spring Data JPA

Le problème N+1 représente un défi de performance majeur en Spring Data JPA, mais plusieurs solutions efficaces existent. Le fetch join et @EntityGraph résolvent la majorité des cas en chargeant les associations en une seule requête. Le batch fetching offre une alternative pour les collections volumineuses où le fetch join génère un produit cartésien. Avec Hibernate 7.4, la pagination avec fetch joins fonctionne enfin au niveau de la base de données.

Ce qu'il faut appliquer en production :

  • Activer les logs SQL en développement pour détecter les requêtes multiples
  • Utiliser JOIN FETCH pour les relations @ManyToOne fréquemment accédées
  • Appliquer @EntityGraph pour un chargement déclaratif réutilisable
  • Configurer @BatchSize pour les collections @OneToMany volumineuses
  • Préférer les projections DTO pour les lectures sans modification
  • Écrire des tests vérifiant le nombre de requêtes exécutées
  • Désactiver les logs SQL et statistiques en production
  • Profiler régulièrement les endpoints critiques avec les métriques Hibernate
  • Mettre à jour vers Spring Boot 4.1+ pour bénéficier des corrections de pagination d'Hibernate 7.4
Défi du jour

Tu saurais repérer le bug en Spring Boot ?

Un vrai bout de code, un bug caché, une tentative par jour. Sans compte pour essayer.

Anthony Fillion-Maillet

Écrit par

Anthony Fillion-Maillet

Fondateur de SharpSkill

Développeur fullstack depuis plus de 10 ans. Il dirige SharpSkill et répond de tout ce qui y est publié.

Mis à jour le 2 septembre 2026

Tags

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

Partager

Articles similaires