Wzorzec Saga w transakcjach rozproszonych – przykłady w Go

Transakcje w mikroserwisach z użyciem wzorca Saga

Page content

Wzorzec Saga oferuje eleganckie rozwiązanie, dzieląc transakcje rozproszone na serię lokalnych transakcji z kompensującymi działaniami.

Zamiast opierać się na rozproszonych blokadach, które mogą blokować operacje między usługami, Saga umożliwia ostateczną spójność poprzez sekwencję odwracalnych kroków, co czyni ją idealną do długotrwałych procesów biznesowych.

W architekturach mikrousług utrzymanie spójności danych między usługami to jedno z najtrudniejszych wyzwań. Tradycyjne transakcje ACID nie działają, gdy operacje obejmują wiele usług z niezależnymi bazami danych, pozostawiając programistów w poszukiwaniu alternatywnych podejść, aby zapewnić integralność danych.

Ten przewodnik demonstruje implementację wzorca Saga w Go z praktycznymi przykładami obejmującymi zarówno podejście orchestracji, jak i choreografii. Jeśli potrzebujesz szybkiego odniesienia do podstaw Go, Go Cheat Sheet oferuje pomocny przegląd.

robot konstrukcyjny z transakcjami rozproszonymi To piękne zdjęcie zostało wygenerowane przez model AI Flux 1 dev.

Zrozumienie wzorca Saga

Wzorzec Saga został pierwotnie opisany przez Hectora Garcia-Moliny i Kennetha Salem w 1987 roku. W kontekście mikrousług jest to sekwencja lokalnych transakcji, w której każda transakcja aktualizuje dane w ramach jednej usługi. Jeśli którykolwiek krok zawiedzie, wykonuje się kompensujące transakcje, aby cofnąć skutki poprzednich kroków.

W przeciwieństwie do tradycyjnych transakcji rozproszonych, które korzystają z dwóch fazowego komitowania (2PC), Saga nie utrzymuje blokad między usługami, co czyni ją odpowiednią do długotrwałych procesów biznesowych. Wymianą jest ostateczna spójność zamiast silnej spójności.

Kluczowe cechy

  • Brak blokad rozproszonych: Każda usługa zarządza własną lokalną transakcją
  • Kompensujące działania: Każda operacja ma odpowiadający jej mechanizm cofania
  • Ostateczna spójność: System w końcu osiąga stan spójny
  • Długotrwałość: Odpowiednia do procesów trwających sekundy, minuty lub nawet godziny

Podejścia do implementacji wzorca Saga

Istnieją dwa główne podejścia do implementacji wzorca Saga: orchestracja i choreografia.

Podejście orchestracji

W orchestracji centralny koordynator (orchestrator) zarządza całym przepływem transakcji. Orchestrator odpowiada za:

  • Wywoływanie usług w odpowiedniej kolejności
  • Obsługę błędów i uruchamianie kompensacji
  • Utrzymanie stanu Saga
  • Koordynowanie ponownych prób i czasów oczekiwania

Zalety:

  • Centralne sterowanie i widoczność
  • Łatwiejsze do zrozumienia i debugowania
  • Lepsze obsługa błędów i odzyskiwanie
  • Prostsze testowanie ogólnego przepływu

Wady:

  • Punkt awaryjny (choć można to zminimalizować)
  • Dodatkowa usługa do utrzymania
  • Może stać się wąskim gardłem dla złożonych przepływów

Przykład w Go:

type OrderSagaOrchestrator struct {
    orderService    OrderService
    paymentService  PaymentService
    inventoryService InventoryService
    shippingService ShippingService
}

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    sagaID := generateSagaID()
    
    // Krok 1: Utwórz zamówienie
    orderID, err := o.orderService.Create(order)
    if err != nil {
        return err
    }
    
    // Krok 2: Rezerwuj zapas
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // Kompensacja
        return err
    }
    
    // Krok 3: Przetwórz płatność
    paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
    if err != nil {
        o.inventoryService.Release(order.Items) // Kompensacja
        o.orderService.Cancel(orderID)          // Kompensacja
        return err
    }
    
    // Krok 4: Utwórz przesyłkę
    if err := o.shippingService.CreateShipment(orderID); err != nil {
        o.paymentService.Refund(paymentID)      // Kompensacja
        o.inventoryService.Release(order.Items) // Kompensacja
        o.orderService.Cancel(orderID)          // Kompensacja
        return err
    }
    
    return nil
}

