Saga-Muster in 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 von lokalen Transaktionen mit Ausgleichsaktionen aufteilt.

Statt auf verteilte Sperren zu setzen, die Operationen über Dienste blockieren können, ermöglicht Saga eine schlussendliche Konsistenz durch eine Abfolge von rückgängig machbaren Schritten, was es ideal für langlaufende Geschäftsprozesse macht.

In Mikrodienstarchitekturen ist die Aufrechterhaltung der Datenkonsistenz über Dienste hinweg eine der größten Herausforderungen. Traditionelle ACID-Transaktionen funktionieren nicht, wenn Operationen mehrere Dienste mit unabhängigen Datenbanken umspannen, was Entwickler nach alternativen Ansätzen zur Sicherstellung der Datenintegrität suchen lässt.

Diese Anleitung 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 eine hilfreiche Übersicht.

Bauarbeiter mit verteilten Transaktionen Dieses schöne Bild wurde von AI-Modell Flux 1 dev erzeugt.

Verständnis des Saga-Musters

Das Saga-Muster wurde ursprünglich 1987 von Hector Garcia-Molina und Kenneth Salem beschrieben. Im Kontext von Mikrodiensten handelt es sich um eine Abfolge von lokalen Transaktionen, bei denen jede Transaktion Daten innerhalb eines einzelnen Dienstes aktualisiert. Falls ein Schritt fehlschlägt, werden Ausgleichstransaktionen ausgeführt, um die Auswirkungen der vorherigen Schritte rückgängig zu machen.

Im Gegensatz zu traditionellen verteilten Transaktionen, die das Zwei-Phasen-Commit (2PC) verwenden, hält Saga keine Sperren über Dienste hinweg, was es für langlaufende Geschäftsprozesse geeignet macht. Der Kompromiss ist eine schlussendliche Konsistenz anstelle einer starken Konsistenz.

Wichtige Merkmale

  • Keine verteilten Sperren: Jeder Dienst verwaltet seine eigene lokale Transaktion
  • Ausgleichsaktionen: Jede Operation hat einen entsprechenden Rückgängig-Mechanismus
  • Schlussendliche Konsistenz: Das System erreicht irgendwann einen konsistenten Zustand
  • Langlaufend: Geeignet für Prozesse, die Sekunden, Minuten oder sogar Stunden dauern

Implementierungsansätze für Sagas

Es gibt zwei Hauptansä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:

  • Aufruf der Dienste in der richtigen Reihenfolge
  • Behandlung von Fehlern und Auslösung von Ausgleichsaktionen
  • Beibehaltung des Zustands der Saga
  • Koordination von Wiederholungen und Timeouts

Vorteile:

  • Zentralisierte Kontrolle und Sichtbarkeit
  • Einfacher zu verstehen und zu debuggen
  • Bessere Fehlerbehandlung und Wiederherstellung
  • Einfachere Tests des gesamten Ablaufs

Nachteile:

  • Einziger Ausfallpunkt (obwohl dies abgemildert werden kann)
  • Zusätzlicher Dienst, der gewartet werden muss
  • Kann für komplexe Abläufe zu einer Flaschenhals 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: Lagerbestand reservieren
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // Ausgleich
        return err
    }

    // Schritt 3: Zahlung bearbeiten
    paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
    if err != nil {
        o.inventoryService.Release(order.Items) // Ausgleich
        o.orderService.Cancel(orderID)          // Ausgleich
        return err
    }

    // Schritt 4: Versand erstellen
    if err := o.shippingService.CreateShipment(orderID); err != nil {
        o.paymentService.Refund(paymentID)      // Ausgleich
        o.inventoryService.Release(order.Items) // Ausgleich
        o.orderService.Cancel(orderID)          // Ausgleich
        return err
    }

    return nil
}

Choreografie-Muster

Bei der Choreografie 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 eine skalierbare Infrastruktur für die Ereignisverteilung über Mikrodienste hinweg bieten. Für eine umfassende Anleitung zur Implementierung ereignisgesteuerter Mikrodienste mit Kinesis siehe Building Event-Driven Microservices with AWS Kinesis.

Vorteile:

  • Dezentralisiert und skalierbar
  • Kein einziger Ausfallpunkt
  • Dienste bleiben locker gekoppelt
  • Natürliche Passform für ereignisgesteuerte Architekturen

Nachteile:

  • Schwerer zu verstehen, wie der gesamte Ablauf funktioniert
  • Schwierig 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) // Ausgleich
}

// 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 {
    // Ausgleich: Zahlung zurückerstatten
    return s.client.Refund(event.PaymentID)
}

Ausgleichsstrategien

Der Ausgleich ist das Herzstück des Saga-Musters. Jede Operation muss eine entsprechende Ausgleichsaktion haben, die ihre Auswirkungen rückgängig machen kann.

Arten von Ausgleichsaktionen

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

    • Beispiel: Freigabe des reservierten Lagerbestands, Rückerstattung von Zahlungen
  2. Ausgleichsaktionen: Unterschiedliche Operationen, die den umgekehrten Effekt erzielen

    • Beispiel: Stornierung einer Bestellung statt Löschung
  3. Pessimistische Ausgleichsaktionen: Ressourcen vorab zuweisen, die freigegeben werden können

    • Beispiel: Lagerbestand reservieren, bevor die Zahlung belastet wird
  4. Optimistische Ausgleichsaktionen: Operationen ausführen und bei Bedarf ausgleichen

    • Beispiel: Zahlung zuerst belasten, rückerstatten, wenn Lagerbestand nicht verfügbar ist

Idempotenz-Anforderungen

