Skip to content

Latest commit

 

History

History
847 lines (641 loc) · 34 KB

File metadata and controls

847 lines (641 loc) · 34 KB

🔝 Retour au Sommaire

20bis.1.2 — Distributed Transactions et Saga Pattern

Introduction

Dans le chapitre précédent, nous avons vu que l'approche Database per Service offre de nombreux avantages : autonomie des équipes, isolation des pannes, scalabilité indépendante. Cependant, elle introduit un défi majeur : comment garantir la cohérence des données lorsqu'une opération métier implique plusieurs services ?

Avec une base de données unique, une simple transaction SQL suffit. Mais quand les données sont réparties sur plusieurs bases, les transactions classiques ne fonctionnent plus. C'est là qu'interviennent les transactions distribuées et, plus particulièrement, le pattern Saga.

Ce chapitre vous expliquera pourquoi les transactions traditionnelles échouent dans un contexte distribué, et comment le pattern Saga permet de maintenir la cohérence des données de manière élégante et résiliente.


Rappel : Les Transactions ACID Classiques

Avant d'aborder les transactions distribuées, rappelons le fonctionnement des transactions dans PostgreSQL.

Les Propriétés ACID

Une transaction PostgreSQL garantit quatre propriétés fondamentales :

Propriété Signification
Atomicité Tout ou rien : soit toutes les opérations réussissent, soit aucune n'est appliquée
Cohérence La base passe d'un état valide à un autre état valide
Isolation Les transactions concurrentes ne s'interfèrent pas
Durabilité Une fois validée, la transaction survit aux pannes

Exemple Concret

Imaginons une commande e-commerce simple avec une seule base de données :

BEGIN;

-- 1. Créer la commande
INSERT INTO orders (id, user_id, total, status)  
VALUES (1001, 42, 150.00, 'pending');  

-- 2. Ajouter les articles
INSERT INTO order_items (order_id, product_id, quantity, price)  
VALUES (1001, 501, 2, 75.00);  

-- 3. Déduire le stock
UPDATE products  
SET stock = stock - 2  
WHERE id = 501;  

-- 4. Débiter le compte client
UPDATE user_accounts  
SET balance = balance - 150.00  
WHERE user_id = 42;  

COMMIT;

Si une erreur survient à l'étape 4 (solde insuffisant), PostgreSQL annule automatiquement toutes les opérations précédentes grâce au ROLLBACK. Le stock n'est pas déduit, la commande n'est pas créée. C'est l'atomicité en action.


Le Problème : Transactions sur Plusieurs Bases

Quand ACID Ne Suffit Plus

Maintenant, imaginons la même opération dans une architecture microservices avec Database per Service :

┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  Service    │     │  Service    │     │  Service    │     │  Service    │
│  Commandes  │     │  Catalogue  │     │  Paiements  │     │   Stock     │
└──────┬──────┘     └──────┬──────┘     └──────┬──────┘     └──────┬──────┘
       │                   │                   │                   │
       ▼                   ▼                   ▼                   ▼
  ┌─────────┐         ┌─────────┐         ┌─────────┐         ┌─────────┐
  │orders_db│         │catalog_ │         │payments_│         │stock_db │
  │         │         │   db    │         │   db    │         │         │
  └─────────┘         └─────────┘         └─────────┘         └─────────┘

Chaque service a sa propre base PostgreSQL. Il est impossible d'écrire une seule transaction SQL qui englobe les quatre bases.

Scénario de Défaillance

Voici ce qui peut mal se passer :

  1. ✅ Service Commandes → Crée la commande (succès)
  2. ✅ Service Stock → Réserve le stock (succès)
  3. ✅ Service Paiements → Débite le client (succès)
  4. ❌ Service Livraison → Échec (transporteur indisponible)

Résultat : Le client est débité, le stock est réservé, mais la livraison n'est pas planifiée. Le système est dans un état incohérent.

Sans mécanisme approprié, il faudrait manuellement annuler les opérations des étapes 1, 2 et 3. C'est exactement le problème que résolvent les transactions distribuées.


Les Approches pour les Transactions Distribuées

Il existe plusieurs stratégies pour gérer les transactions distribuées. Examinons-les avant de nous concentrer sur le pattern Saga.

