Java EE / Jakarta EE
Jakarta Transactions (JTA 2) : transactions gérées par le conteneur, propagation et XA
Intermédiaire11 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Jakarta Transactions (JTA) est le service qui permet au conteneur de démarrer, valider et annuler un travail couvrant une base de données, un broker de messages et toute autre ressource enrôlée. Ce guide vous apprendra ce que garantit une transaction et ce que les niveaux d'isolation laissent ouvert, comment se comporte chaque attribut de propagation, quelles exceptions déclenchent un rollback et pourquoi, quand recourir aux transactions gérées par le bean, et ce que coûte le commit à deux phases XA — sans oublier les pièges de l'auto-invocation et des transactions trop longues.
Ce que vous allez apprendre
- Expliquer les propriétés ACID, les niveaux d'isolation et les anomalies que chacun autorise
- Utiliser @Transactional et @TransactionAttribute avec le bon type de propagation
- Prévoir quand le conteneur annule : exceptions système ou applicatives, rollbackOn et setRollbackOnly
- Recourir à UserTransaction pour les transactions gérées par le bean quand les frontières déclaratives ne conviennent pas
- Déterminer quand le commit à deux phases XA est nécessaire et quelles alternatives existent
Une transaction est une promesse que la plateforme fait à votre place : tout ce qui se trouve à l'intérieur de la frontière se produit entièrement ou pas du tout, et personne d'autre ne voit l'état à moitié fait. Dans Jakarta EE, cette promesse est tenue par le service Jakarta Transactions — que tout le monde appelle encore JTA —, que le conteneur applique autour de chaque méthode d'enterprise bean et de chaque méthode CDI @Transactional, et qui coordonne aussi les ressources JPA, JMS et JDBC pour qu'un seul commit les couvre toutes. Les développeurs appellent rarement JTA directement : ils le configurent par des annotations, puis découvrent ses règles le jour où une exception ne déclenche pas de rollback, où un appel imbriqué n'obtient pas sa propre transaction, ou où un message part pour une commande qui n'a jamais été enregistrée. Ce guide explique ces règles assez précisément pour que vous puissiez anticiper ces situations.
Ce que garantit une transaction
Les propriétés ACID décrivent le contrat :
- Atomicité — toutes les opérations de la transaction prennent effet, ou aucune.
- Cohérence — la transaction fait passer la base d'un état valide à un autre ; les contraintes sont respectées au commit.
- Isolation — les transactions concurrentes ne voient pas l'état intermédiaire les unes des autres (dans une mesure qui dépend du niveau d'isolation).
- Durabilité — une fois validées, les modifications survivent à un crash.
L'isolation est la seule propriété négociable, car une isolation complète se paie en débit. Le standard SQL définit quatre niveaux, caractérisés par les anomalies qu'ils autorisent :
| Niveau | Lecture sale | Lecture non répétable | Lecture fantôme | Coût typique |
|---|---|---|---|---|
READ UNCOMMITTED | possible | possible | possible | le plus faible ; rarement utilisé |
READ COMMITTED | empêchée | possible | possible | défaut sur PostgreSQL, Oracle, SQL Server |
REPEATABLE READ | empêchée | empêchée | possible (empêchée sur certains moteurs) | défaut sur MySQL InnoDB |
SERIALIZABLE | empêchée | empêchée | empêchée | le plus élevé ; nouvelles tentatives en cas de conflit |
Une lecture sale (dirty read) voit des données non validées d'une autre transaction ; une lecture non répétable obtient une valeur différente en relisant la même ligne ; une lecture fantôme voit apparaître de nouvelles lignes lors d'une requête par intervalle répétée. READ COMMITTED associé au verrouillage optimiste (@Version en JPA) est la combinaison sur laquelle tournent la plupart des applications métier : les lectures sont peu coûteuses, et les mises à jour perdues sont détectées au moment de l'écriture au lieu d'être empêchées par des verrous. Notez que JTA ne fixe pas lui-même le niveau : configurez-le sur la data source.
Transactions locales et transactions globales
Une transaction locale ne concerne qu'une ressource : le commit() d'une unique connexion JDBC. La base fait tout le travail, JTA se contente de lui dire quand. Une transaction globale (distribuée) couvre plusieurs ressources — deux bases de données, ou une base et un broker de messages — et a besoin d'un coordinateur capable de les faire toutes valider ou toutes annuler. JTA fournit ce coordinateur, le transaction manager, et les ressources y participent via l'interface XA (javax.transaction.xa.XAResource, restée dans l'espace de noms Java SE).
Dans un serveur, les ressources sont déclarées comme data sources et connection factories XA ou non-XA. Enrôler une ressource non-XA dans une transaction aux côtés d'une autre ressource est soit refusé, soit géré par une « last resource commit optimisation » propre à l'éditeur, qui n'est pas entièrement sûre. La plupart des applications n'utilisent qu'une base de données et ont tout intérêt à choisir une data source non-XA : c'est plus simple, plus rapide, et le transaction manager vous offre toujours des frontières déclaratives et le rollback.
Transactions gérées par le conteneur
Le conteneur délimite la transaction autour d'un appel de méthode et décide, d'après une annotation, comment la méthode se situe par rapport à une éventuelle transaction déjà en cours chez l'appelant. Les six types de propagation sont les mêmes pour @TransactionAttribute sur les enterprise beans et pour @Transactional sur les beans CDI :
| Type | L'appelant a une transaction | L'appelant n'en a pas |
|---|---|---|
REQUIRED (défaut) | La rejoindre | En démarrer une nouvelle, la valider au retour |
REQUIRES_NEW | La suspendre, s'exécuter dans une nouvelle, la reprendre ensuite | En démarrer une nouvelle |
MANDATORY | La rejoindre | Lever TransactionalException / EJBTransactionRequiredException |
SUPPORTS | La rejoindre | S'exécuter sans transaction |
NOT_SUPPORTED | La suspendre, s'exécuter sans | S'exécuter sans |
NEVER | Lever une exception | S'exécuter sans |
REQUIRED convient à presque toutes les méthodes métier : l'appel le plus externe possède la transaction et les appels internes la rejoignent, si bien qu'un service qui appelle deux repositories obtient une seule unité atomique. REQUIRES_NEW sert au travail qui doit être validé même si l'appelant échoue — un enregistrement d'audit, un statut « traitement en échec » — et coûte une seconde connexion et, avec XA, une seconde branche. MANDATORY documente qu'une méthode n'est jamais un point d'entrée. NOT_SUPPORTED et SUPPORTS s'adressent aux chemins en lecture seule qui ne doivent pas tenir de transaction, comme les longues requêtes de reporting ; NEVER protège contre un appel depuis l'intérieur d'une transaction.
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.transaction.Transactional.TxType;
@ApplicationScoped
public class PaymentService {
@Inject OrderRepository orders;
@Inject PaymentGateway gateway;
@Inject AuditLog audit;
@Transactional // REQUIRED: one unit for the whole method
public void pay(Long orderId, CardToken card) {
Order order = orders.find(orderId);
try {
gateway.charge(order.total(), card); // external call; cannot be rolled back
order.markPaid(); // dirty-checked, written at commit
} catch (PaymentDeclinedException e) {
audit.record(orderId, "declined: " + e.code()); // survives the rollback below
throw e; // marked rollbackOn, so the order stays unpaid
}
}
}
@ApplicationScoped
public class AuditLog {
@PersistenceContext EntityManager em;
@Transactional(TxType.REQUIRES_NEW) // its own transaction, committed independently
public void record(Long orderId, String event) {
em.persist(new AuditEntry(orderId, event));
}
}
Pour que cela fonctionne comme prévu, PaymentDeclinedException doit annuler la transaction externe. Ce qui nous amène aux règles.
Ce qui déclenche un rollback
Les deux annotations distinguent deux sortes d'échec. Une exception système — toute RuntimeException ou Error — est traitée comme un bug ou une panne d'infrastructure : le conteneur marque la transaction pour rollback et, pour les enterprise beans, l'enveloppe dans une EJBException et détruit l'instance. Une exception applicative — une exception checked — est traitée comme un résultat métier : la transaction est validée et l'exception se propage telle quelle.
Vous pouvez modifier le comportement par défaut dans les deux sens :
@Transactional(rollbackOn = PaymentDeclinedException.class)annule sur cette exception checked ;dontRollbackOn = ValidationException.classvalide malgré cette exception runtime.- Sur les enterprise beans, annotez la classe d'exception avec
@ApplicationException(rollback = true); cela empêche aussi qu'une exception runtime soit enveloppée dans uneEJBException. - Par programmation, injectez
TransactionManagerouEJBContextet appelezsetRollbackOnly(). La transaction continue mais ne peut plus être validée — uneRollbackExceptionest levée à la frontière —, ce que doit faire une étape de validation enfouie dans la chaîne d'appels lorsqu'elle ne peut pas lever d'exception.
Une fois une transaction marquée pour rollback, plus rien ne peut retirer cette marque. Une surprise fréquente : une méthode interne REQUIRED lève une exception runtime, la méthode externe l'intercepte et poursuit, puis le commit externe échoue avec une RollbackException — parce que l'échec interne avait déjà condamné la transaction partagée.
Timeouts
Chaque serveur a un timeout de transaction par défaut (souvent 300 secondes) ; à son expiration, la transaction est annulée par un thread « reaper » et l'accès suivant à une ressource échoue. Fixez des limites plus courtes sur les méthodes de longue durée avec des annotations propres à l'éditeur (@TransactionTimeout sur WildFly) ou en appelant UserTransaction.setTransactionTimeout dans le code géré par le bean. Une transaction qui a besoin de plusieurs minutes révèle en général un traitement par lots qu'il faudrait découper.
Transactions gérées par le bean
Quand les frontières ne correspondent pas aux appels de méthode — une boucle de batch qui valide tous les mille enregistrements, une méthode qui doit décider à l'exécution s'il faut démarrer une transaction —, prenez la main avec UserTransaction :
import jakarta.annotation.Resource;
import jakarta.ejb.Stateless;
import jakarta.ejb.TransactionManagement;
import jakarta.ejb.TransactionManagementType;
import jakarta.transaction.Status;
import jakarta.transaction.UserTransaction;
@Stateless
@TransactionManagement(TransactionManagementType.BEAN) // the container will not demarcate for this bean
public class ImportJob {
@Resource UserTransaction tx;
@PersistenceContext EntityManager em;
public void importAll(List<Row> rows) throws Exception {
int i = 0;
tx.begin();
try {
for (Row row : rows) {
em.persist(toEntity(row));
if (++i % 1000 == 0) { // commit in chunks: bounded memory, bounded lock time
tx.commit();
em.clear();
tx.begin();
}
}
tx.commit();
} catch (Exception e) {
if (tx.getStatus() == Status.STATUS_ACTIVE) tx.rollback();
throw e;
}
}
}
UserTransaction est accessible à tout composant via @Resource ou le nom JNDI java:comp/UserTransaction ; les beans CDI peuvent aussi l'injecter. Un bean stateless en transactions gérées par le bean ne doit jamais retourner en laissant une transaction ouverte : le conteneur l'annule et journalise une erreur. L'EntityManager à portée transactionnelle rejoint la transaction active au moment où il est utilisé ; un em.clear() après chaque commit empêche donc le contexte de persistance de grossir.
TransactionSynchronizationRegistry est la troisième API à connaître : elle permet d'enregistrer un callback pour beforeCompletion et afterCompletion, ce qui sert à n'envoyer une notification qu'une fois le commit effectivement réussi. Les observers transactionnels de CDI 4.0 font la même chose de façon déclarative : void onOrderPaid(@Observes(during = TransactionPhase.AFTER_SUCCESS) OrderPaid event).
XA et commit à deux phases
Lorsqu'une transaction a enrôlé deux ressources XA — par exemple une data source XA PostgreSQL et une connection factory ActiveMQ Artemis —, le commit se déroule en deux phases :
- Préparation. Le transaction manager demande à chaque ressource de se préparer : écrire sur un stockage durable tout ce qui est nécessaire pour valider, puis voter oui ou non. Une ressource qui vote oui doit être capable de valider plus tard, même après un crash.
- Commit. Si toutes ont voté oui, le manager écrit un enregistrement de décision dans son propre journal de transactions et demande à chaque ressource de valider ; sinon, il leur demande à toutes d'annuler.
C'est le journal qui rend le protocole récupérable : si le serveur tombe entre les deux phases, le manager relit le journal au redémarrage et termine les branches en suspens (in-doubt). C'est aussi le coût opérationnel de XA : le journal doit résider sur un stockage fiable, survivre aux redéploiements et aux redémarrages de conteneur, et une ressource qui reste injoignable laisse des transactions in-doubt qui gardent leurs verrous jusqu'à la fin de la récupération ou jusqu'à ce qu'un administrateur les résolve. Le débit est plus faible à cause des allers-retours supplémentaires et des écritures disque forcées.
Avant d'opter pour XA, envisagez les alternatives :
- Une seule ressource. La plupart des services n'ont besoin que d'une base de données. Placez les messages dans une table (transactional outbox) et laissez un relais les publier ; la transaction locale couvre les deux.
- Consommateurs idempotents. Envoyez le message après le commit et faites en sorte que les consommateurs tolèrent un doublon ou un message qui référence un enregistrement qui n'existe pas encore.
- Compensation. Pour les processus métier longs qui traversent plusieurs services, les sagas remplacent l'atomicité par des actions de compensation explicites ; le guide sur les transactions distribuées examine les compromis.
XA est le bon choix quand les deux ressources sont réellement critiques, que le volume reste modéré et que l'équipe a la maturité opérationnelle nécessaire pour surveiller la récupération — le traitement classique message-driven bean plus base de données en est l'exemple type, et le conteneur en fait un simple changement de configuration d'une ligne.
Comment les pièces s'assemblent à l'exécution
Prenons une méthode de session bean en REQUIRED qui persiste une commande via JPA et envoie un message JMS via un JMSContext injecté :
- Le proxy demande au transaction manager de démarrer une transaction et l'associe au thread courant.
- La première utilisation de l'
EntityManagerobtient une connexion de la data source ; la connexion est enrôlée. - L'envoi via le
JMSContextenrôle la session JMS ; le message est mis en mémoire tampon, pas délivré. - La méthode retourne normalement. Le conteneur appelle la synchronisation
beforeCompletiondu provider JPA, qui effectue le flush du contexte de persistance — l'INSERTs'exécute à ce moment-là, et les violations de contraintes apparaissent ici sous forme dePersistenceException. - Le manager valide : en une phase si une seule ressource est enrôlée, en deux phases sinon. Le message ne devient visible des consommateurs qu'après le commit.
- Le contexte de persistance se ferme ; les entités deviennent détachées.
L'étape 4 explique pourquoi les exceptions de la base de données surgissent « après » la fin de votre code, et pourquoi une méthode @Transactional ne peut pas intercepter ses propres échecs de flush : le flush a lieu en dehors du corps de la méthode. Si vous devez réagir à une violation de contrainte, appelez explicitement em.flush() dans la méthode et interceptez l'exception à cet endroit.
Erreurs courantes
- L'auto-invocation. Appeler
this.record(...)sur une méthodeREQUIRES_NEWl'exécute dans la transaction de l'appelant, car le proxy est court-circuité. Injectez le bean dans lui-même ou déplacez la méthode dans un autre bean. - Compter sur un rollback pour les exceptions checked. Par défaut, elles valident. Utilisez
rollbackOnou@ApplicationException(rollback = true). - Intercepter l'exception interne et valider quand même. La transaction est déjà marquée ; le commit lève une
RollbackException. VérifiezgetStatus()(ougetRollbackOnly()sur le contexte de l'enterprise bean) avant de continuer, ou utilisezREQUIRES_NEWpour le travail interne. - Les transactions longues. Garder une transaction ouverte pendant un appel HTTP distant ou le temps de réflexion d'un utilisateur mobilise verrous et connexions. Limitez la frontière à l'accès aux données.
- Les effets de bord externes dans la transaction. Un e-mail envoyé avant un rollback ne peut pas être rappelé. Envoyez-le depuis un observer
AFTER_SUCCESSou via une outbox. - XA par défaut. Configurer toutes les data sources en XA coûte en performances et ajoute un mécanisme de récupération à exploiter, pour des applications qui n'enrôlent jamais de seconde ressource.
@Transactionalsur une méthode privée ou un objet non CDI. Pas de proxy, pas d'intercepteur, pas de transaction. Uniquement des méthodes publiques sur des beans managés.
À retenir
- JTA fournit au conteneur un transaction manager qui délimite le travail et coordonne les ressources enrôlées ; ACID est le contrat, et le niveau d'isolation se règle sur la data source, pas dans JTA.
REQUIREDpour les méthodes métier,REQUIRES_NEWpour le travail qui doit survivre à l'échec de l'appelant,MANDATORYetNEVERcomme garde-fous,SUPPORTS/NOT_SUPPORTEDpour les chemins en lecture seule.- Les exceptions runtime déclenchent un rollback, les exceptions checked valident ; modifiez ce comportement avec
rollbackOn,@ApplicationExceptionousetRollbackOnly. - Le flush a lieu au commit, en dehors de votre méthode ; appelez
em.flush()explicitement si vous devez traiter les erreurs de base de données sur place. - Utilisez
UserTransactionpour les batchs découpés et les frontières décidées à l'exécution ; ne laissez jamais une transaction ouverte au retour d'un bean stateless. - Le commit à deux phases XA est récupérable et correct, mais coûte en débit et en exploitation ; préférez une seule ressource plus une outbox, sauf si les deux ressources sont critiques.
- Les appels via
thiscourt-circuitent le proxy, et avec lui tous les attributs transactionnels.
Sources et lectures complémentaires
Questions fréquentes
- Quelle différence entre @Transactional et @TransactionAttribute ?
- Ils configurent le même service du conteneur. @TransactionAttribute (jakarta.ejb) s'applique aux enterprise beans, dont les méthodes sont transactionnelles par défaut. @Transactional (jakarta.transaction, depuis JTA 1.2) est un interceptor binding CDI qui fonctionne sur n'importe quel bean managé, doit être ajouté explicitement et apporte les attributs rollbackOn et dontRollbackOn. Le code récent sur des beans CDI utilise @Transactional ; les types de propagation sont identiques.
- Pourquoi ma transaction n'a-t-elle pas été annulée alors que j'ai levé une exception ?
- Parce qu'il s'agissait d'une exception checked. Par défaut, les deux annotations annulent sur RuntimeException et Error, et valident sur les exceptions checked. Déclarez @Transactional(rollbackOn = MyCheckedException.class), annotez la classe d'exception avec @ApplicationException(rollback = true) pour les enterprise beans, ou appelez setRollbackOnly avant de retourner.
- Ai-je besoin de XA avec une seule base de données et un seul broker de messages ?
- Si l'écriture en base et le message doivent être atomiques, oui : ce sont bien deux ressources, et seul un commit à deux phases garantit qu'elles réussissent ou échouent ensemble. Les alternatives sont le pattern transactional outbox (écrire le message dans une table, dans la même transaction de base de données, puis le publier depuis cette table) ou l'acceptation d'une courte fenêtre d'incohérence avec des consommateurs idempotents.
- JTA fixe-t-il le niveau d'isolation ?
- Non. JTA gère les frontières, la propagation et la coordination, pas l'isolation. L'isolation est une propriété de la connexion, définie sur la data source dans la configuration du serveur ou connexion par connexion via JDBC, et sa valeur par défaut dépend de la base : READ COMMITTED sur PostgreSQL et Oracle, REPEATABLE READ sur InnoDB de MySQL.