Retour au blog
  • 11 min de lecture

Transactions distribuées en microservices : sagas et outbox

Transactions distribuées entre microservices : pourquoi le 2PC convient rarement, et comment garder des données cohérentes avec outbox, idempotence et sagas.

Tout commence par une phrase anodine en atelier de conception : « quand le client valide sa commande, on réserve le stock, on débite la carte et on crée l'expédition, tout ou rien ». Dans un monolithe adossé à une seule base PostgreSQL, c'est un BEGIN et un COMMIT. Dès que commande, stock, paiement et expédition vivent dans quatre services avec quatre bases, cette phrase devient une transaction distribuée, l'un des problèmes les plus coûteux d'une architecture microservices.

Ce que la base de données faisait gratuitement, atomicité, isolation, reprise après crash, il faut désormais le reconstruire à la main, au-dessus d'un réseau qui perd des messages, en duplique d'autres et ne garantit rien sur les délais. Voici pourquoi le commit à deux phases convient rarement, et comment l'outbox transactionnelle, l'idempotence et les sagas permettent de garder des données cohérentes malgré tout.

Pourquoi les transactions ACID s'arrêtent à la frontière d'un microservice

Le principe « une base par service » est ce qui rend les microservices indépendants : chaque équipe fait évoluer son schéma sans coordination. Le prix, c'est qu'aucune transaction ne peut englober deux services. Il n'existe plus de moteur unique qui verrouille, journalise et annule pour tout le monde.

Trois propriétés disparaissent en même temps :

  • L'atomicité : le stock peut être réservé alors que le paiement a échoué.
  • L'isolation : pendant l'exécution, les autres requêtes voient des états intermédiaires (stock réservé, commande pas encore confirmée).
  • La certitude sur le résultat : un appel réseau qui expire ne dit pas si l'opération distante a eu lieu. Un timeout n'est pas un échec, c'est un état inconnu.

Ce dernier point est le plus sous-estimé. Toute la suite en découle.

Le piège de la double écriture : base de données et broker

Avant même de parler de transaction entre services, la plupart des systèmes ont déjà un bug de cohérence à l'intérieur d'un seul service :

python
def place_order(command: PlaceOrder) -> Order:
    with transaction.atomic():
        order = Order.objects.create(customer_id=command.customer_id, status=Order.Status.PENDING)
 
    # Crash, redéploiement ou broker indisponible ici : la commande existe, l'événement n'existera jamais.
    producer.send("orders", OrderPlaced(order_id=order.id))
    return order

Écrire en base puis publier sur Kafka ou RabbitMQ, ce sont deux systèmes, donc deux écritures non atomiques. Inverser l'ordre ne règle rien : on publie alors un événement pour une commande dont la transaction peut encore échouer. Tant que ce problème n'est pas résolu, aucun pattern de transaction distribuée ne peut être fiable, parce qu'ils reposent tous sur des messages qui ne se perdent pas.

Le commit à deux phases (2PC) et ses limites entre microservices

La réponse académique est le commit à deux phases (XA dans le monde Java). Un coordinateur demande à chaque participant de se préparer (PREPARE TRANSACTION dans PostgreSQL) : le participant écrit tout sur disque, garde ses verrous et promet de pouvoir valider. Si tous répondent oui, le coordinateur envoie COMMIT PREPARED à chacun ; sinon, ROLLBACK PREPARED.

Sur le papier, c'est l'atomicité retrouvée. En pratique, c'est rarement viable entre microservices :

  • Le blocage : si le coordinateur tombe entre les deux phases, les participants restent « in doubt », verrous posés, jusqu'à ce qu'il revienne. Une ligne de stock verrouillée bloque toutes les commandes sur ce produit.
  • La disponibilité se multiplie : trois services à 99,9 % de disponibilité donnent une transaction disponible à environ 99,7 %. Chaque participant ajouté dégrade l'ensemble.
  • Les participants réels ne le supportent pas : un broker de messages, une API de paiement ou un service SaaS ne participent pas à un 2PC. PostgreSQL lui-même le désactive par défaut (max_prepared_transactions = 0).
  • Le couplage temporel : tous les services doivent être disponibles au même instant, ce qui annule l'intérêt principal du découpage.

Le 2PC garde sa place entre deux bases que l'on contrôle, sur un réseau fiable, à faible volume. Entre microservices, il faut accepter une autre approche.

Le socle : outbox transactionnelle et consommateurs idempotents

L'outbox transactionnelle

Le pattern transactional outbox résout la double écriture en n'écrivant que dans un seul système : la base du service. L'événement est inséré dans une table outbox, dans la même transaction que la donnée métier.

python
def place_order(command: PlaceOrder) -> Order:
    with transaction.atomic():
        order = Order.objects.create(customer_id=command.customer_id, status=Order.Status.PENDING)
        OutboxMessage.objects.create(
            aggregate_type="order",
            aggregate_id=order.id,
            event_type="OrderPlaced",
            payload={"order_id": str(order.id), "lines": command.lines_as_dict()},
        )
    return order

