1. Qu’est-ce que le first-level cache
Si le mot « cache » évoque automatiquement chez vous l’image « on est allé une fois à la base de données, et ensuite tout est gratuit », alors avec Hibernate, il faudra vous enlever cette image et la remplacer soigneusement par une autre. Le first-level cache dans Hibernate est, en substance, le persistence context, et il résout en premier lieu le problème de l’identité des objets, et en second lieu, celui d’économiser des lectures superflues. C’est comme un bureau sur lequel vous gardez les documents pendant que vous effectuez une tâche spécifique : vous ne construisez pas une archive pour des siècles, vous ne voulez tout simplement pas imprimer deux fois la même feuille de papier et modifier accidentellement deux copies différentes.
En termes Hibernate : à l’intérieur d’une même Session (et en termes JPA, à l’intérieur d’un même EntityManager persistence context), l’ORM stocke la correspondance « clé d’entité → objet géré ». Lorsque vous demandez à nouveau une entité avec le même id, Hibernate préfère renvoyer le même objet Java plutôt que d’en créer un nouveau. Ce n’est pas un caprice ni une optimisation pour le plaisir. C’est un moyen de garantir que dans le cadre d’un unit of work, une ligne de base de données est représentée par une représentation gérée.
Si Hibernate ne faisait pas cela, vous pourriez facilement obtenir deux objets Product avec le même id, en modifier un, oublier l’autre, et ensuite des effets « amusants » : des UPDATE non évidents, une perte de modifications, des conflits dans les collections, et tout ce qu’on attribue généralement à « l’ORM qui fait encore des siennes ». Donc le first-level cache est plutôt une assurance contre le chaos logique qu’un « mode turbo de base de données ».
2. Identity map : une ligne de base de données — un objet
Quand Hibernate dit « first-level cache », il fait littéralement référence à une structure de données en mémoire qui ressemble à une Map. La clé n’est pas seulement le id, mais conceptuellement « type d’entité + id » (Hibernate appelle cela EntityKey). La valeur est une référence vers l’objet géré. C’est pourquoi, dans les limites d’une même transaction, vous obtenez la même instance.
Imaginez une situation quotidienne simple : dans une même méthode de service, vous vérifiez d’abord un produit, puis plus loin vous le lisez à nouveau par id « au cas où ». Si Hibernate créait à chaque fois un nouvel objet, vous devriez réfléchir manuellement : « lequel des deux objets est le “vrai” ? ». L’ORM a justement été créé pour que vous n’ayez pas à penser à cela — il pense à votre place, mais selon des règles strictes.
Regardons un exemple minimal dans le cadre de Commerce Persistence Lab. Ici, nous utilisons intentionnellement EntityManager pour voir la mécanique au plus près du « fer », plutôt qu’à travers la couche repository.
import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void demoFindTwice(EntityManager entityManager, long productId) {
// Première lecture : lors d’un cache miss, Hibernate ira en base de données et placera l’entité dans le persistence context
Product p1 = entityManager.find(Product.class, productId);
// Deuxième lecture du même id : la même instance gérée sera renvoyée depuis l’identity map
Product p2 = entityManager.find(Product.class, productId);
// Nous comparons les références (identity), pas l’égalité de champs
System.out.println(p1 == p2); // true
}
Notez : nous comparons avec ==, c’est-à-dire les références. Ce n’est pas equals(), pas d’« égalité de champs », mais une question directe : « est-ce le même objet en mémoire ou deux différents ? ». La réponse true est précisément la manifestation de l’identity map. À l’intérieur du persistence context actuel, Hibernate garantit qu’un find() répété par id renverra la même référence.
Pour ne pas tout garder en tête, il est utile de représenter la chaîne d’appels sous forme d’un schéma simple :
flowchart TD
A["Méthode de service
@Transactional"] --> B["EntityManager"]
B --> C["Persistence Context
(cache de premier niveau / identity map)"]
C -->|cache miss| D["(PostgreSQL)"]
D --> C
C -->|cache hit| B
Le premier find() mène presque toujours à un cache miss : Hibernate va en base, lit la ligne, crée l’objet, le place dans persistence context. Le second find() obtient un cache hit : l’objet est renvoyé depuis le contexte sans nouvelle création.
Et ici, il est important de saisir la nuance. Ce n’est pas simplement « pour aller plus vite ». Cela garantit que tous les changements que vous avez apportés à p1, vous les avez en réalité apportés au seul représentant de cette ligne dans le cadre du unit of work. C’est directement lié au dirty checking : Hibernate suit les modifications de l’objet géré et les synchronise lors du flush. S’il y avait deux objets, le dirty checking deviendrait un jeu de devinettes : lequel d’entre eux considérer comme « vrai » ?
3. Lectures répétées : find() et JPQL
C’est généralement là que l’attente du débutant s’effondre : « puisque l’entité est déjà dans le first-level cache, alors toute requête suivante ne la touchera pas et n’ira pas en base de données ». Cette attente est logique mais erronée. Hibernate garantit l’identité, pas une « annulation définitive du SQL ».
Un find() répété par id n’exécute effectivement pas de SQL, car Hibernate peut simplement consulter l’identity map : la clé existe, donc l’objet est déjà géré, il le renvoie. Mais une requête JPQL arbitraire est un mécanisme différent. La requête doit être exécutée, car Hibernate ne peut pas prouver « à l’œil » que le résultat de la requête est égal à ce qu’il a déjà en mémoire. Même si cela vous paraît évident (en tant qu’humain), l’ORM n’a pas à être télépathe.
Regardons un exemple qui surprend presque toujours la première fois :
import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void demoQueryStillExecutesSql(EntityManager entityManager, long productId) {
// D’abord, nous chargeons l’entité directement par id (souvent un SELECT)
Product p1 = entityManager.find(Product.class, productId);
// Ensuite, nous faisons une requête JPQL : le SQL peut être exécuté, même si l’entité est déjà gérée
Product p2 = entityManager.createQuery(
"""
select p
from Product p
where p.id = :id
""",
Product.class)
// Nous passons le paramètre de manière sécurisée, sans concaténation manuelle de chaînes
.setParameter("id", productId)
// Important : lors de la matérialisation, Hibernate réutilise l’instance gérée depuis le persistence context
.getSingleResult();
// Nous vérifions l’identité : ce sera le même objet en mémoire
System.out.println(p1 == p2); // true
}
L’astuce est la suivante : l’objet est finalement le même (== true), mais la requête JPQL peut envoyer une requête à la base. Elle ramène une ligne, puis Hibernate, lors de la matérialisation du résultat, vérifie : « n’y a-t-il pas déjà une instance gérée pour ce Product#id ? ». Si oui, il la renvoie sans en créer une nouvelle.
Le comportement se compose donc de deux parties : la requête (SQL) et la matérialisation des entités. Le first-level cache influence la deuxième partie, mais n’est pas tenu d’annuler la première.
Comment vérifier cela non seulement visuellement, mais aussi par les faits ? Exactement comme nous le faisons tout au long du cours : activer le profil sql-trace et regarder si le SQL est parti. Il est très facile d’obtenir une situation : p1 == p2 est vrai, mais le SQL a quand même eu lieu. Donc si vous évaluez la performance uniquement avec ==, vous jouez à « la devinette à un seul indice ».
Et un autre point important : le first-level cache n’est pas un query cache. Il ne met pas en cache « le résultat de la requête comme une liste d’id ». Il met en cache les objets gérés par clé. C’est une idée fondamentalement différente.
4. Limites : transaction, clear(), detach()
Il est très facile de surestimer le first-level cache si l’on oublie sa limite principale : il vit à l’intérieur du persistence context actuel, et dans une application Spring typique, le persistence context vit généralement à l’intérieur de la transaction. Dans notre cours, c’est particulièrement important car le projet a défini spring.jpa.open-in-view=false, ce qui signifie que la session n’est pas « étendue » jusqu’à la couche web.
La règle pratique est brutale mais utile : la transaction terminée, le first-level cache est terminé. Dans le prochain appel de service, un nouveau persistence context commencera, et Hibernate reconstruira les objets gérés à partir de zéro.
Outre la fin de la transaction, il existe deux autres « interrupteurs manuels » que nous avons déjà utilisés dans les modules précédents et en particulier dans les scénarios de performance : clear() et detach().
clear() efface complètement le persistence context. Toute l’identity map est remise à zéro, et Hibernate cesse de « se souvenir » des objets gérés.
import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void demoClearResetsIdentityMap(EntityManager entityManager, long productId) {
// Nous chargeons l’entité : elle devient gérée et entre dans l’identity map
Product p1 = entityManager.find(Product.class, productId);
// Réinitialisation complète du persistence context : tous les objets gérés sont "oubliés"
entityManager.clear();
// Une lecture répétée après clear() créera une nouvelle instance gérée (et généralement exécutera un SELECT)
Product p2 = entityManager.find(Product.class, productId);
// L’identité n’est plus conservée car nous avons nous-mêmes vidé le contexte
System.out.println(p1 == p2); // false
}
C’est un excellent exemple pour ressentir la limite : la base est la même, la ligne est la même, l’id est le même, mais l’objet Java est différent parce que vous l’avez délibérément retiré du persistence context.
detach() est une option plus ciblée : vous retirez du contexte une seule entité spécifique. C’est utile lorsque vous voulez « cesser de suivre » un objet spécifique, sans casser tout le contexte. Dans les scénarios de masse, on utilise plus souvent clear(), car le problème est généralement systémique : il y a trop d’objets dans le contexte, il gonfle et commence à gêner.
Pour fixer la logique, il est utile de voir cela comme un petit tableau :
| Opération | Ce qui se passe avec le first-level cache | Signification pratique |
|---|---|---|
| find(Product, id) | Si l’entité est déjà dans l’identity map — le même objet est renvoyé | Lecture répétée par id dans le unit of work |
| JPQL/HQL/Criteria | Le SQL peut être exécuté, mais pour chaque ligne, Hibernate réutilise l’objet géré s’il existe déjà | Le first-level cache influence la matérialisation, mais ne s’engage pas à annuler la requête |
| clear() | Le contexte est entièrement vidé, l’identity map est remise à zéro | Réinitialisation du graphe géré (bulk, grandes boucles, « recommencer à zéro ») |
| detach(entity) | Un objet spécifique est supprimé du contexte | Abandon ciblé du suivi d’un seul objet |
| commit / rollback | Le contexte est fermé, le first-level cache disparaît | Nouvelle transaction = nouveau cache de premier niveau |
Et encore une précision qui fait gagner beaucoup de temps : flush() ne vide pas le first-level cache. Flush synchronise les modifications avec la base de données (envoie le SQL), mais les objets gérés restent gérés et continuent de vivre dans le contexte. Donc « j’ai fait un flush, donc tout est maintenant à jour » — n’est pas toujours une pensée correcte. La fraîcheur et le flush sont des axes différents.
5. Stale state après les opérations bulk et les modifications directes
Peut-être l’aspect le plus « pratiquement désagréable » du first-level cache est qu’il peut contenir un état obsolète. Mais ce n’est pas un bug du cache. C’est une conséquence directe de sa signification : si vous avez chargé une entité gérée, Hibernate la considère comme votre source de vérité dans le cadre du unit of work. Et il continuera à renvoyer le même objet, même si les données ont déjà changé dans la base (ou si vous les avez modifiées par un chemin détourné).
La façon la plus courante de tomber dans un stale state est d’exécuter un bulk update/delete dans la même transaction où vous avez déjà chargé l’entité. Nous avons examiné cela en détail le jour précédent, et aujourd’hui, il est important de le voir à travers le first-level cache : « pourquoi une lecture répétée ne met-elle pas à jour l’objet ? ».
Regardons un exemple de démonstration :
import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void demoStaleAfterBulk(EntityManager entityManager, long productId) {
// Nous chargeons l’entité : elle est gérée, et son état est enregistré dans le persistence context
Product p = entityManager.find(Product.class, productId);
// L’opération bulk va directement en base de données et NE synchronise PAS le persistence context
entityManager.createQuery(
"""
update Product p
set p.name = :name
where p.id = :id
""")
.setParameter("name", "New name")
.setParameter("id", productId)
.executeUpdate();
// Dans l’objet géré, l’ancienne valeur restera, car le contexte n’a pas « appris » le bulk update
System.out.println(p.getName()); // ancienne valeur (stale)
// Nous relisons explicitement l’entité depuis la base dans l’instance gérée actuelle
entityManager.refresh(p);
// Maintenant, l’état correspond à la base
System.out.println(p.getName()); // New name
}
Pourquoi cela arrive-t-il ? Parce que le bulk update modifie les lignes dans la base directement et ne met pas à jour le persistence context. Et le first-level cache, en tant que partie du persistence context, continue de stocker l’objet géré dans son état « comme avant ». Lorsque vous accédez à p.getName(), Hibernate n’est pas obligé de « au cas où » relire la ligne : vous êtes déjà dans le modèle géré, l’objet est déjà chargé, donc les données sont là.
Comment vivre avec cela en tant qu’ingénieur ? Il existe plusieurs options honnêtes, mais sans transformer la leçon en une quête complète sur les outils d’optimisation, nous retiendrons une seule idée importante : si vous avez effectué une opération bulk et que vous comptez continuer à travailler avec les entités, vous devez explicitement résoudre la question de la synchronisation. Les options les plus évidentes sont clear() (vider le contexte et relire) ou refresh() (relire l’entité spécifique depuis la base).
Et voici qu’apparaît le parfait « test de compréhension » : si vous pensiez que le first-level cache était simplement un accélérateur, le stale state ressemble à « Hibernate est cassé ». Si en revanche vous comprenez que le first-level cache est une identity map et une partie du unit of work, le stale state apparaît comme une conséquence logique : vous avez contourné le mécanisme principal de synchronisation, donc synchronisez-vous manuellement.
6. Mini-scénario dans Commerce Persistence Lab
Maintenant, on aimerait que cela cesse d’être seulement des mots et devienne une sensation du comportement du système. Pour cela, il est pratique de créer une petite méthode « de laboratoire » dans notre projet, que l’on peut exécuter depuis un test, depuis un CommandLineRunner, ou depuis un appel temporaire dans un service. L’essentiel est d’activer le profil sql-trace et de regarder quelles requêtes partent réellement vers PostgreSQL.
Exemple d’une méthode qui « capture » trois états : premier find() (SQL présent), deuxième find() (SQL généralement absent), puis clear() et à nouveau find() (SQL de nouveau présent).
import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void observeFirstLevelCache(EntityManager entityManager, long productId) {
// Premier find() : il y aura souvent un SELECT et une entrée dans le persistence context
Product p1 = entityManager.find(Product.class, productId);
// Deuxième find() du même id : l’objet est réutilisé depuis l’identity map
Product p2 = entityManager.find(Product.class, productId);
// Nous confirmons qu’il s’agit du même objet en mémoire
System.out.println(p1 == p2); // true
// Nous vidons le contexte : ensuite, les objets gérés ne sont plus réutilisés
entityManager.clear();
// Après clear(), il y aura à nouveau une nouvelle instance (et généralement un autre SELECT)
Product p3 = entityManager.find(Product.class, productId);
// La référence est différente car nous avons réinitialisé manuellement l’identity map
System.out.println(p1 == p3); // false
}
Si vous exécutez cela sous sql-trace, vous verrez généralement un sens comme celui-ci : premier SELECT sur le produit — présent, deuxième — absent, après clear() — de nouveau présent. Et surtout : p1 == p2 est vrai, p1 == p3 est faux.
Il est utile de garder en tête un modèle court : le first-level cache vit dans les limites du unit of work et est responsable de « ne pas créer de doublons ». Tout ce qui se passe « entre les transactions » est une autre histoire, et c’est pourquoi nous sommes si prudents avec les mots « cache » et « optimisation ». D’abord, un modèle correct, ensuite, l’accélération.
7. Erreurs typiques dans la compréhension du first-level cache
Erreur n°1 : appeler le first-level cache « cache de l’application » et s’attendre à ce qu’il survive à la requête/transaction.
C’est une confusion très fréquente à cause du mot cache. Dans Hibernate, le first-level cache vit aussi longtemps que vit le persistence context. Dans notre projet (et en général dans une application Spring normale avec open-in-view=false), c’est généralement la limite du @Transactional unit of work. La transaction terminée, le contexte disparaît, le cache disparaît. Si vous avez besoin d’une réutilisation entre les transactions, ce sont des outils complètement différents et des risques complètement différents, mais pas le first-level cache.
Erreur n°2 : penser que « puisque l’entité est déjà chargée, toute requête sur elle n’exécutera plus de SQL ».
Un find() répété par id n’exécute en effet souvent pas de SQL, car il s’agit d’un accès direct via l’identity map. Mais une requête JPQL/HQL/Criteria n’est pas obligée de devenir « gratuite » simplement parce qu’une partie des données est déjà gérée. La requête peut être exécutée en base, et Hibernate réutilise simplement l’objet existant lors de la matérialisation du résultat. Il faut donc se fier au log SQL, et non au sentiment « mais j’ai déjà lu ».
Erreur n°3 : mesurer l’« économie de requêtes » uniquement avec p1 == p2 et ignorer la trace SQL.
== montre que l’objet a été réutilisé, mais ne prouve pas que SQL n’a pas été exécuté. Ce sont deux vérifications différentes. Dans le cours deep-dive, nous considérons presque toujours le SQL comme la source principale de vérité : soit il est parti, soit il ne l’est pas. Et == est une agréable confirmation de l’identity map, mais pas un substitut à la mesure.
Erreur n°4 : oublier le stale state après les opérations bulk et continuer à travailler avec les objets gérés comme si de rien n’était.
Le bulk update/delete modifie la base, mais ne synchronise pas le persistence context. Et le first-level cache dans ce contexte continue honnêtement à renvoyer l’objet géré dans son ancien état. En conséquence, vous regardez l’objet et pensez « c’est pareil en base », alors que la base a déjà changé. Cela ne se guérit pas par la prière, mais par des actions explicites : clear() ou refresh(), ou bien placer le bulk dans une transaction séparée où vous ne conservez pas de graphe d’entités vivantes.
Erreur n°5 : ne pas voir que le first-level cache est aussi un « coût mémoire ».
Le cache de premier niveau contient tous les objets gérés que vous avez chargés dans le cadre du unit of work. Dans une grande boucle ou un scénario de masse, il gonfle et commence à gêner (mémoire, temps de dirty checking, nombre d’objets suivis). C’est pourquoi dans les sujets précédents, nous avions des flush()/clear() dans les boucles : ce n’est pas un « rituel », mais une gestion de la taille du persistence context.
GO TO FULL VERSION