1. Two-Phase Commit (2PC)

Le Two-Phase Commit est un protocole classique qui coordonne les transactions entre plusieurs bases de données.

Fonctionnement

                    Coordinateur
                         │
         ┌───────────────┼───────────────┐
         │               │               │
         ▼               ▼               ▼
    ┌─────────┐     ┌─────────┐     ┌─────────┐
    │  Base 1 │     │  Base 2 │     │  Base 3 │
    └─────────┘     └─────────┘     └─────────┘

Phase 1 (Prepare) : Le coordinateur demande à chaque base
                    si elle peut valider la transaction.

Phase 2 (Commit)  : Si toutes répondent "oui", le coordinateur
                    ordonne la validation. Sinon, rollback général.

Pourquoi 2PC Est Problématique

Bien que PostgreSQL supporte 2PC via PREPARE TRANSACTION, cette approche présente des inconvénients majeurs dans une architecture microservices :

Problème Description
Verrouillage prolongé Les ressources sont bloquées pendant toute la durée du protocole
Point de défaillance unique Si le coordinateur tombe en panne, les transactions restent en suspens
Latence élevée Deux allers-retours réseau nécessaires
Couplage fort Tous les participants doivent être disponibles simultanément
Non adapté au cloud Mal compatible avec les environnements élastiques et éphémères

En pratique, le 2PC est rarement utilisé dans les architectures microservices modernes.

2. Le Pattern Saga (Recommandé)

Le pattern Saga est l'alternative privilégiée. Au lieu d'une transaction atomique globale, il découpe l'opération en une séquence de transactions locales, chacune avec une action de compensation en cas d'échec.


Le Pattern Saga en Détail

Principe Fondamental

Une Saga est une séquence de transactions locales où :

  • Chaque transaction met à jour une base de données et publie un événement
  • Si une étape échoue, des transactions de compensation sont exécutées pour annuler les étapes précédentes
  • La cohérence est éventuelle (eventual consistency), pas immédiate

Anatomie d'une Saga

Flux Normal (Happy Path)
═══════════════════════════════════════════════════════════════►

  T1              T2              T3              T4
┌─────┐        ┌─────┐        ┌─────┐        ┌─────┐
│Créer│───────►│Réser│───────►│Débi-│───────►│Plani│
│Comm.│        │Stock│        │ ter │        │Livr.│
└─────┘        └─────┘        └─────┘        └─────┘


Flux avec Échec et Compensation
═══════════════════════════════════════════════════════════════►

  T1              T2              T3              T4
┌─────┐        ┌─────┐        ┌─────┐        ┌─────┐
│Créer│───────►│Réser│───────►│Débi-│───────►│Plani│ ✗ ÉCHEC
│Comm.│        │Stock│        │ ter │        │Livr.│
└─────┘        └─────┘        └─────┘        └─────┘
                                │
◄═══════════════════════════════╧═══════════════════════════════
                    COMPENSATIONS

  C3              C2              C1
┌─────┐        ┌─────┐        ┌─────┐
│Rem- │◄───────│Libé-│◄───────│Annu-│
│bours│        │Stock│        │Comm.│
└─────┘        └─────┘        └─────┘

Les Deux Types de Saga

Il existe deux façons d'implémenter une Saga : Orchestration et Chorégraphie.


Saga par Orchestration

Principe

Un orchestrateur central (le "chef d'orchestre") coordonne toutes les étapes de la Saga. Il sait quelles étapes exécuter, dans quel ordre, et quelles compensations déclencher en cas d'échec.

Schéma

                      ┌──────────────────┐
                      │   Orchestrateur  │
                      │   (OrderSaga)    │
                      └────────┬─────────┘
                               │
       ┌───────────┬───────────┼───────────┬───────────┐
       │           │           │           │           │
       ▼           ▼           ▼           ▼           ▼
  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
  │ Service │ │ Service │ │ Service │ │ Service │ │ Service │
  │Commandes│ │  Stock  │ │Paiement │ │Livraison│ │ Notif.  │
  └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘

L'orchestrateur envoie des commandes aux services et attend leurs réponses pour décider de la suite.

Exemple : Saga de Commande

Voici comment modéliser une Saga de commande avec PostgreSQL comme stockage de l'état.