Un processus séparé, le relais, lit ensuite la table et publie sur le broker. Deux implémentations :

  • Par interrogation (polling), simple et sans dépendance supplémentaire :
sql
SELECT id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
  • Par capture de changements (CDC) : Debezium lit le journal de réplication de PostgreSQL et route les lignes de l'outbox vers Kafka, sans charge de requêtes sur la base.

Attention à l'ordre : SKIP LOCKED permet plusieurs relais en parallèle, mais ils publient alors dans le désordre. Si l'ordre compte, et il compte pour les événements d'un même agrégat, on publie avec l'identifiant de l'agrégat comme clé de partition et on n'autorise qu'un relais par partition.

L'idempotence côté consommateur : gérer les doublons

Le relais peut publier, crasher avant de marquer la ligne comme publiée, puis republier au redémarrage. L'outbox garantit donc une livraison au moins une fois, jamais exactement une fois. Chaque consommateur doit supporter les doublons.

La technique la plus robuste : enregistrer l'identifiant du message traité dans la même transaction que son effet.

sql
CREATE TABLE processed_message (
    consumer     text        NOT NULL,
    message_id   uuid        NOT NULL,
    processed_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (consumer, message_id)
);
rust
async fn handle(pool: &PgPool, msg: &ReserveStock) -> Result<(), HandlerError> {
    let mut tx = pool.begin().await?;
 
    let inserted = sqlx::query(
        "INSERT INTO processed_message (consumer, message_id) VALUES ('stock', $1)
         ON CONFLICT DO NOTHING",
    )
    .bind(msg.message_id)
    .execute(&mut *tx)
    .await?
    .rows_affected();
 
    if inserted == 0 {
        // Already processed: acknowledge without applying the effect twice.
        return Ok(());
    }
 
    reserve_stock(&mut tx, msg).await?;
    // The reply (StockReserved or StockRejected) goes through this service's outbox, in the same transaction.
    write_outbox_reply(&mut tx, msg).await?;
    tx.commit().await?;
    Ok(())
}

Même principe pour les appels HTTP sortants vers des tiers : une clé d'idempotence stable par opération métier, comme l'en-tête Idempotency-Key de Stripe, transforme un « je ne sais pas si le débit est passé » en un rejeu sans risque.

Sans outbox et sans idempotence, inutile d'aller plus loin : les sagas construites au-dessus perdront ou dupliqueront des opérations.

Le pattern saga : une transaction longue découpée en transactions locales

Une saga, notion formalisée par Garcia-Molina et Salem en 1987, est une suite de transactions locales. Chacune valide immédiatement dans son service et publie un événement qui déclenche la suivante. Si une étape échoue, on exécute des transactions de compensation pour les étapes déjà validées.

Point essentiel : une compensation n'est pas un rollback. On ne peut pas « dé-valider » une transaction commitée ; on exécute une nouvelle opération métier qui en annule l'effet. Rembourser n'est pas annuler un débit : le client voit deux lignes sur son relevé, et des frais peuvent s'appliquer. La compensation est une décision fonctionnelle à concevoir avec le métier, pas un détail technique.

Saga orchestrée ou chorégraphiée

  • Chorégraphie : chaque service réagit aux événements des autres. Pas de point central, mais le flux n'existe nulle part explicitement : il faut lire quatre bases de code pour comprendre ce qui se passe après OrderPlaced. Au-delà de trois ou quatre étapes, les dépendances cycliques et les compensations croisées deviennent ingérables.
  • Orchestration : un orchestrateur, souvent logé dans le service qui possède la transaction métier (ici la commande), envoie des commandes et reçoit les réponses. Le flux est lisible, testable, et l'état de chaque saga est persisté.

À ne pas confondre avec l'orchestration d'appels que fait un Backend For Frontend : un BFF agrège et façonne des réponses pour un client, il ne décide rien. Y loger une saga, c'est y déplacer de la logique métier.

Pour une transaction métier critique, je recommande l'orchestration. L'orchestrateur est une machine à états persistée, pas un enchaînement d'await en mémoire :

rust
enum OrderSagaState {
    Started,
    StockReserved,
    PaymentAuthorized,
    PaymentCaptured,
    ShipmentRequested,
    Completed,
    Compensating { failed_at: SagaStep },
    Compensated,
}

Chaque transition est écrite en base avec la commande suivante dans l'outbox, dans la même transaction. Un redémarrage reprend exactement où la saga s'était arrêtée.

L'ordre des étapes : compensables, pivot, rejouables

Chaque étape entre dans une de ces trois catégories :

  1. Compensable : peut être annulée par une compensation (réserver du stock, autoriser un paiement).
  2. Pivot : le point de non-retour. Une fois validée, la saga doit aller au bout (capturer le paiement).
  3. Rejouable : ne peut pas échouer durablement et sera réessayée jusqu'au succès (demander l'expédition, envoyer la confirmation).

