Saga-Muster 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 Operationen über Dienste hinweg blockieren können, ermöglicht Saga eventual consistency (letztendliche Konsistenz) durch eine Sequenz reversibler Schritte, was es ideal für lang laufende Geschäftsprozesse macht.

In Microservices-Architekturen ist die Aufrechterhaltung der Datenkonsistenz über Dienste hinweg eines der schwierigsten Probleme. Traditionelle ACID-Transaktionen funktionieren nicht, wenn Operationen 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 Choreographie-Ansätze abdecken. Wenn Sie eine schnelle Referenz für Go-Grundlagen benötigen, bietet das Go-Cheat-Sheet einen hilfreichen Überblick.

Bauarbeiter mit verteilten Transaktionen Dieses schöne Bild wurde vom KI-Modell Flux 1 dev generiert.

Das Saga-Muster verstehen

Das Saga-Muster wurde ursprünglich von Hector Garcia-Molina und Kenneth Salem im Jahr 1987 beschrieben. Im Kontext von Microservices ist es eine Sequenz lokaler Transaktionen, bei der jede Transaktion Daten innerhalb eines einzelnen Dienstes aktualisiert. Falls 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 Two-Phase-Commit (2PC) verwenden, hält Saga keine Sperren über Dienste hinweg, was es für lang laufende Geschäftsprozesse geeignet macht. Der Kompromiss ist eventual consistency (letztendliche Konsistenz) statt starker Konsistenz.

Wichtige Charakteristiken

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

Ansätze zur Saga-Implementierung

Es gibt zwei Hauptansätze zur Implementierung des Saga-Musters: Orchestrierung und Choreographie.

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 Status der Saga
  • Koordinieren von Wiederholungsversuchen und Timeouts

Vorteile:

  • Zentralisierte Steuerung und Übersichtlichkeit
  • Einfacher zu verstehen und zu debuggen
  • Bessere Fehlerbehandlung und Wiederherstellung
  • Einfachere Tests des gesamten Flusses

Nachteile:

  • Single Point of Failure (obwohl dies gemildert werden kann)
  • Zusätzlicher Dienst, der gewartet werden muss
  • Kann zu einer Engpassstelle bei komplexen Flüssen 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
}

Choreographiemuster

Bei der Choreographie gibt es keinen zentralen Koordinator. Jeder Dienst weiß, was zu tun ist, und kommuniziert über Ereignisse. Dienste hören auf Ereignisse 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 Ereignisverteilung über Microservices hinweg bieten. Für einen umfassenden Leitfaden zur Implementierung ereignisgesteuerter Microservices mit Kinesis siehe 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
}

// Hinweis: s.repo.Save gefolgt von s.eventBus.Publish ist ein Dual-Write.
// In der Produktion sollte dies durch das transactional outbox pattern ersetzt werden, damit das
// Ereignis atomar mit der Bestellzeile geschrieben und von einem Relay veröffentlicht wird.

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 rückgängig machen kann.

Arten der Kompensation

  1. Reversible 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: Ressourcen vorab zuweisen, die freigegeben werden können

    • Beispiel: Inventar reservieren, bevor die Zahlung belastet wird
  4. Optimistische Kompensation: Operationen ausführen und kompensieren, falls nötig

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

Anforderungen an Idempotenz

Alle Operationen und Kompensationen müssen idempotent sein. Dies stellt sicher, dass das Wiederholen einer fehlgeschlagenen Operation keine doppelten Auswirkungen verursacht. Ebenso wichtig ist die Sicherstellung, dass jeder Saga-Teilnehmer seine Ereignisse nach einer lokalen Commit-Operation zuverlässig veröffentlicht — das transactional outbox pattern ist der Standardweg, um diese Lücke zwischen einem Datenbank-Write und einem Broker-Publish zu schließen.

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-Statusverwaltung

Verwalten Sie den Status jeder Saga-Instanz, um den Fortschritt zu verfolgen und die Wiederherstellung zu ermöglichen. Bei der Persistierung von Saga-Status in einer Datenbank ist die Wahl des richtigen ORMs entscheidend für Leistung und Wartbarkeit. Für PostgreSQL-basierte Implementierungen sollten Sie den Vergleich in Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc berücksichtigen, um die beste Lösung für Ihre Saga-Status-Speicherbedü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-Verarbeitung

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 Saga-Status

Verwenden Sie Event Sourcing, um einen vollständigen Audit-Trail aufrechtzuerhalten. Bei der Implementierung von Event Stores und Replay-Mechanismen können Go-Generics dabei helfen, typsicheren und wiederverwendbaren Code für die Ereignisbehandlung zu erstellen. Für fortgeschrittene Muster mit Generics in Go siehe 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 gestartet")
    
    // ... Saga-Ausführung
    
    return nil
}

Gängige 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 Ereignisverö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 Status zwischen Saga-Schritten
  • Long-Running Steps: Zerschneiden Sie Schritte, die zu lange dauern

Tools und Frameworks

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

  • Temporal: Workflow-Orchestrierungsplattform mit integrierter Saga-Unterstützung
  • Zeebe: Workflow-Engine für Microservices-Orchestrierung
  • Eventuate Tram: Saga-Framework für Spring Boot
  • AWS Step Functions: Serverless-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 für die Erstellung von Command-Line-Tools zur Interaktion mit Saga-Orchestratoren.

Bei der Bereitstellung von saga-basierten Microservices in Kubernetes kann die Implementierung eines Service Mesh die Observability, Sicherheit und Verkehrsverwaltung erheblich verbessern. Implementing Service Mesh with Istio and Linkerd beschreibt, wie Service Meshes verteilte Transaktionsmuster ergänzen, indem sie querschnittliche Belange wie verteiltes Tracing und Circuit Breaking bereitstellen.

Wann das Saga-Muster verwendet werden sollte

Verwenden Sie das Saga-Muster, wenn:

  • ✅ Operationen mehrere Microservices umfassen
  • ✅ Lang laufende Geschäftsprozesse
  • ✅ Letztendliche 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 Microservices-Architekturen. Obwohl es Komplexität einführt, bietet es eine praktische Lösung für die Aufrechterhaltung der Datenkonsistenz über Dienstgrenzen hinweg. Wählen Sie Orchestrierung für bessere Kontrolle und Übersichtlichkeit oder Choreographie 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 zu einer erfolgreichen Saga-Implementierung liegt im Verständnis Ihrer Konsistenzanforderungen, der sorgfältigen Ausarbeitung der Kompensationslogik und der Wahl des richtigen Ansatzes für Ihren Anwendungsfall. Mit einer ordnungsgemäßen Implementierung ermöglicht Saga den Aufbau widerstandsfähiger, skalierbarer Microservices, die die Datenintegrität in verteilten Systemen aufrechterhalten.

Abonnieren

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