Spójność danych w mikroserwisach: wzorzec SAGA

You are currently viewing Spójność danych w mikroserwisach: wzorzec SAGA

Wstęp

Wyobraź sobie sklep internetowy rozbity na mikroserwisy. Klient klika „Kup teraz”, a w tle uruchamia się kilka serwisów po kolei. Serwis zamówień zakłada zamówienie, serwis płatności ściąga pieniądze z karty, serwis magazynu rezerwuje towar, a serwis wysyłki zleca kuriera. Każdy z nich ma własną bazę danych i odpowiada tylko za swoje dane.

I teraz scenariusz, który zna każdy, kto siedział przy takim systemie. Płatność przeszła, pieniądze pobrane, ale w tej samej sekundzie serwis magazynu odpowiada „brak towaru”. Albo w ogóle nie odpowiada, bo akurat się restartuje. Co dzieje się z zamówieniem? Klient ma obciążoną kartę. Ty masz pieniądze za produkt, którego nie wyślesz. A w bazie wisi zamówienie w stanie „sam nie wiem”.

W monolicie ten problem prawie nie istnieje. Wrzucasz całą operację w jedną transakcję. Jak cokolwiek pójdzie nie tak, robisz ROLLBACK i wszystko wraca do punktu wyjścia. Baza pilnuje za Ciebie, żeby zapisało się albo wszystko, albo nic. Problem w tym, że w mikroserwisach nie ma jednej bazy ani jednej transakcji spinającej cztery serwisy. Każdy commituje swoje zmiany u siebie i nie wie, czy inny serwis właśnie przestał odpowiadać.

To jest ten moment, w którym spójność danych przestaje być czymś, o co dba silnik bazy. Od teraz musisz zaprojektować ją sam. Do tego właśnie służy wzorzec SAGA.

W tym wpisie pokażę Ci, dlaczego klasyczna transakcja (i rozproszony commit 2PC) tu nie zadziała. Wyjaśnię, czym jest saga i na czym polega kompensacja, czyli „cofanie” bez rollbacku. Porównamy choreografię z orkiestracją. Potem przejdziemy przez konkretny przykład w Spring Boot i Javie: zamówienie, płatność i rezerwacja magazynu. Na koniec pułapki, na które sam trafiłem, i sytuacje, w których saga to zły wybór.

Dlaczego zwykła transakcja nie działa w mikroserwisach

Żeby zrozumieć, po co komu saga, trzeba najpierw zobaczyć, co dokładnie tracimy przy przejściu z monolitu na mikroserwisy. A tracimy transakcję w tej wygodnej, znanej z bazy danych postaci.

W monolicie cała operacja zakupu siedzi w jednej bazie. Zapis zamówienia, pobranie płatności, zdjęcie towaru ze stanu – wszystko to opakowujesz w jedną transakcję i masz gwarancje ACID. Najważniejsza z nich w tym kontekście to atomowość: albo wykonają się wszystkie zmiany, albo żadna. Jak na którymkolwiek kroku poleci wyjątek, baza robi ROLLBACK i nie zostaje po nim ślad. W Springu wygląda to tak znajomo, że często nawet o tym nie myślisz:

@Transactional
public void placeOrder(OrderRequest request) {
    orderRepository.save(newOrder);       // zapis zamówienia
    paymentService.charge(request);       // pobranie płatności
    warehouseService.reserve(request);    // rezerwacja towaru
    // wyjątek w którejkolwiek linii = ROLLBACK całości
}

Tu wszystko działa, bo orderRepositorypaymentService i warehouseService siedzą w jednym procesie i piszą do jednej bazy. Jedno połączenie, jedna transakcja, jeden commit na końcu. Jak dokładnie działa samo @Transactional, rozpisywałem w osobnym wpisie: Co to jest i jak działa @Transactional w Springu?.

Każdy serwis ma własną bazę

W architekturze mikroserwisowej obowiązuje zasada „database per service”. Serwis płatności ma swoją bazę, serwis magazynu swoją, serwis zamówień swoją. To nie jest przypadek ani czyjeś widzimisię, tylko sedno całego podejścia. Dzięki temu serwisy są od siebie niezależne. Każdy może mieć inną technologię bazy, własny schemat i wdrażać się osobno.

Cena za tę niezależność jest taka, że transakcja z poprzedniego przykładu przestaje istnieć. Nie da się objąć jednym @Transactional zapisu w trzech różnych bazach. Dobijasz się do nich przez sieć, w trzech osobnych procesach. Każdy serwis commituje u siebie. W tym momencie nie ma żadnej wiedzy o tym, co stało się w pozostałych. Płatność może się zatwierdzić, a rezerwacja magazynu sekundę później rzucić wyjątkiem. Nie ma nad tym żadnej automatycznej kontroli.

A co z 2PC, czyli rozproszoną transakcją?

W tym miejscu zwykle pada pytanie: przecież istnieje coś takiego jak rozproszona transakcja, dwufazowy commit (2PC). I owszem, istnieje. Z grubsza działa tak: centralny koordynator pyta wszystkie serwisy „czy możecie zatwierdzić zmiany?”. Czeka, aż każdy odpowie „tak”, i dopiero wtedy daje sygnał do commitu.

