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.

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;

    // 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<OrderItem> items = new ArrayList<>();

    // Getters et setters omis
}
Customer.javajava
@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.

OrderService.java - Code problématiquejava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public List<OrderDto> getAllOrders() {
        // 1 requête : SELECT * FROM orders
        List<Order> 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.

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

    // Fetch join explicite pour charger les clients
    @Query("SELECT o FROM Order o JOIN FETCH o.customer")
    List<Order> findAllWithCustomer();

    // Fetch join multiple pour plusieurs associations
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer c " +
           "JOIN FETCH o.items i")
    List<Order> findAllWithCustomerAndItems();

    // Fetch join avec condition WHERE
    @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
-- 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.

OrderService.java - Code optimiséjava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public List<OrderDto> getAllOrders() {
        // 1 seule requête avec jointure
        List<Order> 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();
    }
}

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

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // EntityGraph inline avec attributePaths
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll();

    // EntityGraph avec plusieurs attributs
    @EntityGraph(attributePaths = {"customer", "items"})
    List<Order> findByCreatedAtAfter(LocalDateTime since);

    // EntityGraph nommé référençant une définition sur l'entité
    @EntityGraph(value = "Order.withCustomerAndItems")
    List<Order> findByCustomerId(Long customerId);

    // Combinaison avec requête personnalisée
    @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 {
    // 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.

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 sur la 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
-- 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.

Order.javajava
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> 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.

OrderSummaryDto.javajava
public record OrderSummaryDto(
    Long orderId,
    String orderNumber,
    String customerName,
    String customerEmail
) {}
OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // 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<OrderSummaryDto> 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<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 3.x introduit des améliorations pour la gestion des EntityGraphs et des requêtes optimisées.

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // EntityGraph dynamique avec Specification
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll(Specification<Order> spec);

    // Pagination avec EntityGraph
    @EntityGraph(attributePaths = {"customer"})
    Page<Order> findByCustomerNameContaining(
        String name,
        Pageable pageable
    );

    // Slice pour pagination efficace
    @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) {
        // 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<Order> getOrdersForListing() {
        // Graph minimal pour liste
        return getOrdersWithGraph("Order.withCustomer");
    }

    public List<Order> getOrdersForDetail() {
        // Graph complet pour détail
        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.

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() {
        // 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<Order> 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<Order> 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

Tags

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

Partager

Articles similaires