Alle Operationen und Ausgleichsaktionen müssen idempotent sein. Dies stellt sicher, dass das erneute Ausführen einer fehlgeschlagenen Operation keine doppelten Auswirkungen verursacht.

func (s *PaymentService) Refund(paymentID string) error {
    // Überprüfen, ob bereits rückerstattet
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }

    if payment.Status == "refunded" {
        return nil // Bereits rückerstattet, idempotent
    }

    // Rückerstattung verarbeiten
    return s.processRefund(paymentID)
}

Beste Praktiken

1. Saga-Zustandsverwaltung

Verwalten Sie den Zustand jeder Saga-Instanz, um den Fortschritt zu verfolgen und die Wiederherstellung zu ermöglichen. Bei der Persistierung des Saga-Zustands in einer Datenbank ist die Wahl des richtigen ORMs entscheidend für die Leistung und Wartbarkeit. Für PostgreSQL-basierte Implementierungen sollten Sie den Vergleich in Vergleich von Go-ORMs für PostgreSQL: GORM vs Ent vs Bun vs sqlc berücksichtigen, um die beste Lösung für Ihre Saga-Zustands-Speicheranforderungen 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 lange 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("Kompensation fehlgeschlagen: %w", err)
        }
        return fmt.Errorf("Schritt %s ist nach %v abgelaufen", step.Name(), o.timeout)
    }
}

3. Wiederholungslogik

Implementieren Sie exponentielles 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 fehlgeschlagen nach %d Versuchen", maxRetries)
}

4. Event Sourcing für Saga-Zustand

Verwenden Sie Event Sourcing, um eine vollständige Audit-Trail zu führen. Bei der Implementierung von Event-Stores und Wiedergabemechanismen können Go-Generics helfen, typsicheren, wiederverwendbaren Event-Handling-Code zu erstellen. Für fortgeschrittene Muster mit Generics in Go siehe Go Generics: Anwendungsfälle und Muster.

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("Fehler beim Serialisieren des Payloads: %w", err)
    }

    version, err := s.store.GetNextVersion(sagaID)
    if err != nil {
        return fmt.Errorf("Fehler beim Abrufen der 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("Fehler beim Abrufen der Ereignisse: %w", err)
    }

    saga := NewSaga()
    for _, event := range events {
        if err := saga.Apply(event); err != nil {
            return nil, fmt.Errorf("Fehler beim Anwenden des Ereignisses: %w", err)
        }
    }

    return saga, nil
}

5. Überwachung und Beobachtbarkeit

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
}

Häufige Muster und Anti-Muster

Zu befolgende Muster

  • Saga-Koordinator-Muster: Verwenden Sie einen dedizierten Dienst für die Orchestrierung
  • Outbox-Muster: Stellen Sie zuverlässiges Event-Publishing sicher
  • Idempotenz-Schlüssel: Verwenden Sie eindeutige Schlüssel für alle Operationen
  • Saga-Zustandsmaschine: Modellieren Sie die Saga als Zustandsmaschine

Zu vermeidende Anti-Muster

  • Synchrones Kompensieren: Warten Sie nicht auf den Abschluss der Kompensation
  • Verschachtelte Sagas: Vermeiden Sie Sagas, die andere Sagas aufrufen (verwenden Sie stattdessen Sub-Sagas)
  • Gemeinsamer Zustand: Teilen Sie keinen Zustand zwischen Saga-Schritten
  • Lange laufende Schritte: Brechen Sie Schritte herunter, 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 die Orchestrierung von Mikroservices
  • 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 Überwachung benötigen, bietet Erstellung von CLI-Anwendungen in Go mit Cobra & Viper hervorragende Muster zur Erstellung von Command-Line-Tools zur Interaktion mit Saga-Orchestriern.

Bei der Bereitstellung von saga-basierten Mikroservices in Kubernetes kann die Implementierung eines Service Mesh die Beobachtbarkeit, Sicherheit und Verkehrsverwaltung erheblich verbessern. Implementierung eines Service Mesh mit Istio und Linkerd behandelt, wie Service Meshes verteilte Transaktionsmuster durch die Bereitstellung von Querschnittsfunktionen wie verteiltem Tracing und Circuit Breaking ergänzen.

Wann das Saga-Muster verwenden

Verwenden Sie das Saga-Muster, wenn:

  • ✅ Operationen mehrere Mikroservices umspannen
  • ✅ Lange laufende Geschäftsprozesse
  • ✅ Eventuelle Konsistenz ist akzeptabel
  • ✅ Verteilte Sperren vermieden werden 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 essenziell für die Verwaltung verteilter Transaktionen in Mikroservices-Architekturen. Obwohl es Komplexität einfügt, bietet es eine praktische Lösung zur Aufrechterhaltung der Datenkonsistenz über Service-Grenzen hinweg. Wählen Sie Orchestrierung für bessere Kontrolle und Sichtbarkeit oder Choreografie für Skalierbarkeit und lockere Kopplung. Stellen Sie sicher, dass Operationen idempotent sind, implementieren Sie eine angemessene Kompensationslogik und halten Sie eine umfassende Beobachtbarkeit aufrecht.

Der Schlüssel zu einer erfolgreichen Saga-Implementierung ist das Verständnis Ihrer Konsistenzanforderungen, die sorgfältige Gestaltung der Kompensationslogik und die Wahl des richtigen Ansatzes für Ihren Anwendungsfall. Mit einer ordnungsgemäßen Implementierung ermöglicht Saga Ihnen den Aufbau widerstandsfähiger, skalierbarer Mikroservices, die die Datenintegrität in verteilten Systemen aufrechterhalten.