Saga pattern pour transactions distribuées microservices

1. Aperçu

Dans une application monolithe, vous placez @Transactional sur une méthode et la base de données gère le reste : soit tout est committé, soit tout est annulé. Lors du passage aux microservices, chaque service possède sa propre base de données, donc une seule transaction ACID ne peut plus couvrir l’ensemble du processus métier.

Cet article présente le Saga pattern, le moyen le plus courant de résoudre ce problème. Après l’avoir lu, vous comprendrez les deux types de saga (choreography et orchestration), vous pourrez écrire votre propre petit saga orchestrator en Java 21, tester les flux de compensation et connaître les erreurs courantes lors du déploiement de saga en production.

2. Le problème : un processus métier, plusieurs bases de données

Considérez un flux de commande comprenant trois services :

  1. InventoryService gère les stocks.
  2. PaymentService débite le client.
  3. ShippingService crée une facture d’expédition.

Supposons que l’étape 1 réussisse mais que l’étape 2 échoue parce que le client n’a pas assez d’argent. À ce moment-là, les marchandises sont réservées dans le stock mais aucune commande n’a de paiement. Vous ne pouvez pas « annuler » la base de données d’InventoryService à partir de PaymentService.

La solution classique est le two-phase commit (2PC). Cependant, 2PC est peu utilisé dans les microservices car :

  • Il verrouille les ressources tout en attendant la réponse de toutes les parties, ce qui réduit considérablement le débit.
  • Le coordinateur est un point unique de défaillance.
  • De nombreux systèmes comme Kafka, MongoDB ou les API REST tierces ne supportent pas les transactions XA.

3. Qu’est-ce que Saga ?

Saga divise une grande transaction en série de transactions locales. Chaque transaction locale s’exécute dans un service et est immédiatement validée dans la base de données de ce service. Chaque étape est accompagnée d’une transaction de compensation, c’est-à-dire une opération métier qui « annule » cette étape.

Étape Action Compensation
1 Réserver les stocks (reserve) Libérer les stocks (release)
2 Débiter (charge) Rembourser (refund)
3 Créer une facture d’expédition (ship) Annuler la facture d’expédition (cancel)

Si l’étape n échoue, saga exécute la compensation des étapes n-1, n-2, …, 1 dans l’ordre inverse.

Deux différences importantes par rapport à un vrai rollback :

  • La compensation ne supprime pas l’historique. Elle crée un nouveau changement, par exemple une transaction de remboursement. Pendant une courte période, les autres services pourraient avoir vu l’état intermédiaire.
  • Saga garantit la cohérence à terme, pas l’isolation comme ACID.

Il y a deux façons d’orchestrer une saga :

  • Choreography: chaque service écoute les événements et décide seul de l’étape suivante. Il n’y a pas de centre de contrôle.
  • Orchestration: un orchestrator appelle chaque service dans l’ordre et est responsable de l’exécution de la compensation en cas d’erreur.

4. Orchestration saga en Java 21

Cette section construit un orchestrator Java pur, indépendant du framework, pour que vous voyiez clairement le mécanisme interne.

4.1. Définir une étape saga

import java.util.function.Consumer;

public interface SagaStep<C> {
    String name();
    void execute(C context);
    void compensate(C context);

    static <C> SagaStep<C> of(String name, Consumer<C> action, Consumer<C> compensation) {
        return new SagaStep<>() {
            public String name() { return name; }
            public void execute(C ctx) { action.accept(ctx); }
            public void compensate(C ctx) { compensation.accept(ctx); }
        };
    }
}

Chaque étape a deux comportements : execute pour le flux aller et compensate pour le flux de compensation. Le générique C est un contexte partagé entre les étapes, par exemple les informations de commande. La méthode factory of aide à déclarer une étape avec une lambda de manière concise.

4.2. Orchestrator

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;

public record SagaResult(boolean success, String failedStep, String reason) {
    static SagaResult ok() { return new SagaResult(true, null, null); }
    static SagaResult failed(String step, String reason) { return new SagaResult(false, step, reason); }
}

public class SagaOrchestrator<C> {
    private final List<SagaStep<C>> steps;

    public SagaOrchestrator(List<SagaStep<C>> steps) {
        this.steps = List.copyOf(steps);
    }

    public SagaResult run(C context) {
        Deque<SagaStep<C>> completed = new ArrayDeque<>();
        for (SagaStep<C> step : steps) {
            try {
                step.execute(context);
                completed.push(step);
            } catch (RuntimeException ex) {
                compensate(completed, context);
                return SagaResult.failed(step.name(), ex.getMessage());
            }
        }
        return SagaResult.ok();
    }

    private void compensate(Deque<SagaStep<C>> completed, C context) {
        while (!completed.isEmpty()) {
            SagaStep<C> step = completed.pop();
            try {
                step.compensate(context);
            } catch (RuntimeException ex) {
                // Production: enregistrer pour retry, ne pas ignorer
                System.err.println("Compensation failed at " + step.name() + ": " + ex.getMessage());
            }
        }
    }
}