Podejście choreografii

W choreografii nie ma centralnego koordynatora. Każda usługa wie, co ma zrobić i komunikuje się poprzez zdarzenia. Usługi nasłuchują zdarzeń i reagują odpowiednio. To podejście oparte na zdarzeniach jest szczególnie potężne, gdy jest łączone z platformami przesyłania wiadomości, takimi jak AWS Kinesis, które oferują skalowalną infrastrukturę do dystrybucji zdarzeń między mikrousługami. Dla kompletnego przewodnika po implementacji mikrousług opartych na zdarzeniach z Kinesis, zobacz Stworzenie mikrousług opartych na zdarzeniach z użyciem AWS Kinesis.

Zalety:

  • Rozproszony i skalowalny
  • Brak punktu awaryjnego
  • Usługi pozostają luźno połączone
  • Naturalna odpowiedź na architektury oparte na zdarzeniach

Wady:

  • Trudniejsze do zrozumienia ogólnego przepływu
  • Trudne do debugowania i śledzenia
  • Złożona obsługa błędów
  • Ryzyko zależności cyklicznych

Przykład z architekturą opartą na zdarzeniach:

// Usługa Zamówienia
type OrderService struct {
    eventBus EventBus
    repo     OrderRepository
}

func (s *OrderService) CreateOrder(order Order) (string, error) {
    orderID, err := s.repo.Save(order)
    if err != nil {
        return "", err
    }
    
    s.eventBus.Publish("OrderCreated", OrderCreatedEvent{
        OrderID:    orderID,
        CustomerID: order.CustomerID,
        Items:      order.Items,
        Total:      order.Total,
    })
    
    return orderID, nil
}

func (s *OrderService) HandlePaymentFailed(event PaymentFailedEvent) error {
    return s.repo.Cancel(event.OrderID) // Kompensacja
}

// Usługa Płatności
type PaymentService struct {
    eventBus EventBus
    client   PaymentClient
}

func (s *PaymentService) HandleOrderCreated(event OrderCreatedEvent) {
    paymentID, err := s.client.Charge(event.CustomerID, event.Total)
    if err != nil {
        s.eventBus.Publish("PaymentFailed", PaymentFailedEvent{
            OrderID: event.OrderID,
        })
        return
    }
    
    s.eventBus.Publish("PaymentSucceeded", PaymentSucceededEvent{
        OrderID:   event.OrderID,
        PaymentID: paymentID,
    })
}

func (s *PaymentService) HandleInventoryReservationFailed(event InventoryReservationFailedEvent) error {
    // Kompensacja: zwrot płatności
    return s.client.Refund(event.PaymentID)
}

Strategie kompensacyjne

Kompensacja jest sercem wzorca Saga. Każda operacja musi mieć odpowiadającą jej kompensację, która może odwrócić jej skutki.

Typy kompensacji

  1. Odwracalne operacje: Operacje, które można bezpośrednio cofnąć

    • Przykład: Zwolnienie zarezerwowanego zapasu, zwrot płatności
  2. Kompensujące działania: Różne operacje, które osiągają odwrotny efekt

    • Przykład: Anulowanie zamówienia zamiast jego usunięcia
  3. Optymistyczna kompensacja: Wykonaj operacje i kompensuj, jeśli to konieczne

    • Przykład: Pobierz płatność najpierw, zwróć ją, jeśli zapas nie jest dostępny
  4. Pessimistyczna kompensacja: Przydziel zasoby, które można zwolnić

    • Przykład: Zarezerwuj zapas przed pobraniem płatności

Wymagania dotyczące idempotencji

Wszystkie operacje i kompensacje muszą być idempotencjalne. To zapewnia, że ponowne próby wykonania nieudanej operacji nie powodują podwójnych skutków.