Une saga correcte place toutes les étapes compensables avant le pivot et uniquement des étapes rejouables après. Si l'expédition peut être refusée pour une raison métier, elle doit passer avant la capture du paiement, sinon il n'existe plus de chemin propre en cas d'échec.

Le problème qu'on découvre en production : l'isolation perdue des sagas

Une saga est ACD, pas ACID. Pendant qu'elle s'exécute, ses états intermédiaires sont visibles et d'autres sagas agissent dessus. Anomalies typiques :

  • Mise à jour perdue : une saga d'annulation modifie une commande pendant que la saga de création la confirme, et l'une écrase l'autre.
  • Lecture sale : un calcul de disponibilité compte un stock réservé par une saga qui sera finalement compensée.
  • Compensation avant l'action : les messages arrivent dans le désordre, et « annuler la réservation » est reçu avant « réserver ». La réservation arrive ensuite et n'est jamais annulée.

Les contre-mesures sont connues, mais chacune se conçoit explicitement :

  • Verrou sémantique : un statut PENDING sur la commande, que les autres opérations respectent (refus ou mise en attente).
  • Mises à jour commutatives : concevoir les opérations pour que l'ordre n'importe pas (incrémenter et décrémenter plutôt qu'écrire une valeur absolue).
  • Relecture de la valeur : vérifier avant d'écrire que la donnée n'a pas changé depuis le début de la saga (versionnement optimiste).
  • Pierre tombale : quand une compensation arrive pour une action inconnue, l'enregistrer pour que l'action, si elle arrive plus tard, soit ignorée.
  • Réordonner pour réduire le risque : placer les étapes qui exposent un état sensible le plus tard possible.

Timeouts et réconciliation : l'état inconnu comme cas nominal

Revenons au point de départ. Le service de paiement ne répond pas dans les délais. Compenser tout de suite serait une erreur : le débit est peut-être passé. La bonne séquence :

  1. Réessayer avec la même clé d'idempotence, ce qui est sans risque.
  2. Si le doute persiste, interroger l'état de l'opération chez le tiers.
  3. Seulement ensuite, décider de continuer ou de compenser.

Il faut aussi prévoir le cas où rien ne converge : une file de messages en échec (dead letter queue) surveillée, un job de réconciliation qui compare périodiquement les états entre services, et un écran d'administration pour débloquer une saga à la main. Sans ces outils, une panne partielle chez un fournisseur de paiement se corrige à la main, commande par commande.

Machine à états maison ou moteur de workflow (Temporal)

Tout ce qui précède, persistance de l'état, reprise après crash, retries, timeouts, s'écrit en quelques milliers de lignes sérieuses et testées. Des moteurs d'exécution durable comme Temporal fournissent ces garanties clés en main : le workflow s'écrit comme du code séquentiel et le moteur le rejoue après un crash.

Le compromis : une brique d'infrastructure critique de plus à opérer, et des contraintes fortes sur le code des workflows (déterminisme, versionnement). Pour une ou deux sagas, une machine à états maison sur PostgreSQL reste plus simple. Au-delà, le moteur se rentabilise vite.

Avant tout : la frontière entre services est-elle au bon endroit ?

Si deux services ont besoin d'une transaction atomique pour presque chaque opération, c'est souvent le signe qu'ils appartiennent au même contexte métier et n'auraient pas dû être séparés. Regrouper la réservation de stock et la commande dans le même service supprime toute une saga, ses compensations et ses anomalies. C'est l'une des questions centrales pour choisir entre monolithe modulaire et microservices : le coût d'une transaction distribuée doit être justifié par un bénéfice réel du découpage.

Check-list avant de mettre en place une transaction distribuée

  1. Les frontières de service sont-elles justifiées, ou une transaction locale suffirait-elle ?
  2. Chaque publication d'événement passe-t-elle par une outbox transactionnelle ?
  3. Chaque consommateur et chaque appel à un tiers est-il idempotent ?
  4. Les étapes sont-elles classées (compensables, pivot, rejouables) et ordonnées en conséquence ?
  5. Chaque compensation a-t-elle été validée avec le métier, y compris ses effets visibles pour le client ?
  6. Les anomalies d'isolation ont-elles une contre-mesure explicite ?
  7. Les timeouts sont-ils traités comme des états inconnus, avec vérification avant compensation ?
  8. Existe-t-il une réconciliation, une file d'échecs surveillée et un moyen de reprendre une saga à la main ?

Si une de ces réponses est « non », le système n'est pas cohérent à terme : il est cohérent tant que rien ne tombe en panne. Faire cet audit avant la mise en production coûte beaucoup moins cher que corriger des commandes payées sans stock. C'est typiquement le genre de revue que je mène en conseil en architecture logicielle, et que je mets en œuvre en développement Python et Rust.

Un sujet à creuser pour votre projet ?

Discutons de votre contexte technique et de vos enjeux.

Parlons de votre projet