Table de Suivi de la Saga

-- Table pour suivre l'état de chaque saga
CREATE TABLE order_sagas (
    saga_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id        UUID NOT NULL,
    current_step    VARCHAR(50) NOT NULL,
    status          VARCHAR(20) NOT NULL DEFAULT 'STARTED',
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW(),

    -- Données de contexte
    user_id         INTEGER NOT NULL,
    total_amount    NUMERIC(10,2) NOT NULL,
    items           JSONB NOT NULL,

    -- Résultats des étapes
    payment_id      UUID,
    shipment_id     UUID,

    -- Contrainte sur le statut
    CONSTRAINT valid_status CHECK (
        status IN ('STARTED', 'PENDING', 'COMPLETED', 'COMPENSATING', 'FAILED')
    )
);

-- Table pour l'historique des étapes
CREATE TABLE saga_steps (
    id              SERIAL PRIMARY KEY,
    saga_id         UUID REFERENCES order_sagas(saga_id),
    step_name       VARCHAR(50) NOT NULL,
    step_type       VARCHAR(20) NOT NULL, -- 'FORWARD' ou 'COMPENSATE'
    status          VARCHAR(20) NOT NULL,
    executed_at     TIMESTAMPTZ DEFAULT NOW(),
    error_message   TEXT,

    CONSTRAINT valid_step_status CHECK (
        status IN ('PENDING', 'SUCCESS', 'FAILED')
    )
);

Définition des Étapes et Compensations

-- Table de définition des étapes de la saga
CREATE TABLE saga_step_definitions (
    step_order          INTEGER PRIMARY KEY,
    step_name           VARCHAR(50) NOT NULL UNIQUE,
    service_name        VARCHAR(50) NOT NULL,
    command_type        VARCHAR(50) NOT NULL,
    compensation_type   VARCHAR(50),
    is_compensatable    BOOLEAN DEFAULT TRUE
);

-- Insertion des étapes pour la saga de commande
INSERT INTO saga_step_definitions VALUES
(1, 'CREATE_ORDER',     'order-service',    'CreateOrder',    'CancelOrder',      TRUE),
(2, 'RESERVE_STOCK',    'stock-service',    'ReserveStock',   'ReleaseStock',     TRUE),
(3, 'PROCESS_PAYMENT',  'payment-service',  'ProcessPayment', 'RefundPayment',    TRUE),
(4, 'SCHEDULE_SHIPMENT','shipping-service', 'ScheduleShip',   'CancelShipment',   TRUE),
(5, 'SEND_CONFIRMATION','notif-service',    'SendEmail',      NULL,               FALSE);

Logique de l'Orchestrateur (Pseudocode SQL/PL/pgSQL)

-- Fonction pour avancer la saga à l'étape suivante
CREATE OR REPLACE FUNCTION advance_saga(p_saga_id UUID, p_step_result VARCHAR, p_error TEXT DEFAULT NULL)  
RETURNS void AS $$  
DECLARE  
    v_saga RECORD;
    v_current_step RECORD;
    v_next_step RECORD;
BEGIN
    -- Récupérer l'état actuel de la saga
    SELECT * INTO v_saga FROM order_sagas WHERE saga_id = p_saga_id FOR UPDATE;

    -- Récupérer la définition de l'étape actuelle
    SELECT * INTO v_current_step
    FROM saga_step_definitions
    WHERE step_name = v_saga.current_step;

    IF p_step_result = 'SUCCESS' THEN
        -- Enregistrer le succès
        INSERT INTO saga_steps (saga_id, step_name, step_type, status)
        VALUES (p_saga_id, v_saga.current_step, 'FORWARD', 'SUCCESS');

        -- Trouver l'étape suivante
        SELECT * INTO v_next_step
        FROM saga_step_definitions
        WHERE step_order = v_current_step.step_order + 1;

        IF v_next_step IS NULL THEN
            -- Saga terminée avec succès
            UPDATE order_sagas
            SET status = 'COMPLETED', updated_at = NOW()
            WHERE saga_id = p_saga_id;
        ELSE
            -- Passer à l'étape suivante
            UPDATE order_sagas
            SET current_step = v_next_step.step_name,
                status = 'PENDING',
                updated_at = NOW()
            WHERE saga_id = p_saga_id;

            -- Ici, déclencher l'appel au service suivant
            -- (via message queue, API call, etc.)
        END IF;

    ELSIF p_step_result = 'FAILED' THEN
        -- Enregistrer l'échec
        INSERT INTO saga_steps (saga_id, step_name, step_type, status, error_message)
        VALUES (p_saga_id, v_saga.current_step, 'FORWARD', 'FAILED', p_error);

        -- Démarrer la compensation
        UPDATE order_sagas
        SET status = 'COMPENSATING', updated_at = NOW()
        WHERE saga_id = p_saga_id;

        -- Lancer les compensations (voir fonction suivante)
        PERFORM start_compensation(p_saga_id);
    END IF;
