Dlaczego 2PC nie działa w mikrousługach

Il Zatwierdzenie dwufazowe (2 szt.) to klasyczny protokół dla transakcji rozproszonych: Koordynator prosi wszystkich uczestników o przygotowanie (faza przygotowań), a następnie zamów zatwierdzenie. Działa to dobrze w przypadku jednorodnych baz danych (np. dwóch instancji PostgreSQL), ale jest problematyczne w mikroserwisach:

  • Sprzęganie: Wszystkie usługi muszą być dostępne podczas transakcji
  • Wydajność: zasoby pozostają zablokowane do momentu zatwierdzenia (blokady rozproszone)
  • Ograniczone wsparcie: Wiele usług w chmurze, interfejsów API REST i baz danych NoSQL nie obsługuje 2 komputerów
  • Skalowalność: koordynator staje się pojedynczym punktem awarii

Wzorzec Saga zastępuje jedną dużą rozproszoną transakcję jedną sekwencja transakcji lokalnych, każdy realizowany niezależnie, koordynowany za pośrednictwem wydarzeń lub orkiestratora.

Przykład: Saga o zakupach w handlu elektronicznym

Kroki Saga dotyczące zakupu:

  1. Utwórz zamówienie (Serwis zamówień) → OrdineCreato
  2. Zapasy rezerwowe (Usługa inwentarza) → Zapasy zarezerwowane
  3. Przetwarzanie płatności (Usługa płatnicza) → Płatność potwierdzona
  4. Potwierdź zamówienie (Zamówienie usługi) → Zamówienie potwierdzone

Jeżeli płatność nie powiedzie się, transakcjami kompensującymi są:

  1. Zwolnij inwentarz (Usługa inwentaryzacji)
  2. Anuluj zamówienie (Zamówienie usługi)

Saga choreograficzna: autonomia usług

Nel Saga choreograficzna, nie ma centralnego koordynatora. Każdy serwis nasłuchuje zdarzeń wywołanych przez poprzedni serwis i odpowiednio reaguje, z kolei tworząc nowe wydarzenia. Logika koordynacji jest rozproszona między służbami: każda zna tylko swój krok i zdarzenia, na które reaguje.

Zawodowiec: maksymalne oddzielenie, brak pojedynczego punktu awarii, proste w zastosowaniu. Przeciwko: trudne do debugowania (logika sagi jest dystrybuowana), brak widoczności kluczowe dla stanu sagi jest ryzyko cykli źle zarządzanych wydarzeń.

// CHOREOGRAPHY SAGA con Spring Kafka

// ===== STEP 1: Servizio Ordini - crea l'ordine =====
@Service
public class OrdiniService {

    private final KafkaTemplate<String, Object> kafkaTemplate;

    public String creaOrdine(CreaOrdineCommand cmd) {
        String ordineId = UUID.randomUUID().toString();

        // Salva l'ordine in stato PENDING
        Ordine ordine = Ordine.builder()
            .id(ordineId)
            .clienteId(cmd.getClienteId())
            .prodotti(cmd.getProdotti())
            .stato(StatoOrdine.PENDING)
            .build();
        ordineRepository.save(ordine);

        // Pubblica evento: il Servizio Inventario lo ascolterà
        OrdineCreato event = new OrdineCreato(ordineId, cmd.getProdotti());
        kafkaTemplate.send("ordini-events", ordineId, event);

        return ordineId;
    }

    // Ascolta la conferma del pagamento per finalizzare l'ordine
    @KafkaListener(topics = "pagamenti-events", groupId = "ordini-service")
    public void onPagamento(ConsumerRecord<String, Object> record) {
        if (record.value() instanceof PagamentoConfermato event) {
            ordineRepository.updateStato(event.getOrdineId(), StatoOrdine.CONFERMATO);
            kafkaTemplate.send("ordini-events", event.getOrdineId(),
                new OrdineConfermato(event.getOrdineId()));
        }
        if (record.value() instanceof PagamentoFallito event) {
            // Compensating: annulla l'ordine
            ordineRepository.updateStato(event.getOrdineId(), StatoOrdine.ANNULLATO);
        }
    }
}