Brzmi rozsądnie, ale w mikroserwisach po 2PC sięga się rzadko. Powody są konkretne:

  • Blokady. Między fazą „przygotuj się” a właściwym commitem serwisy trzymają zasoby zablokowane i czekają na koordynatora. Jeśli ten zwolni albo padnie w złym momencie, blokady wiszą, a przepustowość systemu drastycznie spada.
  • Wymóg dostępności. 2PC wymaga, żeby w danej chwili wszyscy uczestnicy byli online i odpowiadali. Wystarczy, że jeden serwis chwilowo przestanie odpowiadać, i cała operacja staje.
  • Koordynator jako pojedynczy punkt awarii. Jak padnie sam koordynator, zostawia transakcje w stanie zawieszenia, który trzeba potem ręcznie sprzątać.

Wszystkie trzy problemy uderzają w to, po co robimy mikroserwisy: w niezależność serwisów i wysoką dostępność. Dlatego zamiast trzymać się silnej, natychmiastowej spójności, w praktyce wybieramy spójność ostateczną (eventual consistency). Zgadzamy się na to, że przez krótką chwilę dane bywają niespójne – zamówienie już jest, a rezerwacji jeszcze nie. Warunek jest jeden: po przejściu wszystkich kroków system sam dojdzie do poprawnego stanu. SAGA jest właśnie sposobem na to, żeby tak się stało.

Czym jest SAGA

Skoro nie możemy objąć całej operacji jedną transakcją, robimy coś innego: rozbijamy ją na ciąg mniejszych, lokalnych transakcji. Każdy serwis wykonuje swój fragment u siebie, w swojej bazie, i robi to w pełni transakcyjnie. Saga to właśnie taki ciąg lokalnych transakcji, które razem realizują jedną operację biznesową rozłożoną na wiele serwisów.

Wróćmy do zamówienia. Cały proces można rozpisać na sekwencję kroków:

  1. Serwis zamówień zapisuje zamówienie ze statusem „w trakcie”.
  2. Serwis płatności pobiera pieniądze.
  3. Serwis magazynu rezerwuje towar.
  4. Serwis wysyłki zleca kuriera.
  5. Serwis zamówień zmienia status na „potwierdzone”.

Każdy z tych kroków to osobna, lokalna transakcja, która commituje się od razu po wykonaniu. Po kroku drugim pieniądze są naprawdę pobrane i zapisane w bazie płatności. Nie ma żadnego nadrzędnego commitu na końcu, który by to wszystko spinał. I tu pojawia się pytanie, które pewnie sam sobie zadajesz. Skoro każdy krok zatwierdza się od razu, to co się dzieje, gdy krok numer trzy zawiedzie? Pieniądze już pobrane, a towaru nie ma.

Zamiast rollbacku – kompensacja

W transakcji bazodanowej cofnięcie zmian załatwia ROLLBACK. W sadze nie ma czego cofać na poziomie bazy, bo każdy krok już dawno się zatwierdził. Dlatego saga wprowadza pojęcie kroku kompensującego: dla każdej akcji definiujesz drugą akcję, która odwraca jej skutek.

Jak krok się wywróci, saga nie udaje, że nic się nie stało. Zamiast tego cofa się po swoich śladach. Wykonuje kompensacje dla wszystkich kroków, które zdążyły się powieść, w odwrotnej kolejności. Magazyn nie ma towaru? To uruchom zwrot płatności, potem oznacz zamówienie jako anulowane. Efekt końcowy jest taki, że system wraca do sensownego, spójnego stanu. Osiągasz to świadomie zaprojektowanymi akcjami odwracającymi, bo żadnego automatycznego rollbacku tu nie uświadczysz.

Kompensacji poświęcę całą następną sekcję, bo to miejsce, gdzie kryje się najwięcej pułapek. Na razie wystarczy Ci jedno: we wzorcu SAGA sam projektujesz, co znaczy „cofnąć” każdy krok.

SAGA to proces biznesowy czy tylko sztuczka techniczna?

Łatwo wpaść w myślenie o sadze jak o czysto technicznym mechanizmie do ratowania spójności. To prawda, ale tylko połowiczna. Tak naprawdę saga modeluje proces biznesowy rozciągnięty w czasie, ze wszystkimi jego stanami pośrednimi i regułami. Co się dzieje, gdy płatność przejdzie, ale towaru zabraknie? Czy zwracamy pieniądze od razu, czy proponujemy klientowi inny termin? To są decyzje biznesowe, a nie tylko techniczne. Saga zmusza Cię, żebyś je nazwał wprost.

Z mojego doświadczenia to jest często najtrudniejsza część. Sam kod orkiestracji bywa prostszy niż rozmowy o tym, jak biznes chce obsłużyć każdy scenariusz porażki.

Pomocna jest tu prosta analogia. Wyobraź sobie rezerwację wyjazdu: bukujesz lot, potem hotel, potem wypożyczenie auta. Jeśli auto okaże się niedostępne, nie udajesz, że lot i hotel nie istnieją. Anulujesz hotel, anulujesz lot (albo odzyskujesz pieniądze zgodnie z warunkami) i wracasz do punktu wyjścia. Każda rezerwacja to osobny krok, a każde anulowanie to jego kompensacja. Saga działa dokładnie tak samo, tylko zamiast biura podróży masz swoje serwisy.

Kompensacja – „cofanie” bez rollbacku

