🎯 OBJECTIF
Comprendre comment :
@Modifying) court-circuitent Hibernate et pourquoi c'est dangereuxclearAutomatically et flushAutomatically pour éviter les bugs silencieuxflush() avant clear()🧠 MODÈLE MENTAL
Hibernate maintient une "photographie" de chaque entité chargée en mémoire : le Persistence Context (cache L1). Avant de sauvegarder, il compare l'état actuel à cette photo et n'envoie que les différences — c'est le dirty checking. Ce mécanisme est la fondation sur laquelle repose toute la magie de JPA.
Les bulk operations (@Modifying + @Query) contournent entièrement ce mécanisme. Elles parlent directement à la base de données via JDBC, comme si Hibernate n'existait pas. La DB est mise à jour, mais la "photographie" en mémoire reste intacte — elle décrit maintenant un état qui n'existe plus. C'est un mensonge silencieux : entityManager.find() te retourne l'ancienne valeur, entity.getStatus() retourne le vieil état, et aucune exception n'est levée.
Le vrai danger n'est pas le bulk lui-même, c'est ce qui se passe après. clearAutomatically = true force Hibernate à vider son L1 après le bulk — mais c'est un reset total, pas une mise à jour ciblée. D'où la règle immuable : flush toujours avant de clear. Le flush envoie les modifications en attente à la DB, le clear vide le L1. Dans cet ordre, rien n'est perdu.
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.createdAt < :cutoff")
int archiveOldOrders(@Param("status") OrderStatus status,
@Param("cutoff") Instant cutoff);
@Modifying
@Query("DELETE FROM Order o WHERE o.status = :status AND o.updatedAt < :cutoff")
int purgeArchivedOrders(@Param("status") OrderStatus status,
@Param("cutoff") Instant cutoff);
}java🔑 Conclusion clé
La valeur retournée est le nombre de lignes affectées en DB. Mais le Persistence Context en mémoire ne sait pas que ces lignes ont changé.
sequenceDiagram
participant Service
participant L1 as Persistence Context (L1)
participant DB as Base de données
Service->>DB: SELECT * FROM orders WHERE customer_id = ?
DB-->>L1: entités chargées (status = PENDING)
L1-->>Service: List<Order> (status = PENDING)
Service->>DB: UPDATE orders SET status = 'ARCHIVED' WHERE ...
Note over L1: L1 non informé — snapshot obsolète
Service->>L1: findById(orderId)
L1-->>Service: Order{status = PENDING} — stale
Note over DB: DB: status = ARCHIVEDmermaidPiège critique — findById() ne recharge pas depuis la DB
Après un bulk, findById() retourne l'entité du cache L1 si elle est déjà présente — zéro aller-retour DB. La seule façon de forcer un rechargement est de vider le L1 (clear()) ou d'appeler entityManager.refresh(entity) explicitement.
@Modifying(clearAutomatically = true) // ✓ Vide le L1 après l'UPDATE
@Query("UPDATE Order o SET o.status = :status WHERE o.createdAt < :cutoff")
int archiveOldOrders(@Param("status") OrderStatus status,
@Param("cutoff") Instant cutoff);javaPiège du clear() sans flush() préalable
clearAutomatically = true vide le L1 sans flush préalable. Toutes les modifications en attente (dirty checking) sont définitivement perdues — Hibernate ne les enverra jamais en DB. Le @Transactional ne les sauvera pas : au commit, le L1 est vide.
// ✓ flushAutomatically force un flush AVANT le bulk
// ✓ clearAutomatically vide le L1 APRÈS le bulk
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("UPDATE Order o SET o.status = :status WHERE o.createdAt < :cutoff")
int archiveOldOrders(@Param("status") OrderStatus status,
@Param("cutoff") Instant cutoff);javaOu manuellement :
entityManager.flush(); // ✓ Envoie les modifications en DB AVANT le bulk
entityManager.clear(); // ✓ Vide le L1 — les prochains find() iront en DB
orderRepository.archiveOldOrders(OrderStatus.ARCHIVED, cutoff);java| Scénario | Que faire ? |
|---|---|
| Pas d'entités chargées avant le bulk | @Modifying seul suffit |
| Entités chargées, pas modifiées | clearAutomatically = true |
| Entités chargées et modifiées | flushAutomatically = true + clearAutomatically = true |
Un bulk UPDATE ne consulte jamais la colonne version et ne la met pas à jour. Deux transactions concurrentes peuvent modifier les mêmes lignes sans que personne ne le sache — aucune OptimisticLockException n'est levée.
| Critère | Bulk @Modifying | Dirty checking standard |
|---|---|---|
| Volume > 10 000 lignes | ✅ Recommandé | ❌ N+1 requêtes |
| Logique métier dans les entités | ❌ Bypassée | ✅ Exécutée |
Callbacks @PreUpdate/@PostUpdate |
❌ Non déclenchés | ✅ Déclenchés |
Lock optimiste @Version |
❌ Ignoré | ✅ Respecté |
| Audit / événements de domaine | ❌ Perdus | ✅ Émis |
⚡ TL;DR — chaque concept en une ligne
Bulk operation (@Modifying)
✓ Exécute un UPDATE/DELETE SQL en une seule requête sur des milliers de lignes sans charger les entités.
⚠ Bypass complet d'Hibernate : Persistence Context, dirty checking, @Version et cache L2 sont tous ignorés.
clearAutomatically = true
✓ Vide le Persistence Context après le bulk — les prochains find() iront chercher en DB.
⚠ Reset TOTAL du L1 : toutes les entités MANAGED deviennent DETACHED, modifications non-flushées définitivement perdues.
flushAutomatically = true
✓ Force un flush du L1 vers la DB avant d'exécuter le bulk — garantit que les écritures en attente ne sont pas perdues.
⚠ À utiliser en combinaison avec clearAutomatically pour un résultat cohérent.
@Version ignoré ✓ (aucun avantage) — le lock optimiste est complètement contourné par les bulk operations. ⚠ Les conflits concurrents ne sont pas détectés — risque de perte de données silencieuse sans aucune exception.
🎓 À retenir
findById() ne contourne pas le cache L1 — même après un bulk, il retourne l'entité périmée si elle est déjà en L1. Seul un clear() ou un refresh() force un vrai aller-retour DB.clear() — une collection lazy non encore chargée sur une entité DETACHED lèvera une LazyInitializationException à l'accès.clearAutomatically vide uniquement le L1 de la session courante. Les autres sessions continuent à lire l'ancien état depuis le cache L2 jusqu'à son expiration.@PreUpdate et les événements de domaine sont silencieusement ignorés — si des entités publient des événements Spring ou déclenchent des callbacks dans @PreUpdate, un bulk les bypasse complètement.