Architecture logicielle
Transactions distribuées et cohérence : 2PC, sagas, outbox et CAP
Avancé13 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
Dès que l'état est réparti entre plusieurs services et bases de données, la transaction mono-base disparaît et il faut la remplacer par autre chose. Ce guide explique ce que CAP et PACELC contraignent réellement, pourquoi le commit à deux phases est rarement la réponse en dehors d'une pile unique et de confiance, comment se comportent vraiment les sagas avec compensation, et comment l'outbox et l'idempotence transforment une livraison at-least-once en résultats métier corrects.
Ce que vous allez apprendre
- Énoncer précisément ce que CAP et PACELC disent, et ne disent pas, d'un système
- Expliquer le protocole de commit à deux phases et les pannes qui le font bloquer
- Concevoir une saga avec actions de compensation et choisir entre chorégraphie et orchestration
- Mettre en œuvre l'outbox transactionnelle pour que changements d'état et messages ne puissent pas diverger
- Raisonner sur la livraison at-least-once, l'idempotence et l'illusion de l'exactly-once
Dans une seule base de données, la cohérence est acquise par défaut : on ouvre une transaction, on fait plusieurs choses, on committe, et soit tout a eu lieu, soit rien. Cette garantie a façonné la manière dont la plupart des développeurs pensent la correction d'un programme, et elle disparaît dès qu'une opération métier s'étend sur deux bases ou deux services. Rien ne la remplace à l'identique. Il existe à la place un ensemble de protocoles et de patterns, qui sacrifient chacun quelque chose de précis, et le travail d'ingénierie consiste à choisir la perte avec laquelle vous pouvez vivre.
Ce guide commence par ce que la théorie contraint réellement, car CAP en particulier est plus souvent cité que compris. Il aborde ensuite les deux familles de réponses : les protocoles de validation atomique, qui tentent de préserver l'ancienne garantie, et les approches de type saga, qui renoncent à l'isolation et reconstruisent la correction à partir de compensations et d'idempotence. Le catalogue des patterns de conception des microservices les présente par leur nom ; ici, on examine comment ils échouent en pratique.
ACID, BASE et ce qu'apporte l'isolation
ACID décrit la transaction locale : atomicité (tout ou rien), cohérence (les contraintes sont respectées au commit), isolation (les transactions concurrentes ne voient pas les états intermédiaires des autres) et durabilité (les données committées survivent à un crash).
L'isolation est la propriété qui manque le plus quand elle disparaît, et il faut se rappeler que, même en local, elle est généralement plus faible que « sérialisable ». La plupart des bases utilisent par défaut le niveau read committed, où une transaction peut voir des lignes qu'une autre a committées pendant son exécution : les lectures non répétables et les lignes fantômes sont donc déjà possibles. Comprendre les anomalies que votre niveau d'isolation autorise (lecture sale, lecture non répétable, fantôme, write skew) est indispensable avant de distribuer quoi que ce soit, car la distribution ne fait qu'élargir ces fenêtres.
BASE en est le contrepoint délibéré : basically available, soft state, eventually consistent. Le système reste disponible et accepte les écritures, les réplicas peuvent diverger un moment et, en l'absence de nouvelles mises à jour, ils convergent. Ce n'est pas une ingénierie au rabais ; c'est un autre contrat, qui exige plus de travail de conception, pas moins, car chaque consommateur doit être écrit pour tolérer une valeur brièvement périmée.
La question métier qui tranche entre les deux n'est pas « avons-nous besoin de cohérence ? » (tout le monde répond oui) mais « que se passe-t-il si ces deux faits se contredisent pendant deux secondes ? ». Deux comptes d'un grand livre en désaccord, c'est inacceptable. Un compteur d'avis produit à la traîne, non. La plupart des systèmes réels ont besoin d'une cohérence forte dans un petit noyau et tolèrent la cohérence à terme partout ailleurs, et c'est pourquoi les agrégats comptent : l'agrégat est précisément la frontière à l'intérieur de laquelle vous exigez ACID.
CAP et PACELC, énoncés avec soin
CAP affirme qu'un système distribué ne peut pas garantir simultanément la cohérence (chaque lecture voit la dernière écriture), la disponibilité ou availability (chaque requête adressée à un nœud non défaillant reçoit une réponse) et la tolérance au partitionnement (le système continue de fonctionner malgré la perte de messages entre nœuds). Et puisque les partitions surviennent que vous les ayez prévues ou non, le vrai choix porte sur ce qu'il faut faire pendant une partition : refuser les requêtes pour préserver la cohérence (CP), ou répondre à partir d'un état potentiellement périmé pour préserver la disponibilité (AP).
Trois précisions dissipent l'essentiel de la confusion :
- Le choix ne s'applique que pendant une partition. Un système sain peut être à la fois cohérent et disponible, et il l'est généralement.
- C'est un choix par opération et par jeu de données, pas par système. Une même plateforme peut refuser un virement pendant une partition tout en continuant à servir un catalogue produits.
- La « cohérence » de CAP désigne la linéarisabilité, une garantie forte et bien précise. Ce n'est pas le C d'ACID, et cette coïncidence de lettres a alimenté des années de débats confus.
PACELC prolonge utilement ce résultat : en cas de Partition, choisir entre disponibilité (Availability) et Cohérence ; sinon (Else), choisir entre Latence et Cohérence. La seconde clause couvre les 99,9 % du temps où le réseau fonctionne, et elle nomme le compromis que vous faites bien plus souvent. Acquitter une écriture dès qu'un seul réplica l'a reçue est rapide mais risque de la perdre ; attendre un quorum est plus lent et plus sûr. Chaque stockage répliqué expose ce réglage (write concern, niveau de cohérence, nombre d'acquittements requis), et le fixer délibérément pour chaque opération pèse bien plus lourd que n'importe quelle étiquette collée sur la base de données.
Commit à deux phases et XA
La réponse classique à l'atomicité entre plusieurs ressources est le commit à deux phases (2PC), coordonné par un gestionnaire de transactions.
Phase 1 — préparation
Le coordinateur demande à chaque participant s'il peut committer. Chacun effectue le travail, l'écrit durablement dans son journal, acquiert les verrous nécessaires et vote oui ou non. Un vote oui est une promesse ferme : le participant doit être capable de committer plus tard, il conserve donc ses verrous.Phase 2 — commit ou annulation
Si tous ont voté oui, le coordinateur inscrit sa décision dans son propre journal et demande à tous de committer. Si un seul a voté non, il demande à tous d'annuler. Les participants appliquent la décision et libèrent leurs verrous.
Le protocole est correct, et son mode de défaillance est qu'il bloque. Si le coordinateur tombe après avoir recueilli les votes oui mais avant de diffuser sa décision, les participants sont coincés : ils ont promis de pouvoir committer, donc ils ne peuvent pas annuler de leur propre chef, et ils ignorent la décision, donc ils ne peuvent pas committer. Les verrous restent posés jusqu'à ce que le coordinateur redémarre et relise son journal. Ces transactions en doute sont un problème d'exploitation bien connu, et les résoudre à la main risque de briser l'atomicité pour de bon.
Les autres coûts sont structurels. Chaque participant doit implémenter le protocole, ce qui exclut la plupart des services HTTP modernes et de nombreux stockages de données. Les disponibilités se multiplient à la baisse : la transaction ne réussit que si tous les participants sont disponibles. La latence comprend au moins deux allers-retours plus des écritures durables dans le journal de chaque participant. Et le débit en pâtit, car les verrous sont conservés pendant tout le protocole : les lignes très sollicitées sérialisent alors le système entier.
XA est l'interface standard pour ce protocole, implémentée sur la JVM via JTA avec un XAResource par ressource participante. Il reste réellement pertinent dans un créneau étroit : un seul serveur d'applications qui coordonne une base de données et un fournisseur JMS, tous deux locaux, gérés par une même équipe d'exploitation, avec un débit modeste. C'est le cas traité dans transactions et JTA. C'est le mauvais outil entre des services déployés indépendamment, et « les transactions distribuées ne fonctionnent pas avec les microservices » n'est qu'un raccourci pour dire exactement cela.
Le commit à trois phases et le commit fondé sur un consensus (Paxos ou Raft pour répliquer la décision du coordinateur) règlent le problème du blocage, au prix de davantage d'allers-retours et d'une machinerie nettement plus lourde. Les systèmes qui ont besoin d'atomicité entre shards, comme les bases SQL distribuées, implémentent en interne quelque chose de cette famille, ce qui est une bonne raison de laisser une base de données s'en charger plutôt que de le construire vous-même.
Sagas
Une saga remplace une transaction distribuée par une séquence de transactions locales, chacune committée indépendamment, avec pour chaque étape une action de compensation capable d'en annuler l'effet si une étape ultérieure échoue.
Le compromis est explicite et mérite d'être dit franchement : vous conservez l'atomicité au sens faible où la saga soit aboutit, soit est compensée, et vous perdez entièrement l'isolation. Les états intermédiaires sont visibles. Entre la réservation du stock et l'encaissement du paiement, le stock est bel et bien réservé et d'autres transactions peuvent le voir. Toute conception de saga doit donc répondre à la question de ce que les autres acteurs observent en cours de route, et soit le rendre acceptable, soit le masquer derrière un statut.
Compenser n'est pas annuler une transaction. C'est une nouvelle action métier, avec sa propre sémantique : un remboursement n'efface pas un paiement, et un e-mail d'annulation a peut-être déjà été lu. Certaines étapes ne peuvent pas être compensées du tout (un e-mail envoyé, un fichier transmis à une chambre de compensation), et c'est pourquoi le conseil habituel est d'ordonner les étapes ainsi : d'abord les étapes compensables, puis une étape pivot qui décide de l'issue, puis des étapes rejouables qui doivent finir par réussir et ne sont jamais annulées.
Chorégraphie
Chaque service réagit à des événements et émet les siens. Il n'y a pas de coordinateur.
OrderService → OrderCreated
InventoryService → (on OrderCreated) reserve stock → StockReserved | StockUnavailable
PaymentService → (on StockReserved) charge → PaymentCaptured | PaymentDeclined
InventoryService → (on PaymentDeclined) release stock [compensation]
OrderService → (on PaymentCaptured) confirm | (on PaymentDeclined) cancel
Le couplage reste faible et chaque équipe reste maîtresse de son étape. Les coûts apparaissent à mesure que le flux grandit : aucun endroit unique ne décrit le processus, donc le comprendre suppose de lire chaque service ; des dépendances cycliques entre événements s'installent ; et répondre à « où la commande 4711 est-elle bloquée ? » exige de corréler des événements à travers plusieurs systèmes.
Orchestration
Un coordinateur détient l'état de la saga et émet des commandes.
// Orchestrator: explicit state machine, persisted after every transition.
@Component
class PlaceOrderSaga {
@SagaStep(order = 1, compensate = "releaseStock")
void reserveStock(SagaState s) { inventory.reserve(s.orderId(), s.lines()); }
@SagaStep(order = 2, compensate = "refund") // pivot: after this, we go forward
void capturePayment(SagaState s) { payments.capture(s.orderId(), s.total(), s.orderId()); }
@SagaStep(order = 3) // retriable: never compensated
void arrangeShipment(SagaState s) { shipping.schedule(s.orderId(), s.address()); }
void releaseStock(SagaState s) { inventory.release(s.orderId()); }
void refund(SagaState s) { payments.refund(s.orderId(), s.total(), s.orderId() + ":refund"); }
}
Le flux est explicite, testable et observable ; les timeouts et l'escalade sont concentrés en un seul endroit ; un outil de support peut afficher chaque instance en cours. Les coûts : un composant qui doit connaître tous les participants, le risque de voir la logique métier s'accumuler dans l'orchestrateur plutôt que dans les services, et un élément de plus à rendre hautement disponible. Notez que capture et refund transmettent tous deux une clé d'idempotence stable : sans elle, une compensation rejouée rembourse deux fois.
| Critère | Chorégraphie | Orchestration |
|---|---|---|
| Couplage | Faible ; les services ne connaissent que les événements | Le coordinateur connaît chaque participant |
| Visibilité du flux | Implicite, répartie entre les services | Explicite et interrogeable de façon centralisée |
| Taille adaptée | 3 à 4 étapes | Toutes, et indispensable au-delà d'environ 5 |
| Timeouts et escalade | Par service, dupliqués | Centralisés |
| Tester le flux complet | Difficile | Simple |
| Écueil à surveiller | Chaînes d'événements cycliques | Un monolithe distribué dans le coordinateur |
Dans les deux cas, les sagas ont besoin de timeouts sur chaque étape (une étape qui ne répond jamais doit finir par être considérée comme échouée), d'un état de saga persisté pour qu'un redémarrage du coordinateur reprenne là où il en était au lieu de tout recommencer, et d'un plan pour le cas où la compensation elle-même échoue, ce qui revient généralement à la rejouer indéfiniment et à lever une alerte d'exploitation, faute de tout autre recours.
Le problème de la double écriture et l'outbox
Un échec plus subtil se cache sous tout cela. Un service qui met à jour sa base puis publie un événement effectue deux écritures dans deux systèmes, sans atomicité. S'il plante entre les deux, l'état a changé et aucun événement n'a été publié ; s'il publie d'abord et que le commit échoue, les consommateurs agissent sur quelque chose qui n'a jamais eu lieu. Aucun de ces cas ne se rattrape en réessayant à l'aveugle.
L'outbox transactionnelle supprime le problème en rendant les deux écritures locales :
BEGIN;
UPDATE orders SET status = 'CONFIRMED' WHERE id = '8f21c' AND status = 'PENDING';
INSERT INTO outbox (id, aggregate_type, aggregate_id, type, payload, created_at)
VALUES (gen_random_uuid(), 'order', '8f21c', 'OrderConfirmed',
'{"orderId":"8f21c","total":"89.90","currency":"EUR"}'::jsonb, now());
COMMIT;
Un relais distinct publie ensuite les lignes de outbox vers le broker, puis les marque ou les supprime. S'il plante après la publication mais avant le marquage, le message est envoyé deux fois : c'est du at-least-once, que les consommateurs doivent déjà tolérer. Le relais peut interroger la table périodiquement, ou lire le write-ahead log de la base par change data capture, ce qui évite la charge du polling et préserve l'ordre des commits.
Son pendant côté consommateur est l'inbox : enregistrer l'identifiant de chaque message traité dans la même transaction que son effet, et ignorer tout ce qui est déjà enregistré. Ensemble, outbox et inbox transforment une paire de systèmes peu fiables en un pipeline fiable, sans aucun protocole distribué.
Pour les services en event sourcing, une autre option est le pattern listen-to-yourself : ajouter l'événement à l'event store comme unique écriture, et publier depuis l'event store. Il n'y a pas de double écriture, puisque l'event store est la source de vérité.
At-least-once, idempotence et l'illusion de l'exactly-once
Tout système de messagerie propose un choix entre at-most-once (on envoie sans se soucier de la suite, avec un risque de perte), at-least-once (on réessaie jusqu'à l'acquittement, avec un risque de doublon) et exactly-once (annoncé, et à lire attentivement). Presque tous les systèmes réels tournent en at-least-once, car perdre un ordre de paiement est pire que traiter un doublon que l'on sait détecter.
Une livraison exactly-once est impossible sur un réseau qui peut perdre des messages : un émetteur qui ne reçoit pas d'acquittement ne peut pas distinguer un message perdu d'un acquittement perdu, et doit choisir entre renvoyer (au risque d'un doublon) ou non (au risque d'une perte). Ce qui est atteignable, c'est un effet exactly-once, et il existe deux voies. La première est le traitement idempotent : le consommateur détecte et ignore les répétitions, à l'aide d'un identifiant de message stocké, d'une clé métier naturelle ou d'une mise à jour conditionnée par l'état, comme UPDATE ... WHERE status = 'PENDING', qui n'affecte tout simplement aucune ligne la seconde fois. La seconde est une frontière transactionnelle interne à un seul système : le producteur transactionnel de Kafka en mode lecture-traitement-écriture, par exemple, committe de manière atomique les enregistrements produits et les offsets consommés, ce qui donne une sémantique exactly-once à l'intérieur de Kafka mais ne dit rien de la base de données externe dans laquelle vous avez aussi écrit. Les garanties et leurs limites sont détaillées dans patterns et garanties de la messagerie.
Conséquences pratiques : rendez chaque consommateur idempotent, quoi que promette le broker ; propagez de bout en bout une clé d'idempotence de niveau métier plutôt que de vous fier aux identifiants de message du broker, qui changent lorsqu'un message est republié ; et conservez les enregistrements de déduplication plus longtemps que la fenêtre de réessai maximale plausible.
La cohérence à terme dans la conception
Choisir la cohérence à terme est une décision produit autant que technique, et les conceptions qui fonctionnent rendent le décalage visible au lieu de le cacher.
- Montrez l'état honnêtement. « Paiement en cours de traitement » est un statut véridique et acceptable ; un solde qui affiche silencieusement un montant périmé ne l'est pas.
- Lisez vos propres écritures. Un utilisateur qui vient de modifier son adresse doit voir la nouvelle. Dirigez ses lectures vers le primaire pendant une courte fenêtre, ou servez la valeur en attente depuis le côté écriture.
- Rendez la convergence observable. Surveillez le retard de réplication et de projection comme des métriques de premier plan, avec des alertes, car une projection bloquée reste silencieuse jusqu'à ce que quelqu'un se plaigne.
- Prévoyez la réconciliation. Exécutez une tâche périodique qui compare les visions que différents services ont des mêmes faits et signale les écarts. Tout système à cohérence à terme en accumule ; la seule question est de savoir si c'est vous qui les trouvez, ou un client.
- Versionnez et ordonnez les événements pour qu'un consommateur puisse écarter ce qu'il a déjà rendu obsolète, puisque l'ordre entre partitions n'est pas garanti par défaut.
Points clés à retenir
- C'est l'isolation, et non l'atomicité, que vous perdez vraiment quand une transaction s'étend sur plusieurs services ; chaque conception doit donc préciser ce que les autres acteurs peuvent observer en cours de route. CAP ne contraint le comportement que pendant une partition et par jeu de données ; PACELC nomme le compromis que vous faites en permanence : latence contre cohérence en fonctionnement normal.
- Le commit à deux phases est correct et bloquant : une panne du coordinateur entre la préparation et la décision maintient les verrous chez tous les participants, et c'est pourquoi XA convient à une pile unique et non à des services indépendants.
- Une saga est une séquence de transactions locales assorties de compensations ; placez les étapes compensables avant le pivot, rendez chaque étape idempotente, persistez l'état de la saga et mettez des timeouts partout.
- La chorégraphie convient aux flux courts et limite le couplage ; l'orchestration devient nécessaire dès qu'un flux compte plusieurs étapes ou que le métier doit voir où une instance est bloquée.
- L'outbox transactionnelle élimine localement le problème de la double écriture, et une inbox côté consommateur rend sûre la livraison at-least-once.
- La livraison exactly-once n'existe pas ; l'effet exactly-once, si, grâce à des consommateurs idempotents et à des clés d'idempotence métier transmises de bout en bout.
Sources et lectures complémentaires
Questions fréquentes
- Le théorème CAP m'oblige-t-il à choisir entre cohérence et disponibilité ?
- Seulement pendant une partition réseau, et seulement pour les données que la partition sépare. Quand le réseau est sain, c'est-à-dire presque tout le temps, vous pouvez avoir les deux, et le compromis intéressant devient alors latence contre cohérence, ce qu'ajoute PACELC. Traiter CAP comme un choix d'architecture permanent entre deux étiquettes produit des systèmes qui renoncent à une cohérence qu'ils n'avaient aucune raison d'abandonner.
- Pourquoi le commit à deux phases est-il jugé néfaste pour les microservices ?
- Parce qu'il bloque. Entre le vote de préparation et la décision de commit, chaque participant conserve ses verrous et ne peut pas décider seul ; une panne du coordinateur à ce moment-là laisse donc les ressources verrouillées jusqu'à son redémarrage. Il exige aussi que chaque participant implémente le protocole, lie leurs disponibilités entre elles et passe mal à l'échelle. Au sein d'un même serveur d'applications avec deux ressources locales, il convient très bien ; entre des services déployés indépendamment, non.
- Comment choisir entre chorégraphie et orchestration pour une saga ?
- Comptez les étapes et demandez-vous qui a besoin de voir le processus. Jusqu'à trois ou quatre étapes avec une gestion d'erreurs simple, la chorégraphie garde les services découplés et n'exige aucun composant supplémentaire. Au-delà, ou quand le métier doit pouvoir demander où une instance donnée est bloquée, un orchestrateur rend le flux explicite, testable et observable, au prix d'un composant qui connaît tous les participants.
- La livraison exactly-once est-elle possible ?
- Une livraison exactly-once sur un réseau non fiable est impossible, car l'émetteur ne peut jamais distinguer un message perdu d'un acquittement perdu. Ce qui est possible, c'est un effet exactly-once : une livraison at-least-once combinée à un traitement idempotent, ou un cycle transactionnel lecture-traitement-écriture au sein d'un seul système, comme le producteur transactionnel de Kafka. Les systèmes qui annoncent une sémantique exactly-once fournissent la seconde option, à l'intérieur de leurs propres frontières.