Słowo „cofanie” potrafi tu wprowadzić w błąd, bo podświadomie myślisz o nim jak o rollbacku z bazy. A to dwie różne rzeczy. Rollback to operacja techniczna: silnik bazy wycofuje niezatwierdzone zmiany. Po transakcji nie zostaje żaden ślad, jakby nigdy jej nie było. Kompensacja działa inaczej, bo cofa coś, co już się wydarzyło i zostało zatwierdzone.

To rozróżnienie nazywamy kompensacją semantyczną. Nie wymazujesz historii, tylko wykonujesz nową akcję biznesową, która znosi skutek poprzedniej. Jak pobrałeś z karty 200 zł, kompensacją nie jest „udawanie, że nie pobrałeś”. Kompensacją jest zwrot tych 200 zł. W bazie płatności zostaną wtedy dwa wpisy: obciążenie i zwrot. Z punktu widzenia księgowości to nawet lepiej niż zniknięcie operacji, bo masz pełną historię tego, co się działo.

Dla naszego zamówienia pary akcja – kompensacja wyglądają tak:

  • Pobierz płatność → zwróć płatność
  • Zarezerwuj towar → zwolnij rezerwację
  • Zleć kuriera → odwołaj zlecenie kuriera
  • Zapisz zamówienie jako „w trakcie” → oznacz zamówienie jako „anulowane”

Każdy krok sagi ma swojego partnera, który potrafi odwrócić jego skutek. I to Ty musisz tego partnera napisać. Baza Ci go nie da.

Nie wszystko da się cofnąć

Tu dochodzimy do najmniej wygodnej części. Niektórych akcji nie da się czysto skompensować. Klasyczny przykład to wysłany e-mail z potwierdzeniem zamówienia. Nie cofniesz wiadomości, która już wyszła z serwera i wisi w skrzynce klienta. Podobnie jest z wysłaniem paczki, która opuściła magazyn, albo z wywołaniem zewnętrznego API, które nie udostępnia operacji odwrotnej.

W takich przypadkach masz dwie sensowne drogi. Pierwsza to kompensacja przez nową akcję, która łagodzi skutek. Skoro wysłałeś e-mail „zamówienie przyjęte”, wyślij kolejny: „zamówienie anulowane, przepraszamy”. Druga to przesunięcie nieodwracalnych kroków na sam koniec sagi, kiedy wszystkie ryzykowne operacje już się powiodły. Maila z potwierdzeniem wysyłasz dopiero wtedy, gdy płatność i rezerwacja są pewne. Szansa, że trzeba będzie go odwoływać, robi się wtedy minimalna.

Jak projektować akcje, żeby dało się je odwrócić

Kilka zasad, które oszczędziły mi sporo czasu:

  • Odwracalne na początek, nieodwracalne na koniec. Ustaw kroki tak, żeby operacje łatwe do cofnięcia szły jako pierwsze, a te nieodwracalne na samym końcu. Jak coś pójdzie nie tak, kompensujesz głównie rzeczy, które naprawdę da się odwrócić.
  • Rezerwuj, zamiast konsumować. Zamiast od razu zdejmować towar ze stanu, najpierw go rezerwujesz. Rezerwację łatwo zwolnić, a faktyczne wydanie robisz na końcu, gdy całe zamówienie jest pewne. Tak samo z płatnością: blokada środków (autoryzacja) jest łatwiejsza do cofnięcia niż faktyczne rozliczenie.
  • Każda kompensacja musi być idempotentna, czyli odporna na wielokrotne wykonanie. W systemie rozproszonym retry to codzienność. Saga może wysłać żądanie zwrotu płatności, nie dostać odpowiedzi i spróbować jeszcze raz. Jeśli „zwróć płatność” wykona się dwa razy i klient dostanie podwójny zwrot, masz nowy problem zamiast rozwiązania. Do idempotencji wrócę w sekcji o pułapkach.

Choreografia kontra orkiestracja

Wiesz już, czym jest saga i jak działa kompensacja. Zostaje pytanie, kto pilnuje kolejności kroków i decyduje, kiedy uruchomić kompensację. Są na to dwa podejścia. Warto rozumieć różnicę między nimi, bo wybór rzutuje na całą architekturę.

Choreografia: serwisy reagują na zdarzenia

W choreografii nie ma żadnego centralnego dyrygenta. Serwisy dogadują się ze sobą przez zdarzenia (eventy) publikowane na szynie, na przykład w Kafce albo RabbitMQ. Każdy serwis nasłuchuje zdarzeń, które go interesują, robi swoją robotę i publikuje kolejne zdarzenie. Na nie zareaguje następny serwis.

Wygląda to mniej więcej tak. Serwis zamówień publikuje OrderCreated. Serwis płatności to łapie, pobiera pieniądze i publikuje PaymentCompleted. Serwis magazynu łapie PaymentCompleted, rezerwuje towar i publikuje StockReserved. I tak dalej. Jeśli magazyn nie ma towaru, publikuje StockReservationFailed, a serwis płatności łapie to zdarzenie i robi zwrot.

Plusem jest luźne powiązanie serwisów. Nie ma jednego komponentu, który wie wszystko o całym procesie, więc serwisy są od siebie naprawdę niezależne. Łatwo też dołożyć nowego uczestnika, który po prostu zacznie nasłuchiwać istniejących zdarzeń.

