Wzorzec Saga w rozproszonych transakcjach – z przykładami w języku Go

Transakcje w architekturze mikrousług z wykorzystaniem wzorca Saga

Page content

Wzorzec Saga dostarcza eleganckiego rozwiązania, dzieląc rozproszone transakcje na serię lokalnych transakcji z akcjami kompensacyjnymi.

Zamiast polegać na rozproszonych blokadach, które mogą blokować operacje między usługami, Saga umożliwia osiągnięcie ostatecznej spójności poprzez sekwencję odwracalnych kroków, co czyni ją idealną dla długotrwałych procesów biznesowych.

W architekturach opartych na mikrousługach utrzymaniem spójności danych między usługami jest jednym z największych wyzwań. Tradycyjne transakcje ACID nie działają w przypadku operacji obejmujących wiele usług z niezależnymi bazami danych, co zmusza programistów do poszukiwania alternatywnych podejść do zapewnienia integralności danych.

Ten przewodnik demonstruje implementację wzorca Saga w języku Go z praktycznymi przykładami obejmującymi zarówno podejście orkiestracyjne, jak i choreograficzne. Jeśli potrzebujesz szybkiego przewodnika po podstawach Go, Środowisko Cheatsheet Go dostarcza przydatne omówienie.

budowniczy z rozproszonymi transakcjami To ładne obraz został wygenerowany przez model AI Flux 1 dev.

Zrozumienie wzorca Saga

Wzorzec Saga został po raz pierwszy opisany przez Hectora Garcia-Molina i Kennetha Salema w 1987 roku. W kontekście mikrousług jest to sekwencja lokalnych transakcji, gdzie każda transakcja aktualizuje dane w ramach pojedynczej usługi. Jeśli którykolwiek krok nie powiedzie się, wykonuje się transakcje kompensacyjne, aby cofnąć efekty poprzednich kroków.

W przeciwieństwie do tradycyjnych rozproszonych transakcji używających dwufazowego zatwierdzania (2PC), Saga nie trzyma blokad między usługami, co czyni ją odpowiednią dla długotrwałych procesów biznesowych. Kompromisem jest ostateczna spójność zamiast silnej spójności.

Kluczowe cechy

  • Brak rozproszonych blokad: Każda usługa zarządza własną lokalną transakcją
  • Akcje kompensacyjne: Każda operacja ma odpowiadający jej mechanizm cofania
  • Ostateczna spójność: System ostatecznie osiąga spójny stan
  • Długotrwała: Odpowiednia dla procesów trwających sekundy, minuty lub nawet godziny

Podejścia do implementacji Saga

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

Wzorzec orkiestracji

W orkiestracji centralny koordynator (orkiestrator) zarządza całym przepływem transakcji. Orkiestrator jest odpowiedzialny za:

  • Wywoływanie usług w odpowiedniej kolejności
  • Obsługę błędów i wyzwalanie kompensacji
  • Utrzymywanie stanu sagi
  • Koordynację ponownych prób i limitów czasu

Zalety:

  • Centralizowana kontrola i widoczność
  • Łatwiejsze zrozumienie i debugowanie
  • Lepiej obsługiwane błędy i odzyskiwanie
  • Prostsze testowanie całego przepływu

Wady:

  • Pojedynczy punkt awarii (choć można go złagodzić)
  • Dodatkowa usługa do utrzymania
  • Może stać się wąskim gardłem w złożonych przepływach

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: Tworzenie zamówienia
    orderID, err := o.orderService.Create(order)
    if err != nil {
        return err
    }
    
    // Krok 2: Rezerwacja magazynu
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // Kompensacja
        return err
    }
    
    // Krok 3: Przetwarzanie płatności
    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: Tworzenie wysyłki
    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
}

