Het Saga-patroon bij gedistribueerde transacties - Met voorbeelden in Go

Transacties in Microservices met het Saga-patroon

Inhoud

Het Saga-patroon biedt een elegante oplossing door gedistribueerde transacties op te splitsen in een reeks lokale transacties met compenserende acties.

In plaats van te vertrouwen op gedistribueerde vergrendelingen die operaties tussen services kunnen blokkeren, maakt Saga uiteindelijke consistentie mogelijk via een reeks omkeerbare stappen. Dit maakt het ideaal voor langlopende bedrijfsprocessen.

In microservice-architecturen is het handhaven van dataconsistentie tussen services een van de meest uitdagende problemen. Traditionele ACID-transacties werken niet wanneer operaties meerdere services met onafhankelijke databases overspannen, waardoor ontwikkelaars op zoek moeten naar alternatieve benaderingen om data-integriteit te waarborgen.

Deze gids demonstreert de implementatie van het Saga-patroon in Go met praktische voorbeelden die zowel orkestratie- als choreografiebenaderingen dekken. Als u een snelle referentie nodig heeft voor Go-fundamenten, biedt het Go Cheat Sheet een nuttig overzicht.

construction worker with distributed transactions Deze mooie afbeelding is gegenereerd door AI model Flux 1 dev.

Het Saga-patroon begrijpen

Het Saga-patroon werd oorspronkelijk beschreven door Hector Garcia-Molina en Kenneth Salem in 1987. In de context van microservices is het een reeks lokale transacties waarbij elke transactie data bijwerkt binnen een enkele service. Als een stap faalt, worden compenserende transacties uitgevoerd om de effecten van voorgaande stappen ongedaan te maken.

In tegenstelling tot traditionele gedistribueerde transacties die twee-fasen commit (2PC) gebruiken, houdt Saga geen vergrendelingen vast tussen services, waardoor het geschikt is voor langlopende bedrijfsprocessen. De afweging is uiteindelijke consistentie in plaats van sterke consistentie.

Belangrijkste kenmerken

  • Geen gedistribueerde vergrendelingen: Elke service beheert zijn eigen lokale transactie
  • Compenserende acties: Elke operatie heeft een bijbehorende rollback-mechanisme
  • Uiteindelijke consistentie: Het systeem bereikt uiteindelijk een consistente staat
  • Langlopend: Geschikt voor processen die seconden, minuten of zelfs uren duren

Implementatiebenaderingen voor Saga

Er zijn twee primaire benaderingen voor het implementeren van het Saga-patroon: orkestratie en choreografie.

Orkestratiepatroon

Bij orkestratie beheert een centrale coördinator (orchestrator) de volledige transactiestroom. De orchestrator is verantwoordelijk voor:

  • Services aanroepen in de juiste volgorde
  • Het afhandelen van fouten en het activeren van compensaties
  • Het bijhouden van de status van de saga
  • Het coördineren van retries en time-outs

Voordelen:

  • Gecentraliseerde controle en zichtbaarheid
  • Easier te begrijpen en te debuggen
  • Betere foutafhandeling en herstel
  • Eenvoudiger testen van de algehele stroom

Nadelen:

  • Enig falingspunt (hoewel dit kan worden gemitigeerd)
  • Aanvullende service die moet worden onderhouden
  • Kan een bottleneck worden voor complexe stromen

Voorbeeld in Go:

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

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    sagaID := generateSagaID()
    
    // Stap 1: Bestelling maken
    orderID, err := o.orderService.Create(order)
    if err != nil {
        return err
    }
    
    // Stap 2: Voorraad reserveren
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // Compenseren
        return err
    }
    
    // Stap 3: Betaling verwerken
    paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
    if err != nil {
        o.inventoryService.Release(order.Items) // Compenseren
        o.orderService.Cancel(orderID)          // Compenseren
        return err
    }
    
    // Stap 4: Verzending maken
    if err := o.shippingService.CreateShipment(orderID); err != nil {
        o.paymentService.Refund(paymentID)      // Compenseren
        o.inventoryService.Release(order.Items) // Compenseren
        o.orderService.Cancel(orderID)          // Compenseren
        return err
    }
    
    return nil
}

Choreografiepatroon

Bij choreografie is er geen centrale coördinator. Elke service weet wat het moet doen en communiceert via gebeurtenissen. Services luisteren naar gebeurtenissen en reageren dienovereenkomstig. Deze gebeurtenisgedreven aanpak is bijzonder krachtig wanneer gecombineerd met message streaming-platforms zoals AWS Kinesis, die schaalbare infrastructuur bieden voor gebeurtenisverdeling over microservices. Voor een uitgebreide gids over het implementeren van gebeurtenisgedreven microservices met Kinesis, zie Building Event-Driven Microservices with AWS Kinesis.

Voordelen:

  • Gedecentraliseerd en schaalbaar
  • Geen enkel falingspunt
  • Services blijven losjes gekoppeld
  • Natuurlijke fit voor gebeurtenisgedreven architecturen

Nadelen:

  • Moeilijker om de algehele stroom te begrijpen
  • Moeilijk te debuggen en traceren
  • Complexe foutafhandeling
  • Risico op cyclische afhankelijkheden

Voorbeeld met Gebeurtenisgedreven Architectuur:

// 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) // Compensatie
}

// 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 {
    // Compensatie: betaling terugstorten
    return s.client.Refund(event.PaymentID)
}

Compensatiestrategieën

Compensatie is het hart van het Saga-patroon. Elke operatie moet een bijbehorende compensatie hebben die de effecten kan omkeren.

