Wzorzec Saga w rozproszonych transakcjach – z przykładami w języku Go
Transakcje w architekturze mikrousług z wykorzystaniem wzorca Saga
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.
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
-
Operacje odwracalne: Operacje, które można bezpośrednio cofnąć
- Przykład: Zwolnienie zarezerwowanego magazynu, zwrot płatności
-
Akcje kompensacyjne: Różne operacje, które osiągną odwrotny efekt
- Przykład: Anulowanie zamówienia zamiast jego usunięcia
-
Kompensacja pesymistyczna: Wstępne przydzielanie zasobów, które można zwolnić
- Przykład: Rezerwacja magazynu przed obciążeniem płatności
-
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
- Wzorce mikrousług przez Chrisa Richardson
- Wzorzec Saga - Martin Fowler
- Framework Saga Eventuate Tram
- Silnik przepływów pracy Temporal
- Dokumentacja AWS Step Functions
- Środowisko Cheatsheet Go
- Generyki w Go: przypadki użycia i wzorce
- Porównanie ORM Go dla PostgreSQL: GORM vs Ent vs Bun vs sqlc
- Implementacja CQRS w Go
- Budowanie aplikacji CLI w Go z Cobra & Viper
- Implementacja Service Mesh z Istio i Linkerd
- Budowanie mikrousług opartych na zdarzeniach z AWS Kinesis