🎯 OBJECTIF
Comprendre comment :
HINT_FETCH_SIZE vs @BatchSize — deux niveaux distincts qui résolvent des problèmes différents🧠 MODÈLE MENTAL
Imagine un chef de cuisine qui prépare 50 assiettes. Pour chaque assiette, il retourne en cuisine chercher la sauce séparément — 50 allers-retours au lieu d'un seul. C'est le N+1 : Hibernate charge une liste d'entités (1 requête), puis pour chaque entité il envoie une requête séparée pour charger la relation (N requêtes).
La solution naïve — passer la relation de LAZY à EAGER — ne fait que déplacer le problème dans le temps. La requête initiale charge maintenant tout automatiquement, mais cela se produit toujours, même quand tu n'as pas besoin des données liées. Tu paies le coût N+1 de façon inconditionnelle.
La vraie solution est de contrôler explicitement quand et comment les relations sont chargées, en fonction du besoin concret : JOIN FETCH pour les petits volumes où tu as besoin de tout, @BatchSize pour les volumes moyens avec pagination, le pattern deux temps pour les gros volumes avec filtrage complexe. Et pour les très gros volumes batch, HINT_FETCH_SIZE contrôle le streaming JDBC — un niveau différent qui n'a rien à voir avec le N+1.
// ⚠️ N+1 classique — déclenche 1 + N requêtes SQL
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class)
.getResultList(); // 1 requête
for (Order order : orders) {
String name = order.getCustomer().getName(); // ⚠️ 1 requête par itération
}javasequenceDiagram
participant App
participant Hibernate
participant DB
App->>Hibernate: getResultList()
Hibernate->>DB: SELECT * FROM orders
DB-->>Hibernate: [order1, order2, order3]
loop Pour chaque Order (N fois)
App->>Hibernate: order.getCustomer().getName()
Hibernate->>DB: SELECT * FROM customers WHERE id = ?
DB-->>Hibernate: customer
end
note over App,DB: Total : 1 + N requêtesmermaid⚡ EAGER ne résout pas le problème
@ManyToOne(fetch = FetchType.EAGER) déplace le N+1 à la requête initiale — les requêtes séparées se produisent quand même, juste automatiquement. Hibernate n'émet pas un JOIN sauf si tu le lui demandes explicitement.
// ✓ JOIN FETCH : 1 seule requête SQL
List<Order> orders = em.createQuery(
"SELECT o FROM Order o JOIN FETCH o.customer",
Order.class
).getResultList();javaJOIN FETCH + pagination = piège critique
List<Order> orders = em.createQuery(
"SELECT o FROM Order o JOIN FETCH o.customer",
Order.class
)
.setMaxResults(20) // ⚠️ Hibernate IGNORE cette limite au niveau SQL
.setFirstResult(0) // ⚠️ et pagine en mémoire après avoir tout chargé
.getResultList();javaHibernate émet le warning HHH90003004 et charge toutes les lignes en mémoire avant de paginer — OutOfMemoryError sur de gros volumes.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"customer"}) // ✓ syntaxe simplifiée Spring Data
List<Order> findByStatus(OrderStatus status);
@EntityGraph(attributePaths = {"customer", "items", "items.product"})
Optional<Order> findById(Long id);
}java🔑 fetchgraph vs loadgraph
fetchgraph : seules les associations listées sont EAGER — toutes les autres deviennent LAZY même si annotées EAGER.loadgraph : les associations listées sont EAGER, les autres conservent leur défaut.Préférer fetchgraph pour un contrôle explicite.
EntityGraph + deux collections = explosion cartésienne
Fetcher deux collections simultanément génère un produit cartésien SQL : une commande avec 10 items et 3 paiements retourne 30 lignes. Solution : fetcher une collection à la fois, ou utiliser le pattern deux temps.
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 25) // ✓ charge 25 customers à la fois
private Customer customer;
}javaConfiguration globale :
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 25yamlAvec 100 commandes et batchSize=25, Hibernate émet 5 requêtes au lieu de 101. Compatible avec la pagination car il ne modifie pas la requête principale.
-- Résultat : au lieu de 100 SELECT individuels
SELECT * FROM customers WHERE id IN (1, 2, 3, ..., 25)
SELECT * FROM customers WHERE id IN (26, 27, ..., 50)
SELECT * FROM customers WHERE id IN (51, 52, ..., 75)
SELECT * FROM customers WHERE id IN (76, 77, ..., 100)sql@Service
@Transactional(readOnly = true)
public class OrderQueryService {
public Page<Order> findFilteredOrders(OrderFilter filter, Pageable pageable) {
// Temps 1 : requête légère pour paginer sur les IDs uniquement
Page<Long> orderIds = orderRepository.findIdsByFilter(filter, pageable);
// Temps 2 : charge les entités complètes avec leurs relations en un seul IN
List<Order> orders = orderRepository.findByIdInWithRelations(orderIds.getContent());
return new PageImpl<>(orders, pageable, orderIds.getTotalElements());
}
}javapublic interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o.id FROM Order o WHERE o.status = :status AND o.total > :minTotal")
Page<Long> findIdsByFilter(@Param("status") OrderStatus status,
@Param("minTotal") BigDecimal minTotal,
Pageable pageable);
@Query("SELECT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findByIdInWithRelations(@Param("ids") List<Long> ids);
}javaCes deux mécanismes résolvent des problèmes différents et sont complémentaires.
HINT_FETCH_SIZE |
@BatchSize |
|
|---|---|---|
| Niveau | JDBC / Base de données | Hibernate / ORM |
| Agit sur | Lignes SQL envoyées par la DB à JDBC | Entités chargées par Hibernate |
| Streaming DB | ✅ Oui — réduit la RAM | ❌ Non |
| Réduction N+1 | ❌ Non | ✅ Oui |
| Relations LAZY | ❌ Non | ✅ Oui |
HINT_FETCH_SIZE — streaming JDBC. Contrôle combien de lignes la DB envoie à JDBC en une fois. Réduit la consommation mémoire JVM sur de très gros volumes. Ne touche pas aux relations.
@Query("""
SELECT s FROM Stock s WHERE s.address = :address
""")
@QueryHints({
@QueryHint(name = HibernateHints.HINT_FETCH_SIZE, value = "100"),
@QueryHint(name = HibernateHints.HINT_READ_ONLY, value = "true"),
@QueryHint(name = HibernateHints.HINT_FLUSH_MODE, value = "COMMIT")
})
List<Stock> findStocksForBatch(String address);javaLa DB envoie 100 lignes → JVM les traite → puis 100 suivantes → etc. La JVM ne charge jamais les 10 000 lignes simultanément.
Combo prod pour gros volumes batch :
// Repository : HINT_FETCH_SIZE pour le streaming DB
@QueryHints({
@QueryHint(name = HibernateHints.HINT_FETCH_SIZE, value = "100"),
@QueryHint(name = HibernateHints.HINT_READ_ONLY, value = "true"),
@QueryHint(name = HibernateHints.HINT_FLUSH_MODE, value = "COMMIT")
})
List<Stock> findStocksForBatch(String address);
// Entité : @BatchSize pour les relations
@Entity
class Stock {
@OneToMany(mappedBy = "stock", fetch = LAZY)
@BatchSize(size = 50)
private List<Blocking> blockings;
}javaRésultat : DB → JVM par paquets de 100 lignes (streaming), blockings chargés par paquets de 50 (N+1 réduit), sans OOM, sans flush surprise.
| Contexte | Volume | Pagination ? | Stratégie |
|---|---|---|---|
| Liste simple, toutes données nécessaires | Petit (< 200) | Non | JOIN FETCH |
| Besoin déclaratif, réutilisable | Petit à moyen | Non | EntityGraph |
| Liste avec pagination, relations simples | Moyen | Oui | @BatchSize |
| Filtrage complexe + pagination + relations | Grand | Oui | Pattern deux temps |
| Traitement batch gros volume (export, job) | Très grand | Non | HINT_FETCH_SIZE + @BatchSize |
⚡ TL;DR — chaque concept en une ligne
N+1
✓ Se détecte facilement en activant show_sql=true : une salve de requêtes identiques après la requête principale est le signal caractéristique.
⚠ Passer en EAGER ne supprime pas le N+1 — il le rend juste systématique et invisible.
JOIN FETCH
✓ Charge la relation en une seule requête SQL avec un JOIN — idéal pour les petits volumes où tu as besoin de toutes les données.
⚠ Incompatible avec setMaxResults()/setFirstResult() sur une collection : Hibernate charge tout en mémoire puis pagine côté JVM — risque d'OutOfMemoryError.
EntityGraph ✓ Alternative déclarative à JOIN FETCH : définit les chemins de chargement sans modifier la requête JPQL, réutilisable via annotation. ⚠ Même comportement que JOIN FETCH sous le capot — les mêmes pièges de pagination s'appliquent.
@BatchSize ✓ Regroupe les N requêtes lazy en lots de taille fixe — réduit N+1 à ceil(N/batchSize)+1 requêtes, compatible avec la pagination. ⚠ N'élimine pas le N+1 complètement : avec 200 entités et batchSize=25, tu génères encore 9 requêtes.
HINT_FETCH_SIZE
✓ Contrôle le streaming JDBC — la DB envoie les lignes par paquets, la JVM ne charge jamais tout en mémoire.
⚠ Ne résout pas le N+1 — c'est un mécanisme JDBC, pas ORM. À combiner avec @BatchSize sur les relations.
Pattern deux temps
✓ Charge les IDs filtrés/paginés en premier, puis charge les entités avec leurs relations via WHERE id IN (...) — seule stratégie qui combine filtrage complexe + pagination + chargement complet.
⚠ Repose sur le Persistence Context pour fusionner les résultats : ne fonctionne que dans le même contexte transactionnel.
🎓 À retenir
HHH90003004 est un warning silencieux en prod — Hibernate émet ce log quand tu combines JOIN FETCH sur une collection avec setMaxResults(), mais l'application continue de tourner en chargeant toute la table en mémoire. Ce bug ne se manifeste qu'à l'échelle.HINT_FETCH_SIZE et @BatchSize résolvent des problèmes différents — HINT_FETCH_SIZE = "comment la DB envoie les lignes à JDBC" (mémoire JVM), @BatchSize = "comment Hibernate charge les relations" (N+1). Sur gros volumes avec relations, les deux ensemble + READ_ONLY + FLUSH_MODE COMMIT = combo optimal.Customer apparaît dans 15 commandes, Hibernate ne l'instancie qu'une seule fois. Un customer.setName(...) se propage silencieusement à toutes les commandes du même contexte.