# Spring Data JPA en 2026 : résoudre le problème N+1 avec fetch join et EntityGraph > Guide complet pour détecter et corriger le problème N+1 en Spring Data JPA. Fetch join, @EntityGraph, batch fetching et stratégies de performance pour les requêtes. - Published: 2026-03-17 - Updated: 2026-03-31 - Author: SharpSkill - Tags: spring data jpa, n+1 problem, fetch join, entitygraph, performance - Reading time: 12 min --- 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. ```java // Order.java @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String orderNumber; private LocalDateTime createdAt; // Relation ManyToOne en lazy par défaut depuis JPA 2.0 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // Relation OneToMany lazy par défaut @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) private List items = new ArrayList<>(); // Getters et setters omis } ``` ```java // Customer.java @Entity @Table(name = "customers") public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String email; // Getters et setters omis } ``` 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. ```java // OrderService.java - Code problématique @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // 1 requête : SELECT * FROM orders List orders = orderRepository.findAll(); // Pour chaque commande, accéder au client déclenche une requête return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), // N requêtes : 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 -- Requête 1 : récupération des commandes SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o -- Requêtes 2 à 101 : récupération de chaque client 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 autres requêtes identiques ``` ## 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: # Formate le SQL pour une meilleure lisibilité format_sql: true # Affiche les statistiques de session (requêtes, temps) generate_statistics: true logging: level: # Log détaillé des requêtes SQL org.hibernate.SQL: DEBUG # Affiche les paramètres des requêtes préparées org.hibernate.orm.jdbc.bind: TRACE ``` Les statistiques Hibernate fournissent un résumé précieux à la fin de chaque transaction. ``` 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. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Fetch join explicite pour charger les clients @Query("SELECT o FROM Order o JOIN FETCH o.customer") List findAllWithCustomer(); // Fetch join multiple pour plusieurs associations @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "JOIN FETCH o.items i") List findAllWithCustomerAndItems(); // Fetch join avec condition WHERE @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "WHERE o.createdAt > :since") List findRecentOrdersWithCustomer( @Param("since") LocalDateTime since ); } ``` Le fetch join transforme les requêtes N+1 en une seule requête optimisée. ```sql -- Une seule requête avec jointure 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. ```java // OrderService.java - Code optimisé @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // 1 seule requête avec jointure List orders = orderRepository.findAllWithCustomer(); // Aucune requête supplémentaire return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), order.getCustomer().getName() // Déjà chargé )) .toList(); } } ``` ## 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é. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // EntityGraph inline avec attributePaths @EntityGraph(attributePaths = {"customer"}) List findAll(); // EntityGraph avec plusieurs attributs @EntityGraph(attributePaths = {"customer", "items"}) List findByCreatedAtAfter(LocalDateTime since); // EntityGraph nommé référençant une définition sur l'entité @EntityGraph(value = "Order.withCustomerAndItems") List findByCustomerId(Long customerId); // Combinaison avec requête personnalisée @EntityGraph(attributePaths = {"customer"}) @Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%") List findByOrderNumberPrefix(@Param("prefix") String prefix); } ``` Les EntityGraphs nommés se définissent directement sur l'entité pour une réutilisation dans plusieurs repositories. ```java // Order.java @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 { // Champs identiques } ``` 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. ## 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. ```java // Order.java @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 sur la collection @OneToMany(mappedBy = "order") @BatchSize(size = 25) private List 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 -- Sans batch fetching : 100 requêtes SELECT * FROM order_items WHERE order_id = 1 SELECT * FROM order_items WHERE order_id = 2 -- ... 98 autres requêtes -- Avec @BatchSize(size = 25) : 4 requêtes 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: # Batch size global par défaut 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. ## 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égie | Cas d'usage | Avantages | Inconvénients | |-----------|-------------|-----------|---------------| | **Fetch Join** | Relations `@ManyToOne` | Une seule requête SQL | Produit cartésien avec collections | | **@EntityGraph** | Chargement déclaratif | Réutilisable, lisible | Moins flexible que JPQL | | **Batch Fetching** | Collections `@OneToMany` | Évite le produit cartésien | Plusieurs requêtes | | **Subselect** | Collections rarement accédées | Charge uniquement si nécessaire | Requête corrélée | La stratégie subselect charge la collection complète lors du premier accès à n'importe quel élément. ```java // Order.java @OneToMany(mappedBy = "order") @Fetch(FetchMode.SUBSELECT) private List items = new ArrayList<>(); ``` ```sql -- Requête subselect générée 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. ```java // OrderSummaryDto.java public record OrderSummaryDto( Long orderId, String orderNumber, String customerName, String customerEmail ) {} ``` ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Projection DTO avec constructeur @Query("SELECT new com.example.dto.OrderSummaryDto(" + "o.id, o.orderNumber, c.name, c.email) " + "FROM Order o JOIN o.customer c") List findAllOrderSummaries(); // Projection DTO avec 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 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 3.x introduit des améliorations pour la gestion des EntityGraphs et des requêtes optimisées. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // EntityGraph dynamique avec Specification @EntityGraph(attributePaths = {"customer"}) List findAll(Specification spec); // Pagination avec EntityGraph @EntityGraph(attributePaths = {"customer"}) Page findByCustomerNameContaining( String name, Pageable pageable ); // Slice pour pagination efficace @EntityGraph(attributePaths = {"customer"}) Slice findByCreatedAtBefore( LocalDateTime date, Pageable pageable ); } ``` Le chargement conditionnel permet d'appliquer différentes stratégies selon le contexte. ```java // OrderService.java @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final EntityManager entityManager; public List getOrdersWithGraph(String graphName) { // Récupération dynamique de l'EntityGraph EntityGraph graph = entityManager .getEntityGraph(graphName); return entityManager .createQuery("SELECT o FROM Order o", Order.class) .setHint("jakarta.persistence.loadgraph", graph) .getResultList(); } public List getOrdersForListing() { // Graph minimal pour liste return getOrdersWithGraph("Order.withCustomer"); } public List getOrdersForDetail() { // Graph complet pour détail return getOrdersWithGraph("Order.full"); } } ``` ## 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. ```java // OrderRepositoryPerformanceTest.java @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() { // Active les statistiques Hibernate Session session = entityManager.unwrap(Session.class); SessionFactory factory = session.getSessionFactory(); statistics = factory.getStatistics(); statistics.setStatisticsEnabled(true); statistics.clear(); } @Test void findAllWithCustomer_shouldExecuteSingleQuery() { // Given : 50 commandes en base createTestOrders(50); entityManager.clear(); statistics.clear(); // When : récupération avec fetch join List orders = orderRepository.findAllWithCustomer(); // Then : une seule requête exécutée 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 commandes en base createTestOrders(50); entityManager.clear(); statistics.clear(); // When : récupération sans optimisation List orders = orderRepository.findAll(); // Accès aux clients pour déclencher le lazy loading orders.forEach(o -> o.getCustomer().getName()); // Then : N+1 requêtes (51 au lieu de 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. ## Conclusion 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. **Checklist anti-N+1 :** - ✅ 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 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/spring-boot/spring-data-jpa-n-plus-1-fetch-join-entitygraph