END;
$$ LANGUAGE plpgsql;

Avantages de l'Orchestration

Avantage Description
Logique centralisée Toute la logique est dans l'orchestrateur, facile à comprendre
Flux explicite L'ordre des étapes est clairement défini
Débogage simplifié Un seul endroit pour suivre l'état de la saga
Gestion des erreurs L'orchestrateur contrôle les compensations

Inconvénients de l'Orchestration

Inconvénient Description
Point central L'orchestrateur peut devenir un goulot d'étranglement
Couplage L'orchestrateur connaît tous les services
Complexité croissante Peut devenir difficile à maintenir avec beaucoup d'étapes

Saga par Chorégraphie

Principe

Dans la chorégraphie, il n'y a pas de coordinateur central. Chaque service écoute les événements des autres services et réagit en conséquence. Les services "dansent" ensemble sans chef d'orchestre.

Schéma

┌─────────────────────────────────────────────────────────────────┐
│                        Message Broker                           │
│                    (ex: RabbitMQ, Kafka)                        │
└───────┬─────────┬─────────┬─────────┬─────────┬─────────────────┘
        │         │         │         │         │
   Publie    Écoute    Publie    Écoute    Publie
        │         │         │         │         │
        ▼         ▼         ▼         ▼         ▼
   ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
   │ Service │ │ Service │ │ Service │ │ Service │
   │Commandes│ │  Stock  │ │Paiement │ │Livraison│
   └─────────┘ └─────────┘ └─────────┘ └─────────┘

Événements :
OrderCreated ──► StockReserved ──► PaymentProcessed ──► ShipmentScheduled

Flux d'Événements

1. Client passe commande
   │
   ▼
┌─────────────────────────────────────────────────────────────────┐
│ Service Commandes                                               │
│ - Crée la commande (status: PENDING)                            │
│ - Publie: OrderCreated { orderId, items, userId, amount }       │
└─────────────────────────────────────────────────────────────────┘
   │
   │ OrderCreated
   ▼
┌─────────────────────────────────────────────────────────────────┐
│ Service Stock                                                   │
│ - Écoute: OrderCreated                                          │
│ - Réserve le stock                                              │
│ - Publie: StockReserved { orderId, reservationId }              │
│   OU                                                            │
│ - Publie: StockInsufficient { orderId, reason }                 │
└─────────────────────────────────────────────────────────────────┘
   │
   │ StockReserved
   ▼
┌─────────────────────────────────────────────────────────────────┐
│ Service Paiement                                                │
│ - Écoute: StockReserved                                         │
│ - Traite le paiement                                            │
│ - Publie: PaymentSucceeded { orderId, paymentId }               │
│   OU                                                            │
│ - Publie: PaymentFailed { orderId, reason }                     │
└─────────────────────────────────────────────────────────────────┘
   │
   │ PaymentSucceeded
   ▼
┌─────────────────────────────────────────────────────────────────┐
│ Service Livraison                                               │
│ - Écoute: PaymentSucceeded                                      │
│ - Planifie la livraison                                         │
│ - Publie: ShipmentScheduled { orderId, shipmentId, eta }        │
└─────────────────────────────────────────────────────────────────┘

Gestion des Compensations en Chorégraphie

Quand une étape échoue, elle publie un événement d'échec. Les services précédents écoutent cet événement et exécutent leur compensation.

PaymentFailed publié
        │
        ├──────────────────────────────┐
        │                              │
        ▼                              ▼