Minus jest taki, że przy dłuższym procesie logika rozłazi się po wszystkich serwisach. Nikt nie ma pełnego obrazu sagi w jednym miejscu. Chcesz wiedzieć, co się dzieje, gdy padnie krok czwarty? Musisz prześledzić, kto nasłuchuje czego, w kilku serwisach naraz. Przy pięciu, sześciu krokach i kilku ścieżkach błędu robi się z tego niezła plątanina, a debugowanie potrafi być oporne.

Orkiestracja: centralny koordynator steruje procesem

W orkiestracji pojawia się dedykowany komponent, orchestrator, który zna cały proces i steruje nim krok po kroku. To on woła serwis płatności, czeka na wynik, potem woła magazyn. A gdy coś zawiedzie, sam uruchamia kompensacje w odpowiedniej kolejności. Serwisy nie wiedzą o sobie nawzajem, znają tylko orchestratora (albo odpowiadają na jego polecenia przez kolejkę).

Plusem jest to, że cała logika procesu siedzi w jednym miejscu. Patrzysz w kod orchestratora i od razu widzisz całość. Masz przed sobą happy path, kroki kompensujące i obsługę każdego rodzaju błędu. Łatwiej to testować, łatwiej monitorować i łatwiej wytłumaczyć nowej osobie w zespole.

Minus to dodatkowy komponent, który trzeba napisać i utrzymać. Orchestrator staje się centralnym punktem procesu. Trzeba więc zadbać o jego dostępność i o to, żeby sam nie stał się wąskim gardłem. Jest też ryzyko, że z czasem urośnie w „boski obiekt”, który wie zbyt dużo o logice poszczególnych serwisów.

Co wybrać?

Różnice najłatwiej widać, gdy zestawisz oba podejścia na kilku punktach. Choreografia sprawdza się przy krótkich procesach (dwa, trzy kroki) i daje luźne powiązanie serwisów. Jej minus to rozproszenie logiki po systemie, przez co obsługa błędów, debugowanie i monitoring są trudniejsze. Zaletą jest to, że nie wymaga żadnego dodatkowego komponentu. Orkiestracja lepiej radzi sobie ze złożonym przepływem na wiele kroków. Trzyma całą logikę procesu w jednym miejscu i ułatwia kontrolę nad kompensacjami, debugowanie i monitoring. W zamian wprowadza zależność serwisów od orchestratora i wymaga utrzymania tego dodatkowego komponentu.

Z mojego doświadczenia sprawdza się prosta zasada. Krótki proces na dwa, trzy kroki bez skomplikowanej logiki błędów? Choreografia w zupełności wystarczy i nie ma sensu dokładać orchestratora. Proces na pięć i więcej kroków, z kilkoma ścieżkami kompensacji, który chcesz łatwo prześledzić? Wtedy orkiestracja. W przykładzie poniżej pokażę właśnie orkiestrację. Na niej najlepiej widać, jak kroki i kompensacje układają się w jeden, czytelny przepływ.

SAGA w praktyce: zamówienie, płatność i rezerwacja magazynu

Teoria za nami, więc zobaczmy, jak to wygląda w kodzie. Zbuduję orchestrator dla naszego zamówienia, z trzema krokami: płatność, rezerwacja magazynu i zlecenie wysyłki. Kod jest w Javie 25 i Spring Boot 4.

Żeby nie utonąć w konfiguracji kolejek i komunikacji sieciowej, całą orkiestrację trzymam w jednej aplikacji. Wywołania do pozostałych serwisów ukrywam za interfejsami klientów. W realnym systemie pod tymi interfejsami siedziałyby wywołania REST albo wiadomości na kolejce. Dla zrozumienia mechanizmu sagi to nieistotny szczegół. Liczy się to, co robi orchestrator, a nie czym dokładnie gada z magazynem.

Klienci serwisów

Każdy serwis udostępnia dwie operacje: jedną do wykonania kroku i drugą do jego kompensacji. To właśnie ta para akcja – kompensacja.

public interface PaymentClient {
    String charge(String orderId, BigDecimal amount); // zwraca paymentId
    void refund(String paymentId);                     // kompensacja
}

public interface WarehouseClient {
    String reserve(String orderId, List<OrderItem> items); // zwraca reservationId
    void release(String reservationId);                     // kompensacja
}

public interface ShippingClient {
    String schedule(String orderId, String address); // zwraca shipmentId
    void cancel(String shipmentId);                   // kompensacja
}

Zwróć uwagę, że każda operacja wykonująca zwraca jakieś id (paymentIdreservationIdshipmentId). To nie przypadek. Bez tego identyfikatora nie miałbyś jak później wykonać kompensacji – żeby zwrócić płatność, musisz wiedzieć, którą.

Stan sagi w bazie

Tu dochodzimy do rzeczy, którą najczęściej się pomija, a która decyduje o tym, czy saga przeżyje restart serwisu. Stan sagi musi gdzieś żyć, i to nie w pamięci. Załóżmy, że orchestrator trzyma postęp procesu tylko w zmiennej. Serwis restartuje się po pobraniu płatności, ale przed rezerwacją towaru. Po wstaniu nie ma pojęcia, że jakaś saga była w toku. Pieniądze pobrane, a o reszcie cisza.

Dlatego stan sagi zapisuję do bazy jako zwykłą encję JPA:

@Entity
@Table(name = "order_saga")
public class OrderSaga {

    @Id
    private String orderId;

    @Enumerated(EnumType.STRING)
    private SagaStatus status;

    private BigDecimal amount;