// ===== STEP 2: Servizio Inventario - riserva i prodotti =====
@Service
public class InventarioService {

    @KafkaListener(topics = "ordini-events", groupId = "inventario-service")
    public void onOrdinCreato(ConsumerRecord<String, Object> record) {
        if (record.value() instanceof OrdineCreato event) {
            try {
                // Tenta la prenotazione dell'inventario
                prenotaInventario(event.getOrdineId(), event.getProdotti());

                // Successo: pubblica evento per il Servizio Pagamenti
                kafkaTemplate.send("inventario-events", event.getOrdineId(),
                    new InventarioRiservato(event.getOrdineId(), event.getTotale()));

            } catch (InventarioInsufficienterException e) {
                // Fallimento: pubblica evento di fallimento
                // Il Servizio Ordini annullerà l'ordine
                kafkaTemplate.send("inventario-events", event.getOrdineId(),
                    new InventarioNonDisponibile(event.getOrdineId(), e.getMessage()));
            }
        }

        // Compensating: rilascia l'inventario se il pagamento fallisce
        if (record.value() instanceof PagamentoFallito event) {
            rilasciaInventario(event.getOrdineId());
        }
    }
}

// ===== STEP 3: Servizio Pagamenti - processa il pagamento =====
@Service
public class PagamentiService {

    @KafkaListener(topics = "inventario-events", groupId = "pagamenti-service")
    public void onInventarioRiservato(ConsumerRecord<String, Object> record) {
        if (record.value() instanceof InventarioRiservato event) {
            try {
                processaPagamento(event.getOrdineId(), event.getTotale());

                kafkaTemplate.send("pagamenti-events", event.getOrdineId(),
                    new PagamentoConfermato(event.getOrdineId()));

            } catch (PagamentoRifiutatoException e) {
                kafkaTemplate.send("pagamenti-events", event.getOrdineId(),
                    new PagamentoFallito(event.getOrdineId(), e.getMessage()));
            }
        }
    }
}

Saga o orkiestracji: scentralizowana kontrola

w'Saga orkiestracyjna, centralny element zwany Orkiestrator Sagi (lub Kierownik Procesu) koordynuje całą sekwencję. Orkiestrator zna wszystkie kroki, wyślij polecenie do usług i poczekaj na ich reakcję. W przypadku niepowodzenia, koordynator jawnie wysyła polecenia kompensacji.

Zawodowiec: scentralizowana i widoczna logika, łatwa do debugowania, wyraźny stan sagi, bardziej precyzyjna obsługa błędów. Przeciwko: sprzęgło główne, orkiestrator zna wszystkie zaangażowane usługi.

// ORCHESTRATION SAGA con Axon Framework (Java)
// Axon gestisce automaticamente la persistenza dello stato della saga

import org.axonframework.saga.*;
import org.axonframework.eventhandling.*;

@Saga
public class AcquistoSaga {

    @Autowired
    private transient CommandGateway commandGateway;

    // Lo stato della saga persiste automaticamente tra gli step
    private String ordineId;
    private List<ProdottoOrdinato> prodotti;
    private BigDecimal totale;

    // STEP 1: Avvio della saga quando viene creato un ordine
    @StartSaga
    @SagaEventHandler(associationProperty = "ordineId")
    public void on(OrdineCreato event) {
        this.ordineId = event.getOrdineId();
        this.prodotti = event.getProdotti();
        this.totale = event.getTotale();

        // Invia comando al servizio inventario
        commandGateway.send(new RiservaInventarioCommand(
            ordineId,
            prodotti
        ));
    }

    // STEP 2: Inventario riservato con successo - procedi con il pagamento
    @SagaEventHandler(associationProperty = "ordineId")
    public void on(InventarioRiservato event) {
        commandGateway.send(new ProcessaPagamentoCommand(
            ordineId,
            totale,
            "CARTA_CREDITO"
        ));
    }