Points clés :

  • completed est une pile (ArrayDeque avec push/pop). Chaque étape réussie est poussée dans la pile, donc quand on la dépile, on a le bon ordre inverse.
  • L’étape qui échoue ne figure pas dans la pile. Cette étape n’a rien validé donc n’a pas besoin de compensation.
  • Une compensation échouée ne doit pas arrêter les autres compensations. C’est pourquoi chaque appel à compensate est enveloppé dans sa propre try/catch.

4.3. Les services simulés

import java.math.BigDecimal;
import java.util.*;

public record Order(String id, String customerId, String sku, int quantity, BigDecimal amount) {}

public class InventoryService {
    private final Map<String, Integer> stock = new HashMap<>(Map.of("BOOK-01", 10));
    public void reserve(Order o) {
        int left = stock.get(o.sku()) - o.quantity();
        if (left < 0) throw new IllegalStateException("Out of stock");
        stock.put(o.sku(), left);
    }
    public void release(Order o) { stock.merge(o.sku(), o.quantity(), Integer::sum); }
    public int available(String sku) { return stock.get(sku); }
}

public class PaymentService {
    private final Map<String, BigDecimal> balances = new HashMap<>(Map.of("alice", new BigDecimal("100")));
    public void charge(Order o) {
        BigDecimal balance = balances.get(o.customerId());
        if (balance.compareTo(o.amount()) < 0) throw new IllegalStateException("Insufficient funds");
        balances.put(o.customerId(), balance.subtract(o.amount()));
    }
    public void refund(Order o) { balances.merge(o.customerId(), o.amount(), BigDecimal::add); }
    public BigDecimal balance(String customerId) { return balances.get(customerId); }
}

public class ShippingService {
    private final Set<String> shipments = new HashSet<>();
    public void ship(Order o) { shipments.add(o.id()); }
    public void cancel(Order o) { shipments.remove(o.id()); }
    public boolean hasShipment(String orderId) { return shipments.contains(orderId); }
}

En pratique, chaque méthode ci-dessus est un appel REST/gRPC ou un message envoyé à un autre service. Cette simulation est uniquement pour illustrer et tester.

4.4. Assembler en saga de commande

import java.math.BigDecimal;
import java.util.List;

public class OrderSagaDemo {
    public static void main(String[] args) {
        var inventory = new InventoryService();
        var payment = new PaymentService();
        var shipping = new ShippingService();

        var saga = new SagaOrchestrator<Order>(List.of(
            SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
            SagaStep.of("charge-payment", payment::charge, payment::refund),
            SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
        ));

        var cheap = new Order("ORD-1", "alice", "BOOK-01", 2, new BigDecimal("40"));
        var pricey = new Order("ORD-2", "alice", "BOOK-01", 3, new BigDecimal("500"));

        System.out.println(saga.run(cheap));
        System.out.println(saga.run(pricey));
        System.out.println("Stock: " + inventory.available("BOOK-01"));
        System.out.println("Balance: " + payment.balance("alice"));
    }
}

Résultat :

SagaResult[success=true, failedStep=null, reason=null]
SagaResult[success=false, failedStep=charge-payment, reason=Insufficient funds]
Stock: 8
Balance: 60

La commande ORD-1 passe les trois étapes. La commande ORD-2 a réservé 3 livres mais échoue au paiement. L’orchestrator appelle alors inventory.release, donc le stock revient à 8 (10 − 2 de la première commande) au lieu de 5.

5. Saga Choreography avec événements

Avec la choreography, aucun service ne « connaît » l’ensemble du flux. Chaque service émet un événement une fois son travail terminé, et un autre service réagit à cet événement. L’exemple ci-dessous utilise ApplicationEventPublisher de Spring dans un processus pour l’illustration. Dans un vrai système, vous le remplaceriez par Kafka ou RabbitMQ.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
public record StockReserved(Order order) {}
public record PaymentFailed(Order order, String reason) {}

@Component
public class PaymentHandler {
    private final PaymentService payment;
    private final ApplicationEventPublisher events;

    public PaymentHandler(PaymentService payment, ApplicationEventPublisher events) {
        this.payment = payment;
        this.events = events;
    }

    @EventListener
    public void on(StockReserved event) {
        try {
            payment.charge(event.order());
        } catch (IllegalStateException ex) {
            events.publishEvent(new PaymentFailed(event.order(), ex.getMessage()));
        }
    }
}

@Component
public class InventoryCompensationHandler {
    private final InventoryService inventory;

    public InventoryCompensationHandler(InventoryService inventory) {
        this.inventory = inventory;
    }

    @EventListener
    public void on(PaymentFailed event) {
        inventory.release(event.order());
    }
}

PaymentHandler écoute StockReserved. Si le débit échoue, il publie PaymentFailed. À son tour, InventoryCompensationHandler reçoit cet événement et restitue la marchandise au stock. Pour que ce code fonctionne, InventoryService et PaymentService doivent aussi être déclarés comme beans.

Comparaison rapide des deux types :

  • Choreography est simple et faiblement couplée quand le flux n’a que 2–3 étapes. Quand le flux est plus long, la logique est dispersée dans plusieurs services, ce qui la rend difficile à suivre et propice aux boucles d’événements.
  • Orchestration centralise le flux en un seul endroit, facile à lire, tester et maintenir l’état. En contrepartie, l’orchestrator devient un composant que vous devez gérer soigneusement.