    // id potrzebne do kompensacji, wypełniane w miarę postępu sagi
    private String paymentId;
    private String reservationId;
    private String shipmentId;

    private String failureReason;

    @Version
    private long version; // optimistic locking, chroni przed równoległą zmianą

    // konstruktory, gettery i settery pomijam dla zwięzłości
}

Statusy trzymam w prostym enumie, który odwzorowuje kolejne etapy procesu:

public enum SagaStatus {
    STARTED,
    PAYMENT_COMPLETED,
    STOCK_RESERVED,
    SHIPMENT_SCHEDULED,
    COMPLETED,      // happy path zakończony
    COMPENSATING,   // coś poszło nie tak, cofamy kroki
    FAILED          // saga zakończona po kompensacji
}

Krok sagi

Każdy krok potrafi dwie rzeczy: wykonać się i skompensować. Ujmuję to w jeden interfejs, dzięki czemu orchestrator może traktować wszystkie kroki jednakowo.

public interface SagaStep {
    void execute(OrderSaga saga);    // wykonuje krok i aktualizuje stan sagi
    void compensate(OrderSaga saga); // odwraca skutek kroku
}

Konkretny krok płatności wygląda tak:

@Component
@Order(1) // kolejność wykonywania kroków
class PaymentStep implements SagaStep {

    private final PaymentClient paymentClient;

    PaymentStep(PaymentClient paymentClient) {
        this.paymentClient = paymentClient;
    }

    @Override
    public void execute(OrderSaga saga) {
        String paymentId = paymentClient.charge(saga.getOrderId(), saga.getAmount());
        saga.setPaymentId(paymentId);
        saga.setStatus(SagaStatus.PAYMENT_COMPLETED);
    }

    @Override
    public void compensate(OrderSaga saga) {
        if (saga.getPaymentId() != null) {
            paymentClient.refund(saga.getPaymentId());
        }
    }
}

Krok magazynu i wysyłki wyglądają analogicznie. W execute wołają swój serwis i zapisują otrzymane id w sadze, a w compensate odwracają operację. Sprawdzenie if (... != null) w kompensacji jest tu po coś, ale o tym za chwilę.

Orchestrator

Całość spina orchestrator. Dostaje listę kroków (Spring wstrzykuje je w kolejności z adnotacji @Order) i wykonuje je po kolei. Po każdym kroku zapisuje stan sagi do bazy. Jak któryś krok rzuci wyjątek, orchestrator przechodzi w tryb kompensacji i cofa wykonane kroki w odwrotnej kolejności.

@Service
public class OrderSagaOrchestrator {

    private final List<SagaStep> steps;
    private final OrderSagaRepository repository;

    public OrderSagaOrchestrator(List<SagaStep> steps, OrderSagaRepository repository) {
        this.steps = steps; // wstrzyknięte w kolejności @Order
        this.repository = repository;
    }

    public void process(OrderSaga saga) {
        List<SagaStep> completed = new ArrayList<>();
        try {
            for (SagaStep step : steps) {
                step.execute(saga);
                repository.save(saga); // zapis stanu po każdym udanym kroku
                completed.add(step);
            }
            saga.setStatus(SagaStatus.COMPLETED);
            repository.save(saga);
        } catch (Exception e) {
            saga.setStatus(SagaStatus.COMPENSATING);
            saga.setFailureReason(e.getMessage());
            repository.save(saga);
            compensate(completed, saga);
        }
    }

    private void compensate(List<SagaStep> completed, OrderSaga saga) {
        // kompensacje w odwrotnej kolejności niż wykonanie
        for (int i = completed.size() - 1; i >= 0; i--) {
            completed.get(i).compensate(saga);
        }
        saga.setStatus(SagaStatus.FAILED);
        repository.save(saga);
    }
}

To jest cała mechanika sagi w jednym miejscu. Niecałe trzydzieści linii, a robi dokładnie to, co opisywaliśmy w teorii.

Skąd startuje saga

Zostaje pytanie, kto zakłada OrderSaga i woła process(...). W najprostszej wersji robi to endpoint. Przyjmuje żądanie zakupu, tworzy sagę w stanie startowym i oddaje ją orchestratorowi:

@RestController
@RequestMapping("/orders")
class OrderController {

    private final OrderSagaOrchestrator orchestrator;

    OrderController(OrderSagaOrchestrator orchestrator) {
        this.orchestrator = orchestrator;
    }

    @PostMapping
    ResponseEntity<String> placeOrder(@RequestBody OrderRequest request) {
        // 1. zakładamy sagę w stanie startowym
        OrderSaga saga = new OrderSaga(request.orderId(), request.amount(), SagaStatus.STARTED);

        // 2. oddajemy ją orchestratorowi
        orchestrator.process(saga);

        // 3. odpowiadamy stanem, w jakim saga się zakończyła
        return ResponseEntity.ok("Stan zamówienia: " + saga.getStatus());
    }
}

W realnym systemie sagę zwykle uruchamia się asynchronicznie, na przykład po odebraniu zdarzenia z kolejki. Synchroniczne wywołanie w wątku requestu HTTP to uproszczenie. Zostaję przy nim, żeby nie zaciemniać tego, co istotne.

Happy path

