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, stelt Saga uiteindelijke consistentie mogelijk door middel van een sequentie van omkeerbare stappen. Dit maakt het ideaal voor langlopende bedrijfsprocessen.

In microservices-architecturen is het behouden van dataconsistentie tussen services een van de grootste uitdagingen. Traditionele ACID-transacties werken niet wanneer operaties meerdere services met onafhankelijke databases omvatten, waardoor ontwikkelaars alternatieve benaderingen moeten zoeken om data-integriteit te waarborgen.

Deze handleiding demonstreert de implementatie van het Saga-patroon in Go met praktische voorbeelden, zowel voor orchestration (orchestratie) als choreography (choreografie). 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 is oorspronkelijk beschreven door Hector Garcia-Molina en Kenneth Salem in 1987. In de context van microservices is het een sequentie van lokale transacties waarbij elke transactie data binnen een enkele service bijwerkt. Als een stap faalt, worden compenserende transacties uitgevoerd om de effecten van voorgaande stappen ongedaan te maken.

In tegenstelling tot traditionele gedistribueerde transacties die Two-Phase Commit (2PC) gebruiken, houdt Saga geen vergrendelingen vast over services heen, 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 corresponderende 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: orchestratie en choreografie.

Orchestratie-patroon

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

  • Het aanroepen van services in de juiste volgorde
  • Het afhandelen van fouten en het triggeren van compensaties
  • Het bijhouden van de status van de saga
  • Het coördineren van opnieuw pogingen en time-outs

Voordelen:

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

Nadelen:

  • Enig punt van falen (hoewel dit kan worden gemitigeerd)
  • Aanvullende service om te 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: Order aanmaken
    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 aanmaken
    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
}

Choreografie-patroon

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

Voordelen:

  • Gedeconcentreerd en schaalbaar
  • Geen enkel punt van falen
  • Services blijven losjes gekoppeld
  • Natuurlijke fit voor gebeurtenisgestuurde architecturen

Nadelen:

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

Voorbeeld met Gebeurtenisgestuurde 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
}

// Opmerking: s.repo.Save gevolgd door s.eventBus.Publish is een dual-write.
// Vervang dit in productie door het transactionele outbox-patroon zodat de
// gebeurtenis atomisch wordt geschreven met de order-regel en door een relay wordt gepubliceerd.

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 de kern van het Saga-patroon. Elke operatie moet een corresponderende compensatie hebben die de effecten kan omkeren.

Types van 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 order annuleren in plaats van deze te verwijderen
  3. Pessimistische compensatie: Resources vooraf toewijzen die vrijgegeven kunnen worden

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

    • Voorbeeld: Eerst betalen, 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. Even belangrijk is het ervoor zorgen dat elke saga-deelnemer zijn gebeurtenissen betrouwbaar publiceert na een lokale commit — het transactionele outbox-patroon is de standaard manier om die kloof tussen een database-schrijfactie en een broker-publicatie te dichten.

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

Best Practices

1. Saga-statusbeheer

Behoud de status van elke saga-instantie om voortgang te volgen en herstel mogelijk te maken. Bij het persistent maken van saga-status naar een database is het kiezen van de juiste ORM cruciaal voor prestaties en onderhoudbaarheid. Voor PostgreSQL-implementaties, overweeg de vergelijking in Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc om de beste optie te selecteren voor uw saga-statusopslagbehoeften:

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 blijven 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. Logica voor opnieuw proberen

Implementeer exponentiële back-off voor tijdelijke 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 volledige audit trail bij te houden. Bij het implementeren van event stores en replay-mechanismen kunnen Go-generics helpen bij het creëren van typeveilige, herbruikbare code voor gebeurtenisafhandeling. Zie voor geavanceerde patronen met generics in Go 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 Observabiliteit

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 Coordinator Pattern: Gebruik een speciale service voor orchestratie
  • Outbox Pattern: Zorg voor betrouwbare gebeurtenispublicatie
  • Idempotency Keys: Gebruik unieke keys voor alle operaties
  • Saga State Machine: Modelleer saga als een statemachine

Anti-patronen om te vermijden

  • Synchrone compensatie: Wacht niet tot compensatie is voltooid
  • Geneste sagas: Vermijd sagas die andere sagas aanroepen (gebruik sub-sagas in plaats daarvan)
  • Gedeelde status: Deel geen status tussen sagastappen
  • Langlopende stappen: Splijt stappen op die te lang duren

Tools en Frameworks

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

  • Temporal: Workflow-orchestratieplatform met ingebouwde Saga-ondersteuning
  • Zeebe: Workflow-engine voor microservices-orchestratie
  • Eventuate Tram: Saga-framework voor Spring Boot
  • AWS Step Functions: Serverless workflow-orchestratie
  • Apache Camel: Integratieframework 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 op saga-gebaseerde microservices in Kubernetes kan een service mesh de observabiliteit, beveiliging en trafficbeheer aanzienlijk verbeteren. Implementing Service Mesh with Istio and Linkerd behandelt hoe service meshes gedistribueerde transactiepatronen aanvullen door cross-cutting concerns te bieden zoals gedistribueerde tracing en circuit breaking.

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 microservices-architecturen. Hoewel het complexiteit introduceert, biedt het een praktische oplossing voor het behouden van dataconsistentie over servic grenzen heen. Kies voor orchestratie voor betere controle en zichtbaarheid, of voor choreografie voor schaalbaarheid en losse koppeling. Zorg er altijd voor dat operaties idempotent zijn, implementeer juiste compensatielogica en behoud uitgebreide observabiliteit.

De sleutel tot succesvolle Saga-implementatie is het begrijpen van uw consistentievereisten, het zorgvuldig ontwerpen van compensatielogica en het kiezen van de juiste benadering voor uw gebruikscase. Met de 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.