Wzorzec Saga w transakcjach rozproszonych – przykłady w Go
Transakcje w mikroserwisach z użyciem wzorca Saga
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.
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
-
Odwracalne operacje: Operacje, które można bezpośrednio cofnąć
- Przykład: Zwolnienie zarezerwowanego zapasu, zwrot płatności
-
Kompensujące działania: Różne operacje, które osiągają odwrotny efekt
- Przykład: Anulowanie zamówienia zamiast jego usunięcia
-
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
-
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
- Wzorce mikrousług przez Chrisa Richardsona
- Wzorzec Saga - Martin Fowler
- Ramka Saga Eventuate Tram
- Silnik workflow Temporal
- Dokumentacja AWS Step Functions
- Go Cheat Sheet
- Generiki w Go: Przypadki użycia i wzorce
- Porównanie ORM dla PostgreSQL w Go: GORM vs Ent vs Bun vs sqlc
- Tworzenie aplikacji CLI w Go z Cobra & Viper
- Implementacja sieci usług z Istio i Linkerd
- Tworzenie mikrousług opartych na zdarzeniach z użyciem AWS Kinesis