Prześledźmy, co się dzieje, gdy wszystko idzie po naszej myśli. Orchestrator wykonuje kroki po kolei:

  1. PaymentStep – pieniądze pobrane, w sadze ląduje paymentId, status PAYMENT_COMPLETED, stan zapisany.
  2. WarehouseStep – towar zarezerwowany, jest reservationId, status STOCK_RESERVED, zapis.
  3. ShippingStep – kurier zlecony, jest shipmentId, status SHIPMENT_SCHEDULED, zapis.

Pętla się kończy, status leci na COMPLETED i zamówienie jest obsłużone. Lista completed nigdy nie zostaje użyta do kompensacji, bo żaden krok nie zawiódł.

Scenariusz błędu i kompensacja

A teraz to, dla czego w ogóle bawimy się w sagę. Płatność przeszła, status PAYMENT_COMPLETEDpaymentId zapisany. Orchestrator próbuje zarezerwować towar i WarehouseClient.reserve(...) rzuca wyjątek, bo magazyn pusty.

Lecimy do bloku catch i dalej dzieje się tak:

  1. Status sagi zmienia się na COMPENSATING, w failureReason ląduje powód, stan zostaje zapisany.
  2. Rusza kompensacja po liście completed, która w tym momencie zawiera tylko PaymentStep (bo tylko on zdążył się powieść).
  3. Orchestrator woła PaymentStep.compensate(...), ten widzi zapisany paymentId i robi zwrot przez paymentClient.refund(...).
  4. Status leci na FAILED, zapis, koniec.

Efekt jest taki, jakiego oczekujemy: klient nie został z obciążoną kartą za towar, którego nie ma. System wrócił do spójnego stanu dzięki świadomie wykonanej kompensacji, którą sami zaprogramowaliśmy. I tu wraca to sprawdzenie if (saga.getPaymentId() != null) z kroku płatności. Kompensujemy tylko te kroki z listy completed, które faktycznie się wykonały. Krok wysyłki nigdy nie ruszył, więc nie próbuje anulować nieistniejącego zlecenia.

Jedno zastrzeżenie, żeby nie wprowadzać Cię w błąd: ten orchestrator to szkielet pokazujący mechanizm, a nie gotowiec na produkcję. Brakuje mu obsługi retry, idempotencji i sytuacji, w której sama kompensacja się nie powiedzie. O tym wszystkim za chwilę. Jeśli nie chcesz pisać tego ręcznie, istnieją gotowe rozwiązania. Axon Framework, Eventuate czy silniki procesowe pokroju Camundy i Temporal biorą część tej roboty na siebie. To temat na osobny wpis, więc tutaj zostawiam je jako trop do sprawdzenia.

Pułapki i dobre praktyki

Szkielet z poprzedniej sekcji pokazuje mechanizm. Ale między „działa na moim laptopie” a „działa na produkcji pod obciążeniem” jest sporo rzeczy, które potrafią napsuć krwi. Zebrałem te, na które sam trafiłem albo widziałem u innych.

Idempotencja

To pułapka numer jeden i wracam do niej z premedytacją. W systemie rozproszonym wiadomości się gubią, odpowiedzi nie docierają, a klient ponawia żądanie. Może się zdarzyć, że orchestrator wyśle „pobierz płatność”, serwis faktycznie pobierze, ale odpowiedź zginie po drodze. Orchestrator nic nie dostaje, więc po timeoucie próbuje jeszcze raz. Bez zabezpieczenia klient płaci dwa razy.

Lekarstwem jest idempotencja: ta sama operacja wykonana wielokrotnie ma dać ten sam skutek co pojedyncze wykonanie. Robisz to przez klucz idempotentności, czyli identyfikator jednoznacznie wskazujący operację. W naszej sadze świetnie nadaje się do tego orderId, bo dla jednego zamówienia płatność pobierasz tylko raz. Serwis płatności przed pobraniem sprawdza, czy zamówienie o tym identyfikatorze już obsłużył. Jeśli tak, zwraca zapisany wynik zamiast pobierać pieniądze po raz drugi.

// implementacja PaymentClient.charge(orderId, amount) po stronie serwisu płatności
public String charge(String orderId, BigDecimal amount) {
    // jeśli płatność dla tego zamówienia już przeszła,
    // zwróć zapisany paymentId zamiast pobierać po raz drugi
    return processedPayments.findByOrderId(orderId)
            .map(ProcessedPayment::getPaymentId)
            .orElseGet(() -> doCharge(orderId, amount));
}

To samo dotyczy kompensacji. „Zwróć płatność” wykonane dwa razy nie może oznaczać dwóch zwrotów.

Retry i timeouty

Skoro ponawiamy żądania, trzeba to robić z głową. Ślepy retry w pętli potrafi dobić serwis, który już się dusi. Stosuj ponawianie z rosnącym odstępem (exponential backoff) i ogranicz liczbę prób. Ustaw też sensowne timeouty na wywołania serwisów. Bez nich orchestrator potrafi wisieć w nieskończoność, czekając na odpowiedź, która nigdy nie przyjdzie.

Nie musisz pisać tej logiki ręcznie. W ekosystemie Springa retry z backoffem zrobisz przez Resilience4j albo Spring Retry. Dorzucasz adnotację i trochę konfiguracji, zamiast klepać własne pętle z licznikiem prób.

Brak izolacji i semantic lock