func (s *PaymentService) Refund(paymentID string) error {
    // Sprawdź, czy płatność została już zworzona
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }
    
    if payment.Status == "refunded" {
        return nil // Płatność została już zworzona, idempotencjalne
    }
    
    // Przetwórz zwrot
    return s.processRefund(paymentID)
}

Najlepsze praktyki

1. Zarządzanie stanem Saga

Utrzymuj stan każdej instancji Saga, aby śledzić postęp i umożliwiać odzyskiwanie. Gdy zapisujesz stan Saga w bazie danych, wybór odpowiedniego ORM jest kluczowy dla wydajności i utrzymanie. Dla implementacji opartych na PostgreSQL, rozważ porównanie w Porównanie ORM dla PostgreSQL w Go: GORM vs Ent vs Bun vs sqlc w celu wyboru najlepszej opcji dla potrzeb przechowywania stanu Saga:

type SagaState struct {
    ID           string
    Status       SagaStatus
    Steps        []SagaStep
    CurrentStep  int
    CreatedAt    time.Time
    UpdatedAt    time.Time
}

type SagaStep struct {
    Service     string
    Operation   string
    Status      StepStatus
    Compensated bool
    Data        map[string]interface{}
}

2. Obsługa czasów oczekiwania

Zaimplementuj czas oczekiwania dla każdego kroku, aby zapobiec zawieszeniu Saga na stałe:

type SagaOrchestrator struct {
    timeout time.Duration
}

func (o *SagaOrchestrator) ExecuteWithTimeout(step SagaStep) error {
    ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
    defer cancel()
    
    done := make(chan error, 1)
    go func() {
        done <- step.Execute()
    }()
    
    select {
    case err := <-done:
        return err
    case <-ctx.Done():
        // Wystąpił czas oczekiwania, kompensuj
        if err := step.Compensate(); err != nil {
            return fmt.Errorf("kompensacja nie powiodła się: %w", err)
        }
        return fmt.Errorf("krok %s wygasł po %v", step.Name(), o.timeout)
    }
}

3. Logika ponownego uruchamiania

Zaimplementuj logikę ponownego uruchamiania z wykładniczym odstępem dla błędów tymczasowych:

func retryWithBackoff(operation func() error, maxRetries int) error {
    backoff := time.Second
    for i := 0; i < maxRetries; i++ {
        err := operation()
        if err == nil {
            return nil
        }
        
        if !isTransientError(err) {
            return err
        }
        
        time.Sleep(backoff)
        backoff *= 2
    }
    return fmt.Errorf("operacja nie powiodła się po %d ponownych próbach", maxRetries)
}

4. Źródłowanie zdarzeń dla stanu Saga

Użyj źródłowania zdarzeń, aby utrzymać pełny audyt. Gdy implementujesz magazyny zdarzeń i mechanizmy odtwarzania, typowe generiki w Go mogą pomóc stworzyć bezpieczne dla typów, ponownie wykorzystywalne kodowanie obsługi zdarzeń. Dla zaawansowanych wzorców z użyciem generik w Go, zobacz Generiki w Go: Przypadki użycia i wzorce.

type SagaEvent struct {
    SagaID    string
    EventType string
    Payload   []byte
    Timestamp time.Time
    Version   int64
}

type SagaEventStore struct {
    store EventRepository
}

func (s *SagaEventStore) AppendEvent(sagaID string, eventType string, payload interface{}) error {
    data, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("nie udało się zakodować ładunku: %w", err)
    }
    
    version, err := s.store.GetNextVersion(sagaID)
    if err != nil {
        return fmt.Errorf("nie udało się uzyskać wersji: %w", err)
    }
    
    event := SagaEvent{
        SagaID:    sagaID,
        EventType: eventType,
        Payload:   data,
        Timestamp: time.Now(),
        Version:   version,
    }
    
    return s.store.Save(event)
}

func (s *SagaEventStore) ReplaySaga(sagaID string) (*Saga, error) {
    events, err := s.store.GetEvents(sagaID)
    if err != nil {
        return nil, fmt.Errorf("nie udało się uzyskać zdarzeń: %w", err)
    }
    
    saga := NewSaga()
    for _, event := range events {
        if err := saga.Apply(event); err != nil {
            return nil, fmt.Errorf("nie udało się zastosować zdarzenia: %w", err)
        }
    }
    
    return saga, nil
}

