Transaction (Jakarta EE ) & cache
Retour d'expérience sur des problèmes rencontrés lors de la mise en production.
Info
Je ne vais parler que des analyses auxquelles j'ai participé, il se peut que d'autres problématiques aient été rencontrées.
Mais cela n'affecte pas l'explication des concepts que l'on va voir.
Introduction
L'annotation @Transactional (provenant de Jakarta Transactions) permet de gérer de manière déclarative les transactions dans les applications Java EE/Jakarta EE.
Elle contrôle les limites transactionnelles et détermine comment les opérations de base de données sont regroupées et validées.
L'annotation doit être placée sur les méthodes qui coordonnent plusieurs opérations de base de données. Cela garantit la cohérence des données.
Besoin de créer des services métier qui orchestrent les différents services => conception.
Cycle de vie
Lorsqu'une méthode annotée avec @Transactional est invoquée, le conteneur EJB gère automatiquement le cycle de vie de la transaction:
-
Démarrage : Une transaction démarre avant l'exécution de la méthode
-
Propagation : Le contexte transactionnel est propagé aux autres services appelés
-
Validation (commit) : Si la méthode se termine normalement, la transaction est validée
-
Annulation (rollback) : En cas d'exception runtime, la transaction est annulée
Mode de propagation
L'annotation supporte différents modes de propagation qui déterminent comment les transactions se comportent lors d'appels imbriqués:
-
REQUIRED (par défaut) : Rejoint une transaction existante ou en crée une nouvelle si aucune n'existe. C'est le comportement standard des EJB.
-
REQUIRES_NEW : Suspend la transaction courante et crée une nouvelle transaction indépendante. Les deux transactions sont isolées : le succès ou l'échec de l'une n'affecte pas l'autre.
-
MANDATORY : Exige qu'une transaction existe déjà, sinon une exception est levée.
-
SUPPORTS : Exécute avec une transaction si elle existe, sinon s'exécute sans transaction.
-
NOT_SUPPORTED : Suspend toute transaction existante et s'exécute en dehors de tout contexte transactionnel.
-
NEVER : Lève une exception si une transaction est active.
Limites
-
Appels locaux : L'annotation ne fonctionne que pour les appels via proxy. Les appels de méthodes internes à la même classe ne déclenchent pas le mécanisme transactionnel.
-
Exceptions checked : Par défaut, seules les exceptions runtime provoquent un rollback. Les exceptions checked nécessitent une configuration explicite.
-
Performance : Chaque transaction implique un coût (verrouillage, synchronisation). Évitez les transactions trop longues.
Etude de cas 1
Problème rencontré : Batch création préadmission
Le batch s'exécute en 3 temps : création, suppression et modification de rdv.
A Castres ce batch était systématiquement en erreur => avec l'erreur suivante :
Danger
OptimisticLockException sur patient lors du chargerPatientParIpp
Explications
- Pourquoi cette erreur sur une lecture ?
- Avant la requête, Hibernate applique toutes les modifications non encore propagées vers la base de données (flush), pour répondre à la contrainte de cohérence transactionnelle même sur une lecture. Cela permet d'avoir la donnée cohérente dans la transaction.
- Pourquoi cette erreur alors qu'on est dans une transaction ?
- Le premier problème est le fait que la transaction est globale aux 3 actions (même si chaque dossier est dans une transaction REQUIRES_NEW) Cela va à l'encontre de ce qui est dit plus haut 'évitez les transactions trop longues' elle doit être au plus près du besoin. il faut une transaction par action pour repartir d'une BDD à jour entre chaque action.
- deuxième problème est le fait que la modification est générée par le batch lui-même. Au fil des itérations on a ajouté une fonctionnalité sur l'INS qui s'effectue dans une transaction REQUIRES_NEW qui modifie le patient. Lorsqu'on revient dans la transaction parente le patient n'est plus cohérent.
Visualisation du problème
@startuml
skinparam backgroundColor transparent
skinparam shadowing false
skinparam borderColor #4f5f72
title Transaction REQUIRED vs REQUIRES_NEW - Problème OptimisticLockException
state "ÉTAPE 1 : Transaction T1 démarre" as etape1 #434c5e
note left of etape1 #3b4252
Service 1 : @Transactional(REQUIRED)
Transaction T1 ouverte
end note
state "ÉTAPE 2 : chargement des données" as etape2 #434c5e
state "ÉTAT Transaction 1 : Chargement initial dans T1" as transac1 #434c5e {
state "Persistence Context T1" as pc1 #darkgoldenrod {
state "A(id=1, version=1)\nManaged" as a1_v1 #4c566a
}
state "Base de données" as db1 #2e3440 {
state "A(id=1, version=1)" as db_a1_v1 #4c566a
}
}
note left of etape2 #3b4252
SELECT A WHERE id=1
A chargé dans le cache de T1
end note
state "ÉTAPE 3 : Appel Service 2 (REQUIRES_NEW)" as etape3 #d08670ac
note left of etape3 #3b4252
Service 2 : @Transactional(REQUIRES_NEW)
T1 suspendue, T2 créée
Modifie A et commit T2
end note
state "ÉTAT Transaction 2 : Après commit de T2" as transac2 #434c5e {
state "Persistence Context T1" as pc2 #darkgoldenrod {
state "A(id=1, version=1)\nManaged\n⚠️ Ancienne version" as a1_v1b #d08670ac
}
state "Base de données" as db2 #2e3440 {
state "A(id=1, version=2)\n✓ Modifié par T2" as db_a1_v2 #5e81ac
}
}
note right of transac2 #3b4252
Transaction T2 committée
BD : version=2
Mais T1 a toujours version=1 en mémoire!
end note
state "ÉTAPE 4 : Rechargement dans T1" as etape4 #434c5e
note left of etape4 #3b4252
SELECT A (nouveau chargement)
Hibernate doit faire un FLUSH
avant le SELECT
end note
state "ÉTAT Transaction 3 : Tentative de FLUSH" as transac3 #434c5e {
state "Persistence Context T1" as pc3 #darkgoldenrod {
state "A(id=1, version=1)\nTente UPDATE" as a1_flush #bf616a
}
state "Base de données" as db3 #2e3440 {
state "A(id=1, version=2)\nREJET!" as db_reject #bf616a
}
}
state "ÉTAPE 5 : OptimisticLockException" as etape5 #bf616a
note left of etape5 #3b4252
UPDATE A SET ...
WHERE id=1 AND version=1
❌ Aucune ligne mise à jour
(version attendue = 2)
OptimisticLockException levée
end note
etape1 -down-> etape2
etape2 -right-> transac1
etape2 -down-> etape3
etape3 -right-> transac2
etape3 -down-> etape4
etape4 -right-> transac3
etape4 --> etape5
legend right
|= Couleur |= Signification |
| <#4c566a> | Entité version initiale |
| <#5e81ac> | Modifié en BD (nouvelle version) |
| <#d08670ac> | Version obsolète (conflit) |
| <#bf616a> | Erreur / Rejet |
| <#darkgoldenrod> | Persistence Context (mémoire) |
| <#2e3440> | Base de données |
endlegend
note bottom #3b4252
**Problème :** T2 (REQUIRES_NEW) modifie la BD
sans mettre à jour le Persistence Context de T1.
Quand T1 tente un flush, les versions divergent.
end note
@enduml
Etude de cas 2 : cache
Dans un contexte particulier, on chargeait une donnée avec une requête HQL qui permettait de renseigner les dépendances à charger. Pourtant on rencontrait lors de l'accés aux données dépendantes des erreurs LazyInitializationException.
Analyse du problème
On s'est rendu compte qu'une première requête HQL chargeait le même type de données sans aucune dépendance et que c'est cette exécution qui engendrait le problème.
Explications
Cache de 1ᵉʳ niveau (Persistence Context) : - Chaque transaction possède son propre cache qui gère les entités managed chargées au cours de la transaction. - Le cache ne peut contenir qu'une seule instance d'une entité pour une transaction.
Comportement prévu par hibernate : - la première requête HQL charge les données sans aucune dépendance et les données sont placées dans le cache. - la seconde requête HQL est bien exécuté avec les dépendances. - cependant lors de la mise en cache des données si celles-ci existent déjà, elles ne sont pas remplacées et les relation sont ignorés - c'est la donnée du cache qui est utilisé par hibernate(sans les relations).
@startuml
skinparam shadowing false
skinparam backgroundColor transparent
title Évolution du Cache Hibernate - Étude de cas HQL avec JOIN FETCH
state "ÉTAPE 1 : État Initial" as etape1 #434c5e
note left of etape1 #3b4252
Cache vide
Transaction démarre
end note
state "ÉTAPE 2 : Requête 1" as etape2 #434c5e
note left of etape2 #3b4252
HQL: FROM A WHERE id IN (1,2,3)
end note
state "ÉTAPE 3 : Cache après Requête 1" as cache1 #darkgoldenrod {
state "A(id=1)\nB = proxy lazy ❌" as a1 #4c566a
state "A(id=2)\nB = proxy lazy ❌" as a2 #4c566a
}
state "ÉTAPE 4 : Requête 2" as etape4 #434c5e
note left of etape4 #3b4252
HQL: FROM A a LEFT JOIN FETCH a.bList
WHERE a.id IN (2,3,4)
end note
state "ÉTAPE 5 : Cache après Requête 2" as cache2 #darkgoldenrod {
state "Entités déjà présentes" as old #2e3440 {
state "A(1) - lazy ❌" as a1b #5e81ac
state "A(2) - lazy ❌\n⚠️ JOIN ignoré" as a3b #bf616a
}
state "Nouvelles entités" as new #2e3440 {
state "A(3) - [B1,B2] ✓" as a4b #5e81ac
state "A(4) - [B3] ✓" as a5b #5e81ac
}
}
state "ÉTAPE 6 : Résultat - Accessibilité" as etape6 #darkgoldenrod {
state "A(1).getBList() ❌" as r1 #d08670ac
state "A(2).getBList() ❌" as r2 #bf616a
state "A(3).getBList() ✓" as r3 #5e81ac
state "A(4).getBList() ✓" as r4 #5e81ac
}
etape1 -down-> etape2
etape2 -right-> cache1
etape2 -down-> etape4
etape4 -right-> cache2
etape4 -down-> etape6
legend right
|= Couleur |= Signification |
| <#d08670ac> | Sans relations (proxy lazy) |
| <#5e81ac> | Avec relations chargées |
| <#bf616a> | Problématique (intersection) |
endlegend
note right of cache2 #3b4252
**Règle du cache Hibernate:**
Une entité = Une instance unique par ID
Le premier chargement définit l'état
Les chargements suivants réutilisent l'instance
end note
@enduml