Typen compensatie

  1. Omkeerbare operaties: Operaties die direct ongedaan kunnen worden gemaakt

    • Voorbeeld: Gereserveerde voorraad vrijgeven, betalingen terugstorten
  2. Compenserende acties: Andere operaties die het omgekeerde effect bereiken

    • Voorbeeld: Een bestelling annuleren in plaats van deze te verwijderen
  3. Pessimistische compensatie: Resources vooraf toewijzen die kunnen worden vrijgegeven

    • Voorbeeld: Voorraad reserveren voordat de betaling wordt afgeschreven
  4. Optimistische compensatie: Operaties uitvoeren en compenseren indien nodig

    • Voorbeeld: Eerst betaling afschrijven, terugstorten als voorraad niet beschikbaar is

Vereisten voor idempotentie

Alle operaties en compensaties moeten idempotent zijn. Dit zorgt ervoor dat het opnieuw proberen van een mislukte operatie geen dubbele effecten veroorzaakt.

func (s *PaymentService) Refund(paymentID string) error {
    // Controleren of al terugbetaald
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }
    
    if payment.Status == "refunded" {
        return nil // Al terugbetaald, idempotent
    }
    
    // Terugbetaling verwerken
    return s.processRefund(paymentID)
}

Best Practices

1. Saga-statusbeheer

Behoud de status van elk saga-instantie om voortgang te volgen en herstel mogelijk te maken. Bij het aanhouden van saga-status in een database is het kiezen van de juiste ORM cruciaal voor prestaties en onderhoudbaarheid. Voor PostgreSQL-gebaseerde implementaties, overweeg de vergelijking in Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc om de beste fit te selecteren voor uw behoeften op het gebied van saga-statusopslag:

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. Time-outafhandeling

Implementeer time-outs voor elke stap om te voorkomen dat sagas onbepaald hangen:

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():
        // Time-out opgetreden, compenseren
        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-logica

Implementeer exponentiële back-off voor voorbijgaande fouten:

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 voor Saga-status

Gebruik event sourcing om een volledig audittrail bij te houden. Bij het implementeren van event stores en replay-mechanismen kunnen Go-generics helpen bij het maken van typeveilige, herbruikbare code voor gebeurtenisafhandeling. Voor geavanceerde patronen met generics in Go, zie 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 en Observability

Implementeer uitgebreide logging en 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
}

Veelvoorkomende patronen en anti-patronen

Patronen om te volgen

  • Saga Coördinator Patroon: Gebruik een dedicated service voor orkestratie
  • Outbox Patroon: Zorg voor betrouwbare gebeurtenispublicatie
  • Idempotentie Sleutels: Gebruik unieke sleutels voor alle operaties
  • Saga Statemachine: Modelleer saga als een statemachine

Anti-patronen om te vermijden

  • Synchrone Compensatie: Wacht niet op de voltooiing van compensatie
  • Geneste Sagas: Vermijd sagas die andere sagas aanroepen (gebruik sub-sagas in plaats daarvan)
  • Gedeelde Status: Deel geen status tussen saga-stappen
  • Langlopende Stappen: Breek stappen af die te lang duren

Tools en Frameworks

Verschillende frameworks kunnen helpen bij het implementeren van Saga-patronen:

  • Temporal: Workflow-orkestratieplatform met ingebouwde Saga-ondersteuning
  • Zeebe: Workflow-engine voor microservice-orkestratie
  • Eventuate Tram: Saga-framework voor Spring Boot
  • AWS Step Functions: Serverless workflow-orkestratie
  • Apache Camel: Integratie-framework met Saga-ondersteuning

Voor orchestrator-services die CLI-interfaces nodig hebben voor beheer en monitoring, biedt Building CLI Applications in Go with Cobra & Viper uitstekende patronen voor het maken van commandoregeltools om te interageren met saga-orchestrators.

Bij het implementeren van service meshes in Kubernetes voor saga-gebaseerde microservices kan dit de observability, beveiliging en traffic management aanzienlijk verbeteren. Implementing Service Mesh with Istio and Linkerd behandelt hoe service meshes gedistribueerde transactiepatronen aanvullen door cross-cutting concerns zoals distributed tracing en circuit breaking te bieden.

Wanneer het Saga-patroon te gebruiken

Gebruik het Saga-patroon wanneer:

  • ✅ Operaties meerdere microservices overspannen
  • ✅ Langlopende bedrijfsprocessen
  • ✅ Uiteindelijke consistentie acceptabel is
  • ✅ U gedistribueerde vergrendelingen wilt vermijden
  • ✅ Services onafhankelijke databases hebben

Vermijd wanneer:

  • ❌ Sterke consistentie vereist is
  • ❌ Operaties eenvoudig en snel zijn
  • ❌ Alle services dezelfde database delen
  • ❌ Compensatielogica te complex is

Conclusie

Het Saga-patroon is essentieel voor het beheren van gedistribueerde transacties in microservice-architecturen. Hoewel het complexiteit introduceert, biedt het een praktische oplossing voor het handhaven van dataconsistentie over servicegrenzen heen. Kies voor orkestratie voor betere controle en zichtbaarheid, of voor choreografie voor schaalbaarheid en losse koppeling. Zorg er altijd voor dat operaties idempotent zijn, implementeer passende compensatielogica en handhaaf uitgebreide observability.

De sleutel tot een succesvolle Saga-implementatie is het begrijpen van uw consistentievereisten, het zorgvuldig ontwerpen van compensatielogica en het kiezen van de juiste aanpak voor uw use case. Met een juiste implementatie stelt Saga u in staat om veerkrachtige, schaalbare microservices te bouwen die data-integriteit behouden over gedistribueerde systemen.

Abonneren

Ontvang nieuwe berichten over systemen, infrastructuur en AI-engineering.