    // STEP 3: Pagamento confermato - finalizza l'ordine
    @SagaEventHandler(associationProperty = "ordineId")
    public void on(PagamentoConfermato event) {
        commandGateway.send(new ConfermaOrdineCommand(ordineId));
    }

    // Fine saga (successo)
    @EndSaga
    @SagaEventHandler(associationProperty = "ordineId")
    public void on(OrdineConfermato event) {
        System.out.println("Saga completata con successo per ordine: " + ordineId);
    }

    // ===== COMPENSATING TRANSACTIONS =====

    // Pagamento fallito: rilascia inventario, poi annulla ordine
    @SagaEventHandler(associationProperty = "ordineId")
    public void on(PagamentoFallito event) {
        // Compensating step 1: rilascia inventario
        commandGateway.send(new RilasciaInventarioCommand(ordineId, prodotti));
    }

    // Inventario rilasciato - annulla l'ordine
    @SagaEventHandler(associationProperty = "ordineId")
    public void on(InventarioRilasciato event) {
        // Compensating step 2: annulla l'ordine
        commandGateway.send(new AnnullaOrdineCommand(ordineId, "Pagamento fallito"));
    }

    // Inventario non disponibile - annulla direttamente
    @SagaEventHandler(associationProperty = "ordineId")
    public void on(InventarioNonDisponibile event) {
        commandGateway.send(new AnnullaOrdineCommand(ordineId, "Inventario insufficiente"));
    }

    // Fine saga per percorso di fallimento
    @EndSaga
    @SagaEventHandler(associationProperty = "ordineId")
    public void on(OrdineAnnullato event) {
        System.out.println("Saga fallita/annullata per ordine: " + ordineId + ". Motivo: " + event.getMotivo());
    }
}

Saga z funkcjami krokowymi AWS (bezserwerowo)

W przypadku architektur bezserwerowych w AWS, Funkcje kroku jest to usługa zarządzana wdrożyć Sagę Orchestration. Każdy krok jest funkcją Lambda lub akcją AWS, i Step Functions automatycznie zarządzają stanem, ponownymi próbami i kompensacjami z definicji w języku stanów Amazonii (ASL).

{
  "Comment": "Saga acquisto e-commerce con compensating transactions",
  "StartAt": "CreaOrdine",
  "States": {
    "CreaOrdine": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:eu-west-1:123456:function:crea-ordine",
      "Next": "RiservaInventario",
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "Next": "FallimentoSaga"
        }
      ]
    },
    "RiservaInventario": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:eu-west-1:123456:function:riserva-inventario",
      "Next": "ProcessaPagamento",
      "Catch": [
        {
          "ErrorEquals": ["InventarioInsufficienterException"],
          "Next": "CompensaAnnullaOrdine"
        }
      ]
    },
    "ProcessaPagamento": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:eu-west-1:123456:function:processa-pagamento",
      "Retry": [
        {
          "ErrorEquals": ["TransientPaymentError"],
          "IntervalSeconds": 2,
          "MaxAttempts": 3,
          "BackoffRate": 2
        }
      ],
      "Next": "ConfermaOrdine",
      "Catch": [
        {
          "ErrorEquals": ["PagamentoRifiutatoException"],
          "Next": "CompensaRilasciaInventario"
        }
      ]
    },
    "ConfermaOrdine": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:eu-west-1:123456:function:conferma-ordine",
      "End": true
    },

    "CompensaRilasciaInventario": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:eu-west-1:123456:function:rilascia-inventario",
      "Next": "CompensaAnnullaOrdine"
    },
    "CompensaAnnullaOrdine": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:eu-west-1:123456:function:annulla-ordine",
      "Next": "FallimentoSaga"
    },
    "FallimentoSaga": {
      "Type": "Fail",
      "Error": "SagaFailed",
      "Cause": "La transazione distribuita e fallita, compensazioni eseguite"
    }
  }
}

Idempotencja w transakcjach kompensacyjnych

Transakcje kompensacyjne muszą być idempotentny: jeśli zostanie wywołany kilka razy (z powodu ponownej próby sieci lub programu Orchestrator), wynik musi być taki sam. Najpopularniejszą techniką jest użycie a klucz idempotencji: identyfikator zamówienia + typ operacji tworzą unikalny klucz, który zapobiega powielaniu operacji.