W transakcji bazodanowej masz izolację: inne transakcje nie widzą Twoich niezatwierdzonych zmian. W sadze tej izolacji nie ma. Po pierwszym kroku dane są już zacommitowane i widoczne dla całego świata, choć cała operacja biznesowa wciąż trwa. Ktoś może odczytać stan w połowie sagi i podjąć na jego podstawie decyzję, a chwilę później kompensacja ten stan odwróci.

Najprostsze obejście to status pośredni, taki semantyczny zamek. Zamówienie w stanie STARTED czy PAYMENT_COMPLETED nie jest „gotowe”. Reszta systemu wie, że nie powinna go jeszcze traktować jak finalne. Dopiero COMPLETED oznacza, że dane są pewne.

Outbox pattern

Tu wchodzę na grunt, na którym sam sporo siedziałem. Wyobraź sobie, że krok sagi zapisuje coś w swojej bazie i zaraz potem publikuje zdarzenie na kolejkę. Co, jeśli zapis się powiedzie, a publikacja padnie, bo broker akurat nie odpowiada? Baza mówi „zrobione”, a reszta systemu nigdy się o tym nie dowie. Saga utyka.

Rozwiązaniem jest wzorzec outbox. Zamiast publikować zdarzenie bezpośrednio, w tej samej transakcji bazodanowej co zmianę biznesową zapisujesz zdarzenie do tabeli outbox:

@Transactional
public void reserveStock(OrderSaga saga, List<OrderItem> items) {
    String reservationId = warehouse.reserve(saga.getOrderId(), items);
    saga.setReservationId(reservationId);

    // zdarzenie zapisujemy w TEJ SAMEJ transakcji co zmianę biznesową
    // albo zapisze się jedno i drugie, albo nic
    outboxRepository.save(new OutboxEvent("StockReserved", saga.getOrderId()));
    // osobny proces odczyta tabelę outbox i opublikuje zdarzenie na kolejkę
}

Skoro to jedna transakcja, albo zapiszą się obie rzeczy, albo żadna. Osobny proces odczytuje potem tę tabelę i publikuje zdarzenia na kolejkę, oznaczając wysłane. Dzięki temu masz gwarancję, że żadne zdarzenie nie zginie między bazą a brokerem. Wdrażałem to w aplikacji opartej o RabbitMQ. Właśnie outbox zamienił „czasem gubimy wiadomości” w „nie gubimy”. Jeśli chcesz wejść głębiej w sam wzorzec, dobrym punktem odniesienia jest opis transactional outbox na microservices.io.

Co, gdy kompensacja też zawiedzie

Zwrot płatności może się nie udać. Wtedy automatyka się kończy i wchodzi człowiek. W praktyce nieudane kompensacje lądują na dead-letter queue albo w tabeli „do ręcznej obsługi”. System alarmuje, że coś wymaga interwencji. Części przypadków po prostu nie da się rozwiązać w pełni automatycznie i nie ma w tym nic złego. Lepiej mieć kontrolowany kanał na takie sytuacje niż cicho je gubić.

Zanim jednak uznasz, że trzeba wołać człowieka, daj kompensacji drugą szansę. Często zawodzi przez przejściową awarię sieci, a ponowienie za chwilę kończy się sukcesem. Ustaw więc kilka prób z backoffem, a dopiero brak efektu kieruje sprawę na dead-letter queue. Jeden warunek: kompensacja musi być idempotentna, żeby kolejne próby niczego nie zdublowały. A jeśli sprawa i tak ląduje u człowieka, w stanie sagi musi być komplet danych. Mowa o id płatności, id rezerwacji i powodzie błędu. Inaczej zejdzie mu pół dnia na odtwarzaniu historii z logów.

Debugowanie

Saga rozłożona na kilka serwisów jest trudna do prześledzenia, jeśli nie zadbasz o to z góry. Bez tego masz logi w pięciu miejscach i zero pojęcia, który zapis dotyczy którego zamówienia.

Ratunkiem jest wspólny identyfikator korelacji. Każde zamówienie dostaje swój sagaId (u nas to po prostu orderId). Wędruje on z każdym żądaniem i zdarzeniem przez wszystkie serwisy i trafia do każdego logu. Do tego dorzuć traceId ze śledzenia rozproszonego (distributed tracing, na przykład OpenTelemetry). Zobaczysz wtedy cały przepływ jednej sagi na osi czasu. Gdy o trzeciej w nocy przyjdzie zgłoszenie „zamówienie 12345 utknęło”, chcesz działać od razu. Wpisujesz jeden identyfikator i widzisz całą historię, zamiast zgadywać.

Pomaga też sam stan sagi w bazie. Skoro zapisujesz go po każdym kroku, tabela order_saga mówi wprost, gdzie utknął każdy proces. Jedno zapytanie WHERE status = 'COMPENSATING' wyciąga wszystkie sagi, które poległy w trakcie kompensacji. Warto na tym zbudować alert. Jeśli saga wisi w stanie pośrednim dłużej niż kilka minut, coś poszło nie tak i ktoś powinien to sprawdzić.

Jak testować sagę

Saga ma sporo ścieżek: happy path plus każda możliwa porażka po drodze. Dlatego testy nie są tu dodatkiem, tylko koniecznością. To właśnie ścieżki błędu najłatwiej zepsuć przy kolejnej zmianie w kodzie. Na szczęście podział na osobne kroki i orchestrator sprawia, że większość testów pisze się prosto.