┌───────────────────┐         ┌───────────────────┐
│ Service Stock     │         │ Service Commandes │
│ Écoute:           │         │ Écoute:           │
│   PaymentFailed   │         │   PaymentFailed   │
│ Action:           │         │ Action:           │
│   Libère le stock │         │   Annule commande │
│ Publie:           │         │ Publie:           │
│   StockReleased   │         │   OrderCancelled  │
└───────────────────┘         └───────────────────┘

Implémentation avec PostgreSQL et LISTEN/NOTIFY

PostgreSQL offre un mécanisme natif de publication/souscription avec LISTEN et NOTIFY. Bien qu'il ne remplace pas un vrai message broker pour la production, il est utile pour comprendre le concept.

-- Service Commandes : Publication d'événement
CREATE OR REPLACE FUNCTION notify_order_created()  
RETURNS TRIGGER AS $$  
BEGIN  
    -- Publier l'événement sur le canal 'order_events'
    PERFORM pg_notify(
        'order_events',
        json_build_object(
            'event_type', 'OrderCreated',
            'order_id', NEW.id,
            'user_id', NEW.user_id,
            'total', NEW.total,
            'items', NEW.items,
            'timestamp', NOW()
        )::text
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER order_created_trigger  
AFTER INSERT ON orders  
FOR EACH ROW  
EXECUTE FUNCTION notify_order_created();  
-- Service Stock : Écoute et réaction (conceptuel)
-- En pratique, cela serait dans le code applicatif

-- Côté applicatif (pseudocode Python avec psycopg)
--
-- conn.execute("LISTEN order_events")
-- while True:
--     conn.poll()
--     for notify in conn.notifies:
--         event = json.loads(notify.payload)
--         if event['event_type'] == 'OrderCreated':
--             reserve_stock(event['order_id'], event['items'])

Avantages de la Chorégraphie

Avantage Description
Découplage fort Les services ne se connaissent pas directement
Pas de point central Pas de goulot d'étranglement
Scalabilité Chaque service évolue indépendamment
Résilience La panne d'un service n'arrête pas les autres

Inconvénients de la Chorégraphie

Inconvénient Description
Flux difficile à suivre La logique est dispersée dans tous les services
Débogage complexe Nécessite une bonne observabilité (tracing distribué)
Dépendances cycliques Risque de créer des boucles d'événements
Cohérence difficile Plus complexe de garantir que toutes les compensations s'exécutent

Le Pattern Outbox : Fiabilité des Événements

Un problème critique des Sagas est la publication fiable des événements. Que se passe-t-il si le service met à jour sa base de données mais plante avant de publier l'événement ?

Le Problème du Double Write

1. BEGIN transaction
2. UPDATE orders SET status = 'confirmed'   ✓ Succès
3. COMMIT                                    ✓ Succès
4. Publier événement OrderConfirmed          ✗ CRASH !

Résultat : Base mise à jour, mais événement jamais publié.
           Les autres services ne sont pas informés.

Solution : Le Pattern Outbox

Au lieu de publier directement, on écrit l'événement dans une table Outbox dans la même transaction que la modification métier.

-- Table Outbox pour les événements à publier
CREATE TABLE outbox (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type  VARCHAR(100) NOT NULL,  -- ex: 'Order'
    aggregate_id    UUID NOT NULL,           -- ex: order_id
    event_type      VARCHAR(100) NOT NULL,   -- ex: 'OrderCreated'
    payload         JSONB NOT NULL,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    published_at    TIMESTAMPTZ,             -- NULL = pas encore publié

    -- Index pour le polling efficace
    CONSTRAINT idx_outbox_unpublished
        CHECK (published_at IS NOT NULL OR TRUE)
);

CREATE INDEX idx_outbox_pending ON outbox(created_at)  
WHERE published_at IS NULL;  
-- Transaction atomique : modification + événement
BEGIN;

-- Modification métier
INSERT INTO orders (id, user_id, total, status, items)  
VALUES ('ord-123', 42, 150.00, 'pending', '["item1", "item2"]');  

-- Événement dans la même transaction
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)  
VALUES (  
    'Order',
    'ord-123',
    'OrderCreated',
    jsonb_build_object(
        'orderId', 'ord-123',
        'userId', 42,
        'total', 150.00,
        'items', '["item1", "item2"]'::jsonb
    )
);

