Saga-Pattern bei verteilten Transaktionen – Mit Beispielen in Go

Transaktionen in Microservices mit dem Saga-Muster

Inhaltsverzeichnis

Das Saga-Muster bietet eine elegante Lösung, indem es verteilte Transaktionen in eine Reihe lokaler Transaktionen mit kompensierenden Aktionen aufteilt.

Anstatt sich auf verteilte Sperren zu verlassen, die Vorgänge über Dienste hinweg blockieren können, ermöglicht Saga durch eine Sequenz umkehrbarer Schritte eine finale Konsistenz (eventual consistency). Dies macht es ideal für lang laufende Geschäftsprozesse.

In Microservice-Architekturen ist die Aufrechterhaltung der Datenkonsistenz über Dienste hinweg eines der größten Herausforderungen. Traditionelle ACID-Transaktionen funktionieren nicht, wenn Vorgänge mehrere Dienste mit unabhängigen Datenbanken umfassen, was Entwickler dazu zwingt, nach alternativen Ansätzen zur Sicherstellung der Datenintegrität zu suchen.

Dieser Leitfaden demonstriert die Implementierung des Saga-Musters in Go mit praktischen Beispielen, die sowohl Orchestrierungs- als auch Choreografie-Ansätze abdecken. Wenn Sie eine schnelle Referenz für Go-Grundlagen benötigen, bietet das Go Cheat Sheet einen hilfreichen Überblick.

construction worker with distributed transactions Dieses hübsche Bild wurde von AI model Flux 1 dev generiert.

Das Saga-Muster verstehen

Das Saga-Muster wurde ursprünglich 1987 von Hector Garcia-Molina und Kenneth Salem beschrieben. Im Kontext von Microservices ist es eine Sequenz lokaler Transaktionen, bei der jede Transaktion Daten innerhalb eines einzelnen Dienstes aktualisiert. Wenn ein Schritt fehlschlägt, werden kompensierende Transaktionen ausgeführt, um die Auswirkungen der vorherigen Schritte rückgängig zu machen.

Im Gegensatz zu traditionellen verteilten Transaktionen, die das Two-Phase-Commit-Protokoll (2PC) verwenden, hält Saga keine Sperren über Dienste hinweg. Dies macht es geeignet für lang laufende Geschäftsprozesse. Der Kompromiss ist finale Konsistenz statt starker Konsistenz.

Hauptmerkmale

  • Keine verteilten Sperren: Jeder Dienst verwaltet seine eigene lokale Transaktion
  • Kompensierende Aktionen: Jede Operation hat einen entsprechenden Rollback-Mechanismus
  • Finale Konsistenz: Das System erreicht schließlich einen konsistenten Zustand
  • Lang laufend: Geeignet für Prozesse, die Sekunden, Minuten oder sogar Stunden dauern

Implementierungsansätze für Saga

Es gibt zwei primäre Ansätze zur Implementierung des Saga-Musters: Orchestrierung und Choreografie.

Orchestrierungsmuster

Bei der Orchestrierung verwaltet ein zentraler Koordinator (Orchestrator) den gesamten Transaktionsfluss. Der Orchestrator ist verantwortlich für:

  • Aufrufen von Diensten in der richtigen Reihenfolge
  • Behandlung von Fehlern und Auslösen von Kompensationen
  • Verwalten des Zustands der Saga
  • Koordinieren von Wiederholungsversuchen und Timeouts

Vorteile:

  • Zentrale Steuerung und Übersicht
  • Einfacher zu verstehen und zu debuggen
  • Bessere Fehlerbehandlung und Wiederherstellung
  • Einfacheres Testen des gesamten Flusses

Nachteile:

  • Single Point of Failure (obwohl dies gemildert werden kann)
  • Zusätzlicher Dienst zur Wartung
  • Kann bei komplexen Flüssen zu einem Engpass werden

Beispiel in Go:

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

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    sagaID := generateSagaID()
    
    // Schritt 1: Bestellung erstellen
    orderID, err := o.orderService.Create(order)
    if err != nil {
        return err
    }
    
    // Schritt 2: Inventar reservieren
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // Kompensieren
        return err
    }
    
    // Schritt 3: Zahlung verarbeiten
    paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
    if err != nil {
        o.inventoryService.Release(order.Items) // Kompensieren
        o.orderService.Cancel(orderID)          // Kompensieren
        return err
    }
    
    // Schritt 4: Versand erstellen
    if err := o.shippingService.CreateShipment(orderID); err != nil {
        o.paymentService.Refund(paymentID)      // Kompensieren
        o.inventoryService.Release(order.Items) // Kompensieren
        o.orderService.Cancel(orderID)          // Kompensieren
        return err
    }
    
    return nil
}