Zacznij od testów jednostkowych pojedynczych kroków. Każdy SagaStep ma dwie metody, więc i dwa rodzaje testów. Sprawdzasz, czy execute woła właściwą operację klienta i zapisuje otrzymane id w sadze. Oraz czy compensate faktycznie odwraca skutek. Klienci to interfejsy, więc podstawiasz pod nie mocki (na przykład w Mockito) i sprawdzasz, że krok wywołał to, co trzeba.

Potem orchestrator. Tu najważniejszy jest test scenariusza błędu, bo na tym polega cała zabawa. Podstaw klienta magazynu, który rzuca wyjątek, i uruchom process(...). Zweryfikuj dwie rzeczy: że kompensacja wykonała się dla wcześniejszych kroków i że saga skończyła w stanie FAILED.

@Test
void shouldCompensatePaymentWhenStockReservationFails() {
    // given: rezerwacja magazynu rzuca wyjątek
    when(warehouseClient.reserve(any(), any()))
            .thenThrow(new RuntimeException("brak towaru"));
    OrderSaga saga = new OrderSaga("order-1", new BigDecimal("199.00"), SagaStatus.STARTED);

    // when
    orchestrator.process(saga);

    // then: płatność została zwrócona, a saga zakończyła się w stanie FAILED
    verify(paymentClient).refund(any());
    assertThat(saga.getStatus()).isEqualTo(SagaStatus.FAILED);
}

Nie zapomnij o teście idempotencji. Wywołaj ten sam krok dwa razy z tym samym orderId i upewnij się, że płatność poszła tylko raz. Na koniec testy integracyjne z prawdziwą bazą. Zapis stanu sagi to za ważna rzecz, żeby sprawdzać go wyłącznie na mockach. Świetnie nadaje się do tego Testcontainers. Uruchamiasz bazę PostgreSQL w kontenerze i sprawdzasz, czy stan sagi przetrwa zapis, odczyt i restart aplikacji.

Kiedy SAGA nie jest dobrym wyborem

Saga rozwiązuje konkretny problem, ale to nie jest wzorzec, który wciskasz wszędzie tam, gdzie pojawia się słowo „mikroserwisy”. Sam kilka razy widziałem, jak ląduje w miejscu, gdzie tylko dokłada złożoności. Warto więc znać sytuacje, w których lepiej z niej zrezygnować.

Jeśli cała operacja mieści się w jednej bazie, nie kombinuj. Zwykła transakcja z @Transactional da Ci atomowość za darmo i z gwarancjami, których saga nigdy nie dorówna. Budowanie orchestratora, stanów i kompensacji dla czegoś, co spina jeden commit, to wkładanie pracy w problem, którego nie masz.

Saga odpada też tam, gdzie wymagasz silnej, natychmiastowej spójności. Z założenia daje spójność ostateczną, czyli akceptuje, że przez chwilę dane bywają niespójne. Są domeny, gdzie to nie przejdzie i potrzebujesz twardszych gwarancji albo trzymania powiązanych danych razem.

Bywa też tak, że kroków po prostu nie da się sensownie skompensować – i wtedy cały pomysł się sypie. Saga stoi na założeniu, że dla każdej akcji istnieje rozsądna akcja odwrotna. Gdy proces składa się głównie z operacji nieodwracalnych, których nie potrafisz nawet złagodzić, masz problem. Zostajesz z niespójnymi stanami, które trzeba sprzątać ręcznie po każdym nieudanym przebiegu.

I sytuacja, która zdarza się najczęściej, choć rzadko się do niej przyznajemy: potrzeba sagi bywa objawem źle wyznaczonych granic serwisów. Jeśli każda operacja biznesowa wymaga skoordynowania pięciu serwisów w jednej transakcji, to coś jest nie tak. Może te serwisy wcale nie powinny być osobno. Czasem prostszą odpowiedzią niż saga jest scalenie kilku serwisów w jeden. Taki serwis trzyma powiązane dane razem i obsługuje je zwykłą transakcją. Zanim sięgniesz po sagę, warto zadać sobie pytanie, czy problem nie leży w podziale na serwisy.

Podsumowanie

Najważniejsze, z czym warto wyjść po tym wpisie: saga nie jest zamiennikiem transakcji ACID jeden do jednego. To sposób na zamodelowanie długiego procesu biznesowego rozłożonego na wiele serwisów. Zamiast liczyć na automatyczny rollback, sam projektujesz, co znaczy „cofnąć” każdy krok.

Cała trudność nie leży w napisaniu kroków, tylko w trzech rzeczach: dobrze zaprojektowanych kompensacjach, idempotencji i obserwowalności. Zadbasz o nie i reszta jest do ogarnięcia. Zignorujesz je, a problemy ujawnią się na produkcji, zwykle w najmniej odpowiednim momencie. I szczera rada na koniec: zanim sięgniesz po sagę, upewnij się, że naprawdę jej potrzebujesz. Czasem to znak, że granice serwisów wymagają przemyślenia. Dobry punkt odniesienia to opis wzorca SAGA na microservices.io.

A jak to wygląda u Ciebie? Stosujesz sagę na orkiestracji, czy wolisz choreografię na zdarzeniach? A może masz swój sposób na obsługę nieudanych kompensacji? Daj znać w komentarzu, chętnie poczytam, jak radzicie sobie z tym w swoich projektach.

Subskrybuj
Powiadom o
guest
0 komentarzy
Najstarsze
Najnowsze Najwięcej głosów