COMMIT;
-- Les deux écritures réussissent ou échouent ensemble !

Publication des Événements Outbox

Un processus séparé (polling ou CDC) lit la table Outbox et publie les événements.

-- Récupérer les événements non publiés
SELECT * FROM outbox  
WHERE published_at IS NULL  
ORDER BY created_at  
LIMIT 100  
FOR UPDATE SKIP LOCKED;  -- Évite les conflits en parallèle  

-- Après publication réussie au message broker
UPDATE outbox  
SET published_at = NOW()  
WHERE id = 'evt-xxx';  

CDC avec Debezium (Alternative au Polling)

Pour les systèmes à fort volume, Debezium peut capturer les changements de la table Outbox directement depuis le WAL de PostgreSQL, sans polling.

PostgreSQL WAL ──► Debezium ──► Kafka ──► Services consommateurs

Gestion des Erreurs et Idempotence

L'Idempotence : Une Nécessité Absolue

Dans un système distribué, les messages peuvent être délivrés plusieurs fois (at-least-once delivery). Chaque étape de la Saga doit être idempotente : exécutée plusieurs fois, elle produit le même résultat.

-- ❌ NON IDEMPOTENT : Déduire le stock à chaque appel
UPDATE products SET stock = stock - 1 WHERE id = 501;
-- Problème : Si appelé 3 fois, stock déduit de 3 !

-- ✅ IDEMPOTENT : Utiliser une réservation unique
INSERT INTO stock_reservations (reservation_id, product_id, quantity)  
VALUES ('res-abc', 501, 1)  
ON CONFLICT (reservation_id) DO NOTHING;  
-- Appelé 3 fois = 1 seule réservation créée

Stratégies d'Idempotence

Stratégie Description
Clé d'idempotence Stocker l'ID de chaque requête traitée
Upsert conditionnel ON CONFLICT DO NOTHING ou DO UPDATE
État final Vérifier l'état avant modification
Version optimiste Utiliser un numéro de version
-- Table pour tracker les requêtes traitées
CREATE TABLE processed_requests (
    request_id      UUID PRIMARY KEY,
    processed_at    TIMESTAMPTZ DEFAULT NOW(),
    result          JSONB
);

-- Vérifier avant traitement
CREATE OR REPLACE FUNCTION process_payment_idempotent(
    p_request_id UUID,
    p_order_id UUID,
    p_amount NUMERIC
) RETURNS JSONB AS $$
DECLARE
    v_existing RECORD;
    v_result JSONB;
BEGIN
    -- Vérifier si déjà traité
    SELECT * INTO v_existing
    FROM processed_requests
    WHERE request_id = p_request_id;

    IF FOUND THEN
        -- Retourner le résultat précédent
        RETURN v_existing.result;
    END IF;

    -- Traiter le paiement...
    -- ... logique métier ...

    v_result := jsonb_build_object('status', 'success', 'payment_id', 'pay-xyz');

    -- Enregistrer le traitement
    INSERT INTO processed_requests (request_id, result)
    VALUES (p_request_id, v_result);

    RETURN v_result;
END;
$$ LANGUAGE plpgsql;

Compensation vs Rollback : Différences Clés

Il est crucial de comprendre que les compensations ne sont pas des rollbacks classiques.

Rollback (Transaction Unique)

  • Annule les modifications non commitées
  • Automatique et complet
  • Aucune trace laissée
  • Géré par le SGBD

Compensation (Saga)

  • Crée une nouvelle opération qui "annule" l'effet de la précédente
  • Doit être explicitement programmée
  • Laisse une trace dans l'historique
  • Peut être plus complexe que l'opération originale

Exemple Concret

-- OPÉRATION ORIGINALE : Réserver du stock
INSERT INTO stock_reservations (id, product_id, quantity, status)  
VALUES ('res-123', 501, 5, 'active');  

UPDATE products SET available_stock = available_stock - 5  
WHERE id = 501;  

