1. Coût de la lecture et choix LAZY/EAGER
Lorsque nous définissons les relations entre les entités, il est très facile de tomber dans le piège : penser que le travail est terminé lorsque le code compile et que l'enregistrement de test est sauvegardé avec succès. Mais dans une application réelle, les entités sont plus souvent lues que créées, et c'est généralement la lecture qui devient le point le plus coûteux. Et coûteux – pas philosophiquement, mais littéralement : en termes de temps de requêtes, de quantité de données, de charge sur la base de données, de mémoire JVM et de « surprises imprévues » dans les logs.
Pour ne pas transformer la discussion en abstraction, introduisons un modèle simple de « coût de lecture ». Lorsque le code exécute productRepository.findById(id), le coût se compose de plusieurs parties. Premièrement, le nombre de requêtes SQL, car chaque requête n'est pas seulement un travail de base de données, mais aussi un « ping-pong » réseau. Deuxièmement, la taille du résultat : combien de lignes et de colonnes ont réellement été extraites. Troisièmement, les effets indirects – par exemple, si à cause des jointures, la même entité s'est « multipliée » dans les lignes du résultat (et Hibernate regroupe ensuite tout).
FetchType est, en substance, votre contrat avec l'ORM : « quand je lis cette entité, j'ai généralement besoin des données associées immédiatement » ou « il ne faut pas traîner de superflu – ne charge que si je vais réellement accéder à la relation ». La bonne nouvelle : cela est entièrement contrôlable au niveau du mapping. La mauvaise nouvelle : si vous ne contrôlez pas, JPA fera le choix à votre place, et il n'est pas garanti qu'il sera comme vous le souhaitiez.
2. Que signifie LAZY et EAGER
FetchType.EAGER signifie : lorsque Hibernate charge une entité, il doit également charger cette association. Mais il décide lui-même comment exactement : via un JOIN dans la même requête SQL ou par une requête séparée par la suite. Techniquement, cela peut même vous sembler de la « magie », car vous n'avez pas écrit la deuxième requête manuellement, et elle est soudainement apparue dans les logs.
FetchType.LAZY signifie : lorsque Hibernate charge une entité, il n'est pas obligé de charger immédiatement l'objet associé. Il peut vous renvoyer un « substitut » (proxy/enveloppe) qui ressemble à une référence normale, mais le chargement réel ne se fera que lorsque vous accéderez aux données de la relation. Si vous n'accédez jamais à product.getCategory() – vous ne paierez jamais la lecture de la catégorie. Et si vous y accédez, vous paierez au moment où vous y avez accédé.
On peut retenir une formule très pratique :
| Choix | Comportement de l'application | Effet sur le coût |
|---|---|---|
| EAGER | « Je vais récupérer l'association immédiatement, même si tu ne demandes pas. » | le coût est payé à l'avance, parfois inutilement |
| LAZY | « Je vais récupérer l'association lorsque tu y accéderas réellement. » | le coût est reporté et parfois jamais payé |
Pour que cette formule ne reste pas une théorie, regardons un extrait minimal de Product avec un chargement paresseux explicite :
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
// Nous fixons explicitement la stratégie : la relation n'est pas chargée tant que nous n'y accédons pas
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
À ce niveau, il est important de comprendre non pas les détails de l'implémentation des proxies, mais le sens : Product peut être lu par une « lecture économique », et la catégorie – « à la demande ». C'est le modèle économique.
3. Fetch par défaut dans JPA
Voici le moment où de nombreux débutants disent : « Je n'ai rien choisi du tout, tout fonctionne comme ça ». Oui, ça marche – parce que JPA et Hibernate ont pris la décision à votre place. Mais cette décision ne correspond pas toujours aux scénarios d'application, surtout dans un projet pédagogique où nous voulons prévisibilité et contrôle.
JPA a des valeurs par défaut pour le type de fetch, et il est bon de les connaître comme la table de multiplication (non pas parce que c'est joli, mais parce que cela économise des heures de débogage) :
| Annotation de l'association | Fetch par défaut |
|---|---|
| @ManyToOne | EAGER |
| @OneToOne | EAGER |
| @OneToMany | LAZY |
| @ManyToMany | LAZY |
Faites attention au point le plus « sournois » : @ManyToOne est par défaut eager. Et @ManyToOne – c'est littéralement « l'association la plus fréquente dans les applications métier ». Dans notre mini-shop, cela concerne Product -> Category, OrderItem -> Product, OrderItem -> CustomerOrder. Si vous laissez la valeur par défaut, toute lecture de OrderItem pourrait potentiellement entraîner également celle de Product, même si vous vouliez simplement consulter quantity et unitPrice.
Par conséquent, la règle du jour est simple et pragmatique : pour les associations to-one (en particulier @ManyToOne), ne laissez pas le type de fetch « au hasard ». Nous le définissons explicitement afin que le modèle soit prévisible lors de la lecture.
4. Comment EAGER rend la lecture plus coûteuse
Si EAGER avait toujours signifié « un seul JOIN et on y va », la vie serait plus simple. Mais en réalité, EAGER signifie : « L'ORM s'engage à apporter l'association avant de retourner le résultat », mais comment exactement dépend du fournisseur, des paramètres et de la requête spécifique. Cela signifie que EAGER peut se révéler coûteux même là où vous ne vous attendez à rien de grave.
Scénario 1 : la méthode ne demande que le nom, et vous payez pour la moitié du magasin
Imaginez une méthode tout à fait innocente dans CatalogService : nous n'avons besoin que du name du produit.
import org.springframework.stereotype.Service;
@Service
public class CatalogService {
public String loadProductName(ProductRepository productRepository, Long id) {
// Si Product a des associations EAGER, elles peuvent être chargées dès l'étape findById(...)
Product product = productRepository.findById(id).orElseThrow();
// Nous lisons seulement un champ simple, mais le "coût de lecture" a pu augmenter à cause du eager par défaut
return product.getName();
}
}
Si les associations de Product sont marquées comme EAGER, alors lors de findById(id), Hibernate essaiera d'apporter à la fois Category, ProductDetails et tout ce qui y est attaché. Même si cette méthode ne touche absolument pas les catégories, détails et étiquettes. Le coût de lecture a augmenté, mais le sens métier n'a pas changé d'un iota.
Scénario 2 : EAGER ne signifie pas une seule requête
Supposons que Product.category soit eager. Hibernate peut faire un JOIN :
-- motif approximatif, pas une sortie littérale
-- Important : EAGER peut être implémenté par un seul JOIN, mais ce n'est pas une garantie "toujours ainsi"
select p.*, c.*
from product p
join category c on c.id = p.category_id
where p.id = ?;
Il peut aussi faire deux requêtes (d'abord le produit, puis la catégorie) :
-- Important : EAGER peut se transformer en plusieurs requêtes (dépend du fournisseur/paramètres/situation)
select * from product where id = ?;
select * from category where id = ?;
Et c'est là que ça devient subtil : vous auriez pu penser « bon, d'accord, une seule jointure, pas grave ». Mais en réalité, vous avez obtenu deux requêtes. Ou l'inverse : vous vous attendiez à deux requêtes, mais vous avez obtenu une jointure qui a extrait plus de colonnes que nécessaire.
La conclusion est simple : EAGER est une promesse de résultat, mais pas une forme d'exécution. Par conséquent, il faut raisonner comme un ingénieur, pas comme un chaman : « ai-je vraiment besoin de cela à chaque lecture d'entité ? » Si non, EAGER devient très facilement un « impôt sur chaque requête ».
5. Comment LAZY reporte le coût
LAZY est souvent perçu comme une « option d'optimisation ». Mais en réalité, c'est simplement une discipline honnête : ne pas tirer les données tant qu'elles ne sont pas nécessaires. Cela ressemble à une situation où vous ne portez pas tout du magasin dans vos bras, mais vous appelez la livraison uniquement pour les objets lourds. Si les objets lourds ne sont pas nécessaires – pas de livraison.
Regardons une simple illustration sur Product -> Category.
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
// LAZY : avant d'accéder à product.getCategory(), Hibernate n'est pas obligé de charger Category
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false) // Colonne FK dans la table product
private Category category;
Maintenant, la lecture du produit peut être moins coûteuse. Si le code fait ceci :
Product product = productRepository.findById(id).orElseThrow();
// Ici, nous utilisons seulement un champ simple de l'entité, sans accéder à l'association category
System.out.println(product.getName()); // imprimons seulement le nom
alors la base de données ne recevra probablement qu'une requête sur product. La catégorie n'est pas nécessaire – donc elle n'est pas chargée.
Mais dès que vous faites :
Product product = productRepository.findById(id).orElseThrow();
// IMPORTANT : l'appel à getCategory() peut déclencher une requête SQL supplémentaire (chargement LAZY)
String categoryName = product.getCategory().getName();
System.out.println(categoryName); // par exemple : "Laptops"
Hibernate comprend : « Ah, le propriétaire a réellement accédé à la catégorie » – et exécute un SQL supplémentaire pour charger Category. C'est normal. Ce n'est pas une « requête superflue », c'est une requête en fonction du besoin.
Un détail d'ingénierie très important (sans entrer dans les grandes futures thématiques) : pour que LAZY puisse charger les données, Hibernate doit avoir une réelle possibilité d'aller en base de données au moment de l'accès à l'association. Si vous avez extrait une entité, que vous l'avez « détachée » du travail avec la base, puis que soudainement vous voulez récupérer des données de l'association – l'ORM ne peut physiquement pas le faire. Par conséquent, LAZY n'est pas seulement une question d'économie, mais aussi de discipline quant au « où et quand nous lisons les données ».
Pour ancrer visuellement, on peut le représenter comme une séquence :
sequenceDiagram
participant Code as Code
participant ORM as Hibernate
participant DB as PostgreSQL
Code->>ORM: "findById(Product, 10)"
ORM->>DB: "SELECT product ..."
Note over ORM: "category n'est pas encore chargée (LAZY)"
Code->>ORM: "product.getCategory().getName()"
ORM->>DB: "SELECT category ..."
ORM-->>Code: "Laptops"
Le sens du diagramme n'est pas dans le SQL, mais dans le moment du paiement : avant l'appel à getCategory(), la catégorie était une « possibilité », pas un « coût ».
6. Configuration fetch de base pour mini-shop
Nous n'essayons pas « d'optimiser tout ce qui existe ». Notre objectif est plus modeste et plus utile : rendre le modèle prévisible. C'est-à-dire un modèle où la lecture de Product ne se transforme pas en loterie du « qu'est-ce qu'il va encore ramener ? ». Dans un projet pédagogique, c'est particulièrement important : sinon, vous déboguerez non pas l'idée JPA, mais les conséquences de valeurs par défaut aléatoires.
Pour Product dans notre mini-shop, un démarrage raisonnable ressemble à ceci : Category est lu paresseusement (car pas toujours nécessaire), ProductDetails est lu paresseusement (les détails ne sont pas toujours nécessaires), tags (si vous avez ajouté un Tag) – également paresseusement, car les étiquettes ne sont généralement pas nécessaires dans chaque scénario. Avec StockItem, il est important de ne pas confondre le fetch et la forme de l'association : pour le modèle simple de mini-shop, les stocks peuvent être conservés de manière unidirectionnelle depuis StockItem vers Product, et alors Product n'a pas du tout de champ stockItem. Si plus tard vous ajoutez une référence inverse dans Product, un bon début pour elle est LAZY, afin que le catalogue ne paie pas pour les stocks à chaque lecture.
Exemple d'un fragment de mapping « économique » pour Product :
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
// Souvent, category n'est nécessaire que dans des scénarios spécifiques, donc on commence avec LAZY
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
// Les détails du produit sont généralement "plus lourds" et pas toujours nécessaires, donc aussi LAZY
@OneToOne(fetch = FetchType.LAZY, mappedBy = "product")
private ProductDetails details;
Pour @ManyToOne, un tel LAZY est généralement un point de départ fiable. Avec @OneToOne, il est utile d'être un peu plus attentif : surtout du côté de mappedBy, le comportement réel dépend davantage d'Hibernate et de la configuration, donc il est préférable de regarder non seulement l'annotation, mais aussi les logs SQL. Le principe général reste le même : on commence par la lecture minimale nécessaire, et non pas par l'habitude de traîner l'association à l'avance.
Et séparément – un détail important concernant OrderItem. Les jours précédents, nous avons créé l'association OrderItem -> Product via @ManyToOne. Si vous ne définissez pas explicitement le fetch, par défaut ce sera eager. Par conséquent, la lecture d'une seule ligne de commande peut commencer à entraîner Product. Dans la vie réelle, cela est souvent étonnamment coûteux.
Mini-fragment d'un bon départ pour OrderItem :
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
// Position de commande peut être lue séparément de la commande elle-même, donc LAZY – un départ sûr
@ManyToOne(fetch = FetchType.LAZY)
private CustomerOrder order;
// Le produit pour la position de commande n'est pas toujours nécessaire "tout de suite" (par exemple, on lit seulement le prix/la quantité)
@ManyToOne(fetch = FetchType.LAZY)
private Product product;
Remarquez : ici il n'y a pas de « magie d'optimisation ». C'est simplement une règle honnête : les lignes de commande existent comme des lignes avec leur prix et leur quantité, et nous n'avons pas toujours besoin du produit complet et de la commande complète à chaque lecture de OrderItem. Nous choisissons LAZY comme discipline de lecture minimale nécessaire.
7. Erreurs typiques lors du choix de FetchType.LAZY / FetchType.EAGER
Lorsque vous commencez à gérer les types de fetch, il est très facile de « forcer la main » dans un sens : soit mettre EAGER partout et obtenir une lecture lourde, soit mettre LAZY partout et s'étonner que les données ne se chargent pas soudainement au moment inattendu. Ce sont des erreurs de croissance normales, mais il vaut mieux les voir dans un projet pédagogique que dans la production un vendredi soir (le vendredi soir est déjà une chose compliquée, il n'est pas nécessaire d'y ajouter Hibernate).
Erreur n°1 : laisser @ManyToOne par défaut sans remarquer qu'il est eager.
C'est l'un des problèmes les plus fréquents chez les débutants : le modèle « semble fonctionner », mais la lecture devient lourde et imprévisible. Cela se résout simplement : presque toujours définissez fetch = FetchType.LAZY sur @ManyToOne, à moins que vous n'ayez une raison très claire de garder eager.
Erreur n°2 : mettre EAGER parce que « c'est plus pratique, tout est disponible immédiatement ».
Cela ressemble à un souci pour votre futur vous, mais souvent ça se transforme en impôt sur chaque requête. Le confort est trompeur : vous économisez une ligne de code aujourd'hui et vous payez en performances et complexité de lecture demain. Dans un modèle pédagogique, il vaut mieux commencer par la lecture minimale nécessaire et ne pas hésiter à « récupérer » les données seulement là où elles sont réellement nécessaires.
Erreur n°3 : penser que EAGER garantit une seule requête avec un JOIN.
EAGER garantit « chargé », mais ne garantit pas « en un seul SQL ». Hibernate peut faire une jointure, mais aussi des requêtes supplémentaires. Par conséquent, la bonne façon de penser : EAGER – ce n'est pas « en une seule requête », mais « payer avant de retourner l'entité ».
Erreur n°4 : activer EAGER sur les collections (@OneToMany, @ManyToMany) « au cas où ».
Les collections sont potentiellement beaucoup de données. Aujourd'hui, un produit a deux étiquettes, demain vingt. Aujourd'hui, une commande a deux lignes, demain deux cents. Si vous traînez les collections en eager, vous obtenez très vite une lecture « tout et tout de suite », qui est étonnamment lourde. Même dans un projet pédagogique, cela conduit à un gonflement des requêtes et à des logs étranges.
Erreur n°5 : déclencher implicitement le chargement lazy via toString() ou la journalisation.
Nous avons déjà dit que toString() sur une entité doit être prudent. Si à l'intérieur de toString() vous accédez à getCategory().getName(), alors un simple log log.info("Product = {}", product) peut soudainement aller en base de données. Et ensuite, vous passerez du temps à vous expliquer pourquoi la journalisation « fait des requêtes ».
GO TO FULL VERSION