6. Tester le comportement de compensation

Ajoutez la dépendance de test :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

class OrderSagaTest {

    private final InventoryService inventory = new InventoryService();
    private final PaymentService payment = new PaymentService();
    private final ShippingService shipping = new ShippingService();

    private final SagaOrchestrator<Order> saga = new SagaOrchestrator<>(List.of(
        SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
        SagaStep.of("charge-payment", payment::charge, payment::refund),
        SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
    ));

    @Test
    void givenInsufficientFunds_whenRunSaga_thenStockIsReleased() {
        var order = new Order("ORD-9", "alice", "BOOK-01", 3, new BigDecimal("500"));

        SagaResult result = saga.run(order);

        assertThat(result.success()).isFalse();
        assertThat(result.failedStep()).isEqualTo("charge-payment");
        assertThat(inventory.available("BOOK-01")).isEqualTo(10);
        assertThat(payment.balance("alice")).isEqualByComparingTo("100");
        assertThat(shipping.hasShipment("ORD-9")).isFalse();
    }

    @Test
    void givenValidOrder_whenRunSaga_thenAllStepsCommitted() {
        var order = new Order("ORD-10", "alice", "BOOK-01", 1, new BigDecimal("30"));

        assertThat(saga.run(order).success()).isTrue();
        assertThat(inventory.available("BOOK-01")).isEqualTo(9);
        assertThat(payment.balance("alice")).isEqualByComparingTo("70");
        assertThat(shipping.hasShipment("ORD-10")).isTrue();
    }
}

Le premier test vérifie le point le plus important de saga : après un échec, tous les effets secondaires ont été annulés. Le stock revient à 10, le solde reste inchangé et aucune facture d’expédition n’a été créée. Le second test confirme que le flux réussi valide les trois étapes correctement.

7. Erreurs courantes et comment les éviter

Note : L’orchestrator de cet article garde l’état en mémoire. Si le processus s’arrête à mi-chemin, vous ne savez pas où vous en êtes et vous ne pouvez pas compenser. En production, enregistrez l’état de la saga (étape actuelle, étapes terminées) dans une base de données après chaque étape, puis permettez la reprise au redémarrage.

La compensation doit être idempotente. Les messages peuvent être envoyés plusieurs fois, donc exécuter refund deux fois ne doit pas rembourser deux fois. Enregistrez l’orderId des transactions déjà traitées et ignorez les demandes en double.

La compensation ne doit pas « abandonner ». Le bloc catch qui enregistre le journal à la section 4.2 est uniquement pour l’illustration. En pratique, une compensation échouée doit être relancée avec un backoff. Après plusieurs tentatives échouées, mettez-la en file d’attente de lettres mortes pour que quelqu’un la traite.

Double écriture. Si un service valide la base de données et envoie en même temps un message à Kafka, l’une des deux opérations peut échouer. Utilisez la boîte de sortie transactionnelle : écrivez l’événement dans une table outbox dans la même transaction locale, puis un autre processus lit cette table et le publie.

Manque d’isolation. Entre le moment du débit et le remboursement, l’utilisateur peut voir temporairement son solde réduit. Vous pouvez réduire cet impact avec un verrou sémantique, par exemple en fixant le statut de la commande à PENDING jusqu’à la fin de la saga. Une autre approche consiste à réorganiser les étapes, en mettant en avant celle qui est le plus susceptible d’échouer.

Certaines étapes ne peuvent pas être compensées. Un email envoyé ne peut pas être rappelé. Placez ces étapes (appelées pivot ou retriable step) à la fin de la saga, après que toutes les étapes qui peuvent échouer aient réussi.

Quand NE PAS utiliser saga :

  • Toutes les données se trouvent dans une seule base de données. Dans ce cas, utilisez simplement une transaction locale pour la simplicité.
  • Le métier nécessite une cohérence forte absolue et n’accepte pas d’état intermédiaire, par exemple certains processus comptables de base. Dans ce cas, vous devez reconsidérer la façon dont vous délimitez vos services.
  • Le flux n’a qu’un seul service qui écrit les données, tandis que les autres services ne font que lire.

8. Conclusion

Saga remplace une transaction distribuée par une série de transactions locales accompagnées de transactions de compensation, ce qui permet au système d’atteindre la cohérence à terme sans avoir besoin de 2PC. Vous avez construit un petit orchestrator utilisant une pile pour compenser dans l’ordre inverse, vu comment la choreography s’orchestre via les événements et écrit des tests confirmant que tous les effets secondaires sont annulés en cas d’erreur.

Lors du déploiement de saga en production, souvenez-vous de quatre choses : enregistrer l’état de saga de manière durable, écrire une compensation idempotente et avec retry, utiliser la boîte de sortie transactionnelle pour éviter les doubles écritures, et concevoir l’ordre des étapes de sorte que les opérations non annulables se trouvent à la fin du flux.

microservicessagadistributed transactionspring bootjava