-- COMPENSATION (ce n'est PAS un rollback !)
-- On ne supprime pas la réservation, on la marque comme annulée
UPDATE stock_reservations  
SET status = 'cancelled', cancelled_at = NOW()  
WHERE id = 'res-123';  

UPDATE products SET available_stock = available_stock + 5  
WHERE id = 501;  

-- L'historique montre : réservation créée PUIS annulée

Compensations Complexes

Certaines opérations ne peuvent pas être parfaitement compensées :

Opération Compensation possible
Envoyer un email ❌ Impossible d'annuler (on peut envoyer un autre email d'excuse)
Facturer un client ⚠️ Remboursement (frais possibles)
Publier sur réseau social ❌ Ou ⚠️ Supprimer (mais vu par certains)
Réserver un billet d'avion ⚠️ Annulation (avec pénalités potentielles)

Pour ces cas, on parle de transactions de mitigation plutôt que de vraies compensations.


Choisir entre Orchestration et Chorégraphie

Tableau de Décision

Critère Orchestration Chorégraphie
Nombre d'étapes Beaucoup (5+) Peu (2-4)
Complexité logique Élevée Simple
Besoin de visibilité Fort Faible
Équipes Une équipe centrale Équipes autonomes
Évolutivité logique Modifications centralisées Modifications distribuées
Latence acceptable Moins critique Plus critique

Recommandation Pratique

  • Débutez avec l'orchestration si vous n'êtes pas familier avec les architectures événementielles
  • Passez à la chorégraphie lorsque le découplage devient plus important que la visibilité
  • Hybride : Orchestration pour les flux critiques, chorégraphie pour les flux secondaires

Bonnes Pratiques avec PostgreSQL

1. Utilisez le Pattern Outbox

Toujours écrire les événements dans une table Outbox dans la même transaction que la modification métier.

2. Rendez Chaque Étape Idempotente

Utilisez des clés d'idempotence et des upserts conditionnels.

3. Stockez l'État de la Saga

Maintenez une table de suivi pour chaque saga, avec l'historique des étapes.

4. Implémentez des Timeouts

Les sagas peuvent rester bloquées. Prévoyez des timeouts et des mécanismes de reprise.

-- Trouver les sagas bloquées depuis plus d'une heure
SELECT * FROM order_sagas  
WHERE status = 'PENDING'  
AND updated_at < NOW() - INTERVAL '1 hour';  

5. Utilisez le Dead Letter Queue

Les événements qui échouent de manière répétée doivent être mis de côté pour analyse manuelle.

6. Investissez dans l'Observabilité

  • Tracing distribué (OpenTelemetry)
  • Logs corrélés par saga_id
  • Métriques sur les durées et taux d'échec

Outils et Frameworks

Voici quelques outils qui facilitent l'implémentation des Sagas :

Outil Description
Temporal.io Orchestration de workflows durable et résiliente
Eventuate Tram Framework Saga pour Java avec support Outbox
MassTransit Framework .NET avec saga state machine
NServiceBus Saga pattern pour .NET avec persistance PostgreSQL
Debezium CDC pour publier les événements Outbox
Apache Kafka Message broker pour la chorégraphie

Conclusion

Les transactions distribuées sont un défi inévitable dans les architectures microservices. Plutôt que de forcer des transactions ACID globales (2PC), le pattern Saga offre une approche pragmatique :

  • Découper en transactions locales indépendantes
  • Prévoir des compensations pour chaque étape
  • Accepter la cohérence éventuelle
  • Garantir l'idempotence de chaque opération

PostgreSQL, grâce à ses transactions locales robustes, son support JSONB pour les événements, et ses mécanismes comme LISTEN/NOTIFY, est un excellent choix pour implémenter les Sagas.

Le choix entre orchestration et chorégraphie dépend de votre contexte : complexité du flux, besoin de visibilité, et organisation des équipes.


Points Clés à Retenir

  • 2PC : Protocole classique mais peu adapté aux microservices (verrouillage, latence, couplage)
  • Saga : Séquence de transactions locales avec compensations en cas d'échec
  • Orchestration : Un coordinateur central dirige le flux (visibilité, mais point central)
  • Chorégraphie : Les services réagissent aux événements (découplage, mais complexité)
  • Pattern Outbox : Garantit la publication fiable des événements
  • Idempotence : Chaque étape doit pouvoir être rejouée sans effet de bord
  • Compensation ≠ Rollback : C'est une nouvelle opération qui annule l'effet, pas un retour en arrière

⏭️ Foreign Data Wrappers pour la fédération