Wzorzec 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 zdarzeniowe podejście jest szczególnie mocne, gdy połączone z platformami strumieniowania wiadomości takimi jak AWS Kinesis, które dostarczają skalowalną infrastrukturę do dystrybucji zdarzeń między mikrousługami. W celu kompleksowego przewodnika po implementacji mikrousług opartych na zdarzeniach z Kinesis, zobacz Budowanie mikrousług opartych na zdarzeniach z AWS Kinesis.

Zalety:

  • Rozproszenie i skalowalność
  • Brak pojedynczego punktu awarii
  • Usługi pozostają luźno powiązane
  • Naturalne dopasowanie do architektur opartych na zdarzeniach

Wady:

  • Trudniejsze zrozumienie całego przepływu
  • Trudne debugowanie i śledzenie
  • Skomplikowana obsługa błędów
  • Ryzyko cyklicznych zależności

Przykład z architekturą opartą na zdarzeniach:

// Usługa zamówień
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
}

// UWAGA: s.repo.Save followed by s.eventBus.Publish is a dual-write.
// In production, replace this with the transactional outbox pattern so the
// event is written atomically with the order row and published by a relay.

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 kompensacji

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

Rodzaje kompensacji

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

    • Przykład: Zwolnienie zarezerwowanego magazynu, zwrot płatności
  2. Akcje kompensacyjne: Różne operacje, które osiągną odwrotny efekt

    • Przykład: Anulowanie zamówienia zamiast jego usunięcia
  3. Kompensacja pesymistyczna: Wstępne przydzielanie zasobów, które można zwolnić

    • Przykład: Rezerwacja magazynu przed obciążeniem płatności
  4. Kompensacja optymistyczna: Wykonywanie operacji i kompensacja w razie potrzeby

    • Przykład: Najpierw obciążenie płatności, zwrot jeśli magazyn jest niedostępny

Wymagania idempotentności

Wszystkie operacje i kompensacje muszą być idempotentne. Zapewnia to, że ponowna próba nieudanej operacji nie spowoduje zduplikowanych efektów. Równie ważne jest upewnienie się, że każdy uczestnik sagi niezawodnie publikuje swoje zdarzenia po lokalnym zatwierdzeniu — wzorzec transakcyjnej skrzynki wychodzącej jest standardowym sposobem zamykania tej luki między zapisem w bazie danych a publikacją w brokerze.

func (s *PaymentService) Refund(paymentID string) error {
    // Sprawdź czy już zwrócono
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }
    
    if payment.Status == "refunded" {
        return nil // Już zwrócono, idempotentne
    }
    
    // Przetwórz zwrot
    return s.processRefund(paymentID)
}

Najlepsze praktyki

1. Zarządzanie stanem Saga

Utrzymuj stan każdej instancji sagi w celu śledzenia postępów i umożliwienia odzyskiwania. Przy utrwalaniu stanu sagi w bazie danych wybór odpowiedniego ORM jest kluczowy dla wydajności i utrzymania. W implementacjach opartych na PostgreSQL rozważ porównanie w Porównanie ORM Go dla PostgreSQL: GORM vs Ent vs Bun vs sqlc aby wybrać najlepsze rozwiązanie dla potrzeb przechowywania stanu sagi:

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 limitów czasu

Wdroż limity czasu dla każdego kroku, aby zapobiec zawieszaniu się sag:

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():
        // Limit czasu minął, skompensuj
        if err := step.Compensate(); err != nil {
            return fmt.Errorf("kompensacja nieudana: %w", err)
        }
        return fmt.Errorf("krok %s przekroczył limit czasu po %v", step.Name(), o.timeout)
    }
}

3. Logika ponownych prób

Wdroż wykładniczy backoff dla chwilowych awarii:

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 nieudana po %d ponownych próbach", maxRetries)
}

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

