🎬 Passer en mode présentation

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

Terminé