🎯 OBJECTIF
Comprendre comment :
@Cacheable se distingue du cache Hibernate🧠 MODÈLE MENTAL
JPA/Hibernate gère deux niveaux de cache. Le L1 est la mémoire de travail de ta transaction : tout ce qu'Hibernate lit dans la transaction est conservé en L1 — si tu relis le même ID, aucun SELECT en DB. Il disparaît à la fin de la transaction. Le L2 est la mémoire partagée entre toutes les transactions : des snapshots d'entités stockés dans un cache commun (Caffeine, EHCache...) pour éviter les aller-retours répétés en DB.
Le cache applicatif (@Cacheable) est différent : il cache le résultat d'une méthode, pas une entité JPA. Il vit au-dessus d'Hibernate, indépendamment du cycle de vie des transactions. La règle d'or : cacher des DTOs immuables avec @Cacheable, pas des entités JPA — une entité cachée devient DETACHED, ses proxies LAZY cassent, et elle peut être partagée entre threads.
Le cache L1 est la mémoire vivante d'une transaction. Hibernate y stocke toutes les entités lues ou créées dans la transaction courante.
sequenceDiagram
participant S as Service
participant L1 as Cache L1 (Persistence Context)
participant DB as Base de données
S->>L1: find(Order.class, 1L)
L1->>DB: SELECT * FROM orders WHERE id=1
DB-->>L1: [order1]
L1-->>S: order1
S->>L1: find(Order.class, 1L)
Note over L1: déjà en L1 — pas de SELECT
L1-->>S: order1 (même instance Java)mermaid@Transactional
public void demo() {
Order o1 = em.find(Order.class, 1L); // SELECT en DB
Order o2 = em.find(Order.class, 1L); // ✓ depuis L1, aucun SELECT
System.out.println(o1 == o2); // true — même instance
}java🔑 Le L1 garantit l'identité des objets dans une transaction
Deux appels find() avec le même ID dans la même transaction retournent exactement le même objet Java (==). C'est ce qui permet le dirty checking : Hibernate compare l'objet avec son snapshot initial pour détecter les modifications.
Le L2 est partagé entre toutes les transactions. Il stocke des snapshots (valeurs + version), pas des instances MANAGED.
flowchart LR
T1["Transaction 1"] -->|"lit"| L2["Cache L2\n(snapshots partagés)"]
T2["Transaction 2"] -->|"lit"| L2
T3["Transaction 3"] -->|"lit"| L2
L2 -->|"si absent"| DB["Base de données"]mermaid@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
@Id private Long id;
private String name;
private int stock;
}java🚨 Le L2 ne résout pas le N+1
Le cache L2 évite des SELECTs répétés pour des entités déjà connues. Mais si les entités ne sont pas encore en cache, le N+1 se produit normalement. Résoudre d'abord le N+1 avec JOIN FETCH ou batch fetching, puis envisager le L2 pour les données stables.
| Stratégie | Cohérence | Locks | Use-case |
|---|---|---|---|
READ_ONLY |
Éventuelle | Aucun | Référentiels immuables (pays, devises, types) |
READ_WRITE |
Forte | Soft locks | Entités métier modifiables |
NONSTRICT_READ_WRITE |
Éventuelle | Aucun | Données peu critiques à forte charge lecture |
TRANSACTIONAL |
Forte (JTA) | Transactionnel | Très rare en Spring Boot |
READ_ONLY ne détecte pas les modifications directes en DB
Si tu modifies la table directement (script SQL, bulk operation), le cache READ_ONLY reste avec les anciennes valeurs jusqu'à expiration ou restart. À utiliser uniquement pour des données vraiment immuables.
Le cache applicatif vit au-dessus d'Hibernate. Il cache le résultat d'une méthode, indépendamment de toute transaction.
@Service
public class CountryService {
@Cacheable(cacheNames = "countries", key = "#code")
public CountryDto getCountry(String code) {
Country c = countryRepo.findById(code).orElseThrow();
return new CountryDto(c.getCode(), c.getName()); // ✓ DTO immuable
}
@CacheEvict(cacheNames = "countries", key = "#code")
public void updateCountry(String code, String name) {
// ✓ invalidation explicite à la modification
}
}javaspring:
cache:
type: caffeine
caffeine:
spec: maximumSize=500,expireAfterWrite=10myaml🚨 Ne jamais cacher des entités JPA avec @Cacheable
// ❌ DANGER — entité DETACHED après la transaction
@Cacheable("products")
public Product getProduct(Long id) {
return productRepo.findById(id).orElseThrow();
}
// La prochaine fois : entité DETACHED, proxies LAZY cassés
// o.getCategory().getName() → LazyInitializationExceptionjavaCacher des DTOs : new ProductDto(p.getId(), p.getName()) — immuables, pas de proxies, thread-safe.
| Cache L2 | @Cacheable | |
|---|---|---|
| Ce qu'il cache | Snapshots d'entités | Résultat de méthode (DTO, calcul…) |
| Granularité | Par entité | Par méthode + paramètres |
| Invalidation | Par Hibernate au commit | Manuelle ou TTL |
| Quand l'utiliser | Entités stables, accédées par ID | DTOs, calculs coûteux, données multi-sources |
⚡ TL;DR — chaque concept en une ligne
Cache L1 (Persistence Context) ✓ Évite les SELECT répétés dans une même transaction — toujours actif, impossible à désactiver. ⚠ Limité à une transaction : deux transactions ne partagent pas le L1.
Cache L2 ✓ Partage des snapshots d'entités entre transactions — réduit les accès DB pour les données stables. ⚠ Ne résout pas le N+1. Les bulk operations le contournent et peuvent le rendre incohérent.
READ_ONLY vs READ_WRITE ✓ READ_ONLY pour les référentiels immutables, READ_WRITE pour les entités modifiables. ⚠ READ_ONLY ne détecte pas les modifications directes en DB — le cache peut être obsolète.
@Cacheable (cache applicatif) ✓ Cache le résultat d'une méthode avec TTL configurable — idéal pour les DTOs et calculs coûteux. ⚠ Ne jamais cacher des entités JPA — DETACHED, proxies cassés, partage mémoire entre threads.
🎓 À retenir
session.clear() en cours de transaction), les modifications peuvent ne pas être flushées silencieusement.UPDATE/DELETE directs (JPQL ou SQL natif) contournent Hibernate et ne purgent pas le cache L2. Le cache sert des données obsolètes jusqu'à expiration. Appeler em.clear() et invalider le cache explicitement après un bulk.@CacheEvict est l'oubli classique — cacher avec @Cacheable sans prévoir l'invalidation via @CacheEvict au moment de la mise à jour conduit à des données figées en cache jusqu'au TTL. Le TTL n'est pas un plan d'invalidation.