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.

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.
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.
@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
}@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.
@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.
-- 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 identiquesDé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.
# 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: TRACELes 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.
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.
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.
-- 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.idLe service utilise maintenant la méthode optimisée sans modification du code métier.
@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é.
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.
@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.
@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.
-- 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.
# application.yml
spring:
jpa:
properties:
hibernate:
# Batch size global par défaut
default_batch_fetch_size: 25Le 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.
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();-- 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.
public record OrderSummaryDto(
Long orderId,
String orderNumber,
String customerName,
String customerEmail
) {}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.
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.
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.
@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.
@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 FETCHpour les relations@ManyToOnefréquemment accédées - ✅ Appliquer
@EntityGraphpour un chargement déclaratif réutilisable - ✅ Configurer
@BatchSizepour les collections@OneToManyvolumineuses - ✅ 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
Partager
Articles similaires

Spring GraphQL en entretien : resolvers, DataLoaders et gestion du problème N+1
Préparez vos entretiens Spring GraphQL avec ce guide complet. Resolvers, DataLoaders, gestion N+1, mutations et bonnes pratiques pour réussir vos questions techniques.

Spring Boot 3.4 Virtual Threads : questions d'entretien et benchmarks de performance
Maîtrisez les Virtual Threads Java 21 avec Spring Boot 3.4 : 15 questions d'entretien, benchmarks de performance et patterns de migration pour réussir vos entretiens techniques.

Spring Boot logging en 2026 : logs structurés pour la production avec Logback et JSON
Guide complet des logs structurés Spring Boot. Configuration Logback JSON, MDC pour le tracing, best practices production et intégration ELK Stack.