Choreografiemuster

Bei der Choreografie gibt es keinen zentralen Koordinator. Jeder Dienst weiß, was zu tun ist, und kommuniziert über Events. Dienste hören auf Events und reagieren entsprechend. Dieser ereignisgesteuerte Ansatz ist besonders leistungsfähig, wenn er mit Message-Streaming-Plattformen wie AWS Kinesis kombiniert wird, die skalierbare Infrastruktur für die Event-Verteilung über Microservices hinweg bereitstellen. Für einen umfassenden Leitfaden zur Implementierung ereignisgesteuerter Microservices mit Kinesis sehen Sie Building Event-Driven Microservices with AWS Kinesis.

Vorteile:

  • Dezentral und skalierbar
  • Kein Single Point of Failure
  • Dienste bleiben lose gekoppelt
  • Natürliche Passform für ereignisgesteuerte Architekturen

Nachteile:

  • Schwerer, den Gesamtfluss zu verstehen
  • Schwer zu debuggen und zu verfolgen
  • Komplexe Fehlerbehandlung
  • Risiko zyklischer Abhängigkeiten

Beispiel mit ereignisgesteuerter Architektur:

// Order Service
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) // Kompensation
}

// Payment Service
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 {
    // Kompensation: Zahlung erstatten
    return s.client.Refund(event.PaymentID)
}

Kompensationsstrategien

Kompensation ist das Herzstück des Saga-Musters. Jede Operation muss eine entsprechende Kompensation haben, die ihre Auswirkungen umkehren kann.

Arten der Kompensation

  1. Umkehrbare Operationen: Operationen, die direkt rückgängig gemacht werden können

    • Beispiel: Freigabe reservierten Inventars, Rückerstattung von Zahlungen
  2. Kompensierende Aktionen: Andere Operationen, die den umgekehrten Effekt erzielen

    • Beispiel: Stornieren einer Bestellung anstatt sie zu löschen
  3. Pessimistische Kompensation: Vorab Zuweisung von Ressourcen, die freigegeben werden können

    • Beispiel: Reservierung des Inventars vor der Belastung der Zahlung
  4. Optimistische Kompensation: Ausführen von Operationen und Kompensieren bei Bedarf

    • Beispiel: Zahlung zuerst belasten, erstatten, wenn Inventar nicht verfügbar ist

Anforderungen an die Idempotenz

Alle Operationen und Kompensationen müssen idempotent sein. Dies stellt sicher, dass das Wiederholen einer fehlgeschlagenen Operation keine doppelten Effekte verursacht.

func (s *PaymentService) Refund(paymentID string) error {
    // Prüfen, ob bereits erstattet
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }
    
    if payment.Status == "refunded" {
        return nil // Bereits erstattet, idempotent
    }
    
    // Rückerstattung verarbeiten
    return s.processRefund(paymentID)
}

Best Practices

1. Saga-Zustandsverwaltung

Verwalten Sie den Zustand jeder Saga-Instanz, um den Fortschritt zu verfolgen und Wiederherstellung zu ermöglichen. Bei der Persistierung des Saga-Zustands in einer Datenbank ist die Wahl des richtigen ORMs entscheidend für Leistung und Wartbarkeit. Für PostgreSQL-basierte Implementierungen sollten Sie die_comparison in Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc berücksichtigen, um die beste Option für Ihre Saga-Zustandsspeicherbedürfnisse auszuwählen:

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. Timeout-Handling

Implementieren Sie Timeouts für jeden Schritt, um zu verhindern, dass Sagas unendlich hängen bleiben:

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():
        // Timeout aufgetreten, kompensieren
        if err := step.Compensate(); err != nil {
            return fmt.Errorf("compensation failed: %w", err)
        }
        return fmt.Errorf("step %s timed out after %v", step.Name(), o.timeout)
    }
}

3. Retry-Logik

Implementieren Sie exponentiellen Backoff für vorübergehende Fehler:

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("operation failed after %d retries", maxRetries)
}

4. Event Sourcing für den Saga-Zustand

Verwenden Sie Event Sourcing, um einen vollständigen Audit-Trail aufrechtzuerhalten. Bei der Implementierung von Event Stores und Replay-Mechanismen können Go-Generics helfen, typsicheren, wiederverwendbaren Event-Handling-Code zu erstellen. Für fortgeschrittene Muster mit Generics in Go sehen Sie Go Generics: Use Cases and Patterns.

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("failed to marshal payload: %w", err)
    }
    
    version, err := s.store.GetNextVersion(sagaID)
    if err != nil {
        return fmt.Errorf("failed to get version: %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("failed to get events: %w", err)
    }
    
    saga := NewSaga()
    for _, event := range events {
        if err := saga.Apply(event); err != nil {
            return nil, fmt.Errorf("failed to apply event: %w", err)
        }
    }
    
    return saga, nil
}