Używaj źródłowania zdarzeń do utrzymania pełnego śladu audytowego. Przy wdrażaniu magazynów zdarzeń i mechanizmów odtwarzania, generyki w Go mogą pomóc w tworzeniu bezpiecznych typowo, wielokrotnego użytku kodu obsługującego zdarzenia. W celu poznania zaawansowanych wzorców używających generyków w Go, zobacz Generyki 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("błąd serializacji ładunku: %w", err)
    }
    
    version, err := s.store.GetNextVersion(sagaID)
    if err != nil {
        return fmt.Errorf("błąd pobrania 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("błąd pobrania zdarzeń: %w", err)
    }
    
    saga := NewSaga()
    for _, event := range events {
        if err := saga.Apply(event); err != nil {
            return nil, fmt.Errorf("błąd zastosowania zdarzenia: %w", err)
        }
    }
    
    return saga, nil
}

5. Monitorowanie i obserwowalność

Wdroż 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 sagi
    
    return nil
}

Popularne wzorce i antywzorce

Wzorce do stosowania

  • Wzorzec koordynatora Saga: Używaj dedykowanej usługi do orkiestracji
  • Wzorzec skrzynki wychodzącej: Zapewnij niezawodne publikowanie zdarzeń
  • Klucze idempotentności: Używaj unikalnych kluczy dla wszystkich operacji
  • Automat stanowy Saga: Modeluj sagę jako automat stanowy

Antywzorce do unikania

  • Kompensacja synchroniczna: Nie czekaj na zakończenie kompensacji
  • Zagnieżdżone sagi: Unikaj sag wywołujących inne sagi (zamiast tego użyj pod-sag)
  • Wspólny stan: Nie dziel stanu między krokami sagi
  • Długotrwałe kroki: Podziel kroki, które zajmują zbyt dużo czasu

Narzędzia i frameworki

Kilka frameworków może pomóc w implementacji wzorców Saga:

  • Temporal: Platforma orkiestracji przepływów pracy ze wsparciem Saga
  • Zeebe: Silnik przepływów pracy do orkiestracji mikrousług
  • Eventuate Tram: Framework Saga dla Spring Boot
  • AWS Step Functions: Orkiestracja przepływów pracy bezserwerowych
  • Apache Camel: Framework integracyjny ze wsparciem Saga

Dla usług orkiestratorów potrzebujących interfejsów CLI do zarządzania i monitorowania, Budowanie aplikacji CLI w Go z Cobra & Viper dostarcza doskonałe wzorce do tworzenia narzędzi wiersza poleceń do interakcji z orkiestratorami sagi.

Podczas wdrażania mikrousług opartych na Sage w Kubernetesie, wdrożenie service mesh może znacząco poprawić obserwowalność, bezpieczeństwo i zarządzanie ruchem. Implementacja Service Mesh z Istio i Linkerd omawia jak service mesh uzupełnia wzorce rozproszonych transakcji, dostarczając poprzeczne aspekty takie jak rozproszone śledzenie i circuit breaking.

Kiedy używać wzorca Saga

Używaj wzorca Saga kiedy:

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

Unikaj kiedy:

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

Podsumowanie

Wzorzec Saga jest niezbędny do zarządzania rozproszonymi transakcjami w architekturach opartych na mikrousługach. Choć wprowadza złożoność, dostarcza praktyczne rozwiązanie do utrzymania spójności danych między granicami usług. Wybierz orkiestrację dla lepszej kontroli i widoczności, lub choreografię dla skalowalności i luźnego powiązania. Zawsze upewnij się, że operacje są idempotentne, wdroż odpowiednią logikę kompensacji i utrzymuj kompleksową obserwowalność.

Kluczem do sukcesowej implementacji Saga jest zrozumienie wymagań spójności, staranne projektowanie logiki kompensacji i wybór odpowiedniego podejścia dla Twojego przypadku użycia. Przy odpowiedniej implementacji Saga umożliwia budowanie odpornych, skalowalnych mikrousług, które utrzymują integralność danych w rozproszonych systemach.

Przydatne linki

Subskrybuj

Otrzymuj nowe wpisy o systemach, infrastrukturze i inżynierii AI.