// RilasciaInventarioHandler.java - Compensating transaction idempotente
@CommandHandler
public void handle(RilasciaInventarioCommand cmd) {
    // Controllo idempotenza: questa compensazione e gia stata eseguita?
    String idempotencyKey = cmd.getOrdineId() + ":RILASCIA_INVENTARIO";

    if (compensazioniEseguite.contains(idempotencyKey)) {
        System.out.println("Compensazione gia eseguita per " + idempotencyKey + ", skip");
        return;
    }

    // Esegui la compensazione
    for (ProdottoOrdinato prodotto : cmd.getProdotti()) {
        inventarioRepository.incrementaDisponibilita(
            prodotto.getProductId(),
            prodotto.getQuantita()
        );
    }

    // Segna la compensazione come eseguita
    compensazioniEseguite.add(idempotencyKey);

    // Pubblica evento per continuare la catena di compensazione
    eventBus.publish(new InventarioRilasciato(cmd.getOrdineId()));
}

Śledzenie stanu Saga: wgląd w stan

Jedną z krytyki Orchestration Saga jest trudność w śledzeniu stanu w systemach o dużej objętości. Wyraźna tabela śledzenia sagi rozwiązuje ten problem:

-- Tabella di tracking per le saga in corso
CREATE TABLE saga_state (
    saga_id       VARCHAR(36) PRIMARY KEY,
    saga_type     VARCHAR(100) NOT NULL,
    ordine_id     VARCHAR(36) NOT NULL,
    current_step  VARCHAR(100) NOT NULL,
    stato         VARCHAR(50) NOT NULL,  -- IN_CORSO, COMPLETATA, FALLITA, COMPENSATA
    started_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    completed_at  TIMESTAMPTZ,
    error_message TEXT
);

-- Query utili per monitoring
-- Saga in corso da piu di 5 minuti (potenzialmente bloccate)
SELECT * FROM saga_state
WHERE stato = 'IN_CORSO'
  AND started_at < NOW() - INTERVAL '5 minutes';

-- Distribuzione per step corrente
SELECT current_step, COUNT(*) as count
FROM saga_state WHERE stato = 'IN_CORSO'
GROUP BY current_step;

Porównanie: choreografia kontra orkiestracja

Czekam Choreografia Orkiestracja
Sprzęganie Niski (zdarzenia) Średni (orkiestrator zna usługi)
Widoczność stanu Twarde (rozproszone) Wysoka (scentralizowana)
Debugowanie Twardy (logika rozproszona) Łatwy (stan jawny)
Kompleksowość usług Wysoka (każdy serwis zna rekompensaty) Niski (proste usługi, logika w programie Orchestrator)
Skalowalność Wysoka (brak pojedynczych punktów) Średni (Orkiestrator w skali)
Idealny przypadek użycia Kilka kroków, autonomiczne usługi, niezależne zespoły Wiele kroków, złożona logika, niezbędna widoczność

Kolejne kroki w serii

  • Artykuł 6 – AWS EventBridge: Choreografia Saga korzysta z magistrali komunikatów wydarzenia wymiany. EventBridge to idealna magistrala zarządzana przez AWS do inteligentnego routingu zdarzeń pomiędzy Lambdą i SQS.
  • Artykuł 10 – Wzór skrzynki nadawczej: Zapewnij atomową wysyłkę wydarzeń Saga wraz z aktualizacją lokalnej bazy danych.

Połącz z innymi seriami

  • Pozyskiwanie źródeł zdarzeń + CQRS (art. 4): każdy etap Sagi wytwarza wydarzenia które można wykorzystać jako źródło prawdy do rekonstrukcji stanu zagregowanego. Axon Framework natywnie obsługuje kombinację Saga + Event Sourcing.
  • Apache Kafka (seria 38): Kafka to idealny autobus do przesyłania wiadomości Choreografia Saga w produkcji, z gwarancją trwałości i porządku na partycję kluczową.