5. Monitorowanie i obserwowalność

Zaimplementuj kompleksowe logowanie i śledzenie:

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    span := tracer.StartSpan("saga.create_order")
    defer span.Finish()
    
    span.SetTag("saga.id", sagaID)
    span.SetTag("order.id", order.ID)
    
    logger.WithFields(log.Fields{
        "saga_id": sagaID,
        "order_id": order.ID,
        "step": "create_order",
    }).Info("Saga rozpoczęta")
    
    // ... wykonanie Saga
    
    return nil
}

Powszechne wzorce i antywzorce

Wzorce do stosowania

  • Wzorzec koordynatora Saga: Użyj dedykowanej usługi do orchestracji
  • Wzorzec outbox: Zapewnij niezawodne publikowanie zdarzeń
  • Klucze idempotencjalne: Użyj unikalnych kluczy dla wszystkich operacji
  • Maszyna stanów Saga: Modele Saga jako maszynę stanów

Antywzorce do unikania

  • Kompensacja synchroniczna: Nie czekaj, aż kompensacja zostanie zakończona
  • Zagnieżdżone Saga: Unikaj, aby Saga wywoływała inne Saga (zamiast tego użyj pod-Saga)
  • Wspólne stany: Nie dziel stanów między krokami Saga
  • Długotrwałe kroki: Rozbij kroki, które trwają zbyt długo

Narzędzia i ramy

Wiele ram może pomóc w implementacji wzorca Saga:

  • Temporal: Platforma orchestracji workflow z wbudowaną obsługą Saga
  • Zeebe: Silnik workflow dla orchestracji mikrousług
  • Eventuate Tram: Ramka Saga dla Spring Boot
  • AWS Step Functions: Orchestracja workflow bez serwera
  • Apache Camel: Ramka integracji z obsługą Saga

Dla usług orchestratora, które potrzebują interfejsów CLI do zarządzania i monitorowania, Tworzenie aplikacji CLI w Go z Cobra & Viper oferuje doskonałe wzorce do tworzenia narzędzi CLI do interakcji z orchestratorami Saga.

Gdy wdrażasz mikrousługi oparte na Saga w Kubernetes, implementacja sieci usług może znacząco poprawić obserwowalność, bezpieczeństwo i zarządzanie ruchem. Implementacja sieci usług z Istio i Linkerd pokazuje, jak sieci usług uzupełniają wzorce transakcji rozproszonych, oferując zagadnienia takie jak śledzenie rozproszone i przerywanie obwodów.

Kiedy używać wzorca Saga

Użyj wzorca Saga, gdy:

  • ✅ Operacje obejmują wiele mikrousług
  • ✅ Długotrwałe procesy biznesowe
  • ✅ Ostateczna spójność jest akceptowalna
  • ✅ Chcesz uniknąć blokad rozproszonych
  • ✅ Usługi mają niezależne bazy danych

Unikaj, gdy:

  • ❌ Wymagana jest silna spójność
  • ❌ Operacje są proste i szybkie
  • ❌ Wszystkie usługi dzielą tę samą bazę danych
  • ❌ Logika kompensacyjna jest zbyt skomplikowana

Podsumowanie

Wzorzec Saga jest kluczowy do zarządzania transakcjami rozproszonymi w architekturach mikrousług. Choć wprowadza złożoność, oferuje praktyczne rozwiązanie do utrzymania spójności danych między granicami usług. Wybierz orchestrację dla lepszego kontroli i widoczności, lub choreografię dla skalowalności i luźnego sprzężenia. Zawsze zapewnij, że operacje są idempotencjalne, zaimplementuj odpowiednią logikę kompensacyjną i utrzymuj kompleksową obserwowalność.

Kluczem do pomyślnego wdrożenia Saga jest zrozumienie wymagań spójności, staranne zaprojektowanie logiki kompensacyjnej i wybór odpowiedniego podejścia dla Twojego przypadku użycia. Dzięki odpowiedniej implementacji, Saga umożliwia budowanie odpornych, skalowalnych mikrousług, które utrzymują integralność danych w systemach rozproszonych.

Przydatne linki