5. Monitoring und Observability

Implementieren Sie umfassende Protokollierung und Tracing:

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 started")
    
    // ... saga execution
    
    return nil
}

Häufige Muster und Anti-Patterns

Zu befolgende Muster

  • Saga Coordinator Pattern: Verwenden Sie einen dedizierten Dienst für die Orchestrierung
  • Outbox Pattern: Stellen Sie eine zuverlässige Event-Veröffentlichung sicher
  • Idempotency Keys: Verwenden Sie eindeutige Schlüssel für alle Operationen
  • Saga State Machine: Modellieren Sie die Saga als Zustandsmaschine

Zu vermeidende Anti-Patterns

  • Synchronous Compensation: Warten Sie nicht auf die Fertigstellung der Kompensation
  • Nested Sagas: Vermeiden Sie Sagas, die andere Sagas aufrufen (verwenden Sie stattdessen Sub-Sagas)
  • Shared State: Teilen Sie keinen Zustand zwischen Saga-Schritten
  • Long-Running Steps: Unterteilen Sie Schritte, die zu lange dauern

Tools und Frameworks

Mehrere Frameworks können bei der Implementierung von Saga-Mustern helfen:

  • Temporal: Workflow-Orchestrierungsplattform mit eingebauter Saga-Unterstützung
  • Zeebe: Workflow-Engine für Microservice-Orchestrierung
  • Eventuate Tram: Saga-Framework für Spring Boot
  • AWS Step Functions: Serverlose Workflow-Orchestrierung
  • Apache Camel: Integrationsframework mit Saga-Unterstützung

Für Orchestrator-Dienste, die CLI-Schnittstellen für Verwaltung und Monitoring benötigen, bietet Building CLI Applications in Go with Cobra & Viper exzellente Muster zur Erstellung von Befehlszeilentools zur Interaktion mit Saga-Orchestratoren.

Bei der Bereitstellung saga-basierter Microservices in Kubernetes kann die Implementierung eines Service Mesh die Observability, Sicherheit und Traffic-Verwaltung erheblich verbessern. Implementing Service Mesh with Istio and Linkerd beschreibt, wie Service Meshes verteilte Transaktionsmuster ergänzen, indem sie Cross-Cutting Concerns wie verteiltes Tracing und Circuit Breaking bereitstellen.

Wann man das Saga-Muster verwenden sollte

Verwenden Sie das Saga-Muster, wenn:

  • ✅ Operationen mehrere Microservices umfassen
  • ✅ Lang laufende Geschäftsprozesse
  • ✅ Finale Konsistenz akzeptabel ist
  • ✅ Sie verteilte Sperren vermeiden müssen
  • ✅ Dienste unabhängige Datenbanken haben

Vermeiden Sie es, wenn:

  • ❌ Starke Konsistenz erforderlich ist
  • ❌ Operationen einfach und schnell sind
  • ❌ Alle Dienste dieselbe Datenbank teilen
  • ❌ Die Kompensationslogik zu komplex ist

Fazit

Das Saga-Muster ist entscheidend für die Verwaltung verteilter Transaktionen in Microservice-Architekturen. Obwohl es Komplexität einführt, bietet es eine praktische Lösung zur Aufrechterhaltung der Datenkonsistenz über Dienstgrenzen hinweg. Wählen Sie Orchestrierung für bessere Kontrolle und Übersicht oder Choreografie für Skalierbarkeit und lose Kopplung. Stellen Sie immer sicher, dass Operationen idempotent sind, implementieren Sie eine ordnungsgemäße Kompensationslogik und halten Sie eine umfassende Observability aufrecht.

Der Schlüssel zur erfolgreichen Saga-Implementierung liegt im Verständnis Ihrer Konsistenzanforderungen, der sorgfältigen Gestaltung der Kompensationslogik und der Wahl des richtigen Ansatzes für Ihren Anwendungsfall. Mit einer ordnungsgemäßen Implementierung ermöglicht Saga Ihnen, resiliente, skalierbare Microservices zu bauen, die die Datenintegrität über verteilte Systeme hinweg aufrechterhalten.

Abonnieren

Neue Beiträge zu Systemen, Infrastruktur und KI-Engineering.