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.

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;
// 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
}@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.
@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.
-- 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 queriesDé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:
# 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: 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> {
// 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.
-- 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.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() {
// 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.
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.
@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 :
@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.
@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.
-- 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.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
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.
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.
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 :
-- 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é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<>();-- 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.
public record OrderSummaryDto(
Long orderId,
String orderNumber,
String customerName,
String customerEmail
) {}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.
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.
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.
@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.
@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
- Hibernate 7.4 What's New : la pagination avec fetch joins fonctionne désormais au niveau de la base de données
- Notes de version Spring Boot 4.1 : livré avec Hibernate 7.4.5.Final
- Documentation Hibernate ORM : référence officielle pour les stratégies de chargement et EntityGraph
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 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
- Mettre à jour vers Spring Boot 4.1+ pour bénéficier des corrections de pagination d'Hibernate 7.4
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.

Écrit par
Anthony Fillion-MailletFondateur 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
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.

Observabilité Spring Boot en 2026 : OpenTelemetry, Tracing Distribué et Questions d'Entretien
Guide complet sur l'observabilité Spring Boot avec OpenTelemetry et le tracing distribué. Configuration OTLP, Observation API, propagation de contexte et questions techniques pour entretiens backend.