Modello Saga nei Trasferimenti di dati Distribuiti - Esempi in Go

Transazioni nei Microservizi con il pattern Saga

Indice

Il pattern Saga
fornisce una soluzione elegante suddividendo le transazioni distribuite in una serie di transazioni locali con azioni di compensazione.

Invece di affidarsi a blocchi distribuiti che possono bloccare le operazioni tra i servizi, il pattern Saga consente una coerenza eventuale attraverso una sequenza di passaggi reversibili, rendendolo ideale per processi aziendali di lunga durata.

Nelle architetture a microservizi, mantenere la coerenza dei dati tra i servizi è uno dei problemi più complessi. Le transazioni tradizionali ACID non funzionano quando le operazioni si estendono a diversi servizi con database indipendenti, lasciando gli sviluppatori alla ricerca di approcci alternativi per garantire l’integrità dei dati.

Questo documento dimostra l’implementazione del pattern Saga in Go con esempi pratici che coprono entrambi gli approcci di orchestrazione e di coreografia. Se hai bisogno di un riferimento rapido per i fondamenti di Go, il Go Cheat Sheet fornisce un utile riepilogo.

lavoratore edile con transazioni distribuite Questa immagine gradevole è generata dal modello AI Flux 1 dev.

Comprendere il Pattern Saga

Il pattern Saga è stato originariamente descritto da Hector Garcia-Molina e Kenneth Salem nel 1987. Nel contesto dei microservizi, è una sequenza di transazioni locali in cui ogni transazione aggiorna i dati all’interno di un singolo servizio. Se qualsiasi passo fallisce, vengono eseguite transazioni di compensazione per annullare gli effetti dei passaggi precedenti.

A differenza delle tradizionali transazioni distribuite che utilizzano il commit a due fasi (2PC), il pattern Saga non mantiene blocchi tra i servizi, rendendolo adatto ai processi aziendali di lunga durata. Il compromesso è la coerenza eventuale invece di quella forte.

Caratteristiche Principali

  • Nessun Blocco Distribuito: Ogni servizio gestisce la propria transazione locale
  • Azioni di Compensazione: Ogni operazione ha un meccanismo di rollback corrispondente
  • Coerenza Eventuale: Il sistema raggiunge eventualmente uno stato coerente
  • Lunga Durata: Adatto a processi che durano secondi, minuti o anche ore

Approcci all’Implementazione del Pattern Saga

Esistono due approcci principali per implementare il pattern Saga: l’orchestrazione e la coreografia.

Pattern di Orchestrazione

Nell’orchestrazione, un coordinatore centrale (orchestratore) gestisce l’intero flusso delle transazioni. L’orchestratore è responsabile di:

  • Invocare i servizi nell’ordine corretto
  • Gestire gli errori e attivare le compensazioni
  • Mantenere lo stato del saga
  • Coordinare i retry e i timeout

Vantaggi:

  • Controllo e visibilità centralizzati
  • Più facile da comprendere e debuggare
  • Migliore gestione degli errori e del recupero
  • Test più semplice del flusso complessivo

Svantaggi:

  • Punto unico di fallimento (sebbene possa essere mitigato)
  • Servizio aggiuntivo da mantenere
  • Può diventare un collo di bottiglia per flussi complessi

Esempio in Go:

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

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    sagaID := generateSagaID()
    
    // Step 1: Create order
    orderID, err := o.orderService.Create(order)
    if err != nil {
        return err
    }
    
    // Step 2: Reserve inventory
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // Compensate
        return err
    }
    
    // Step 3: Process payment
    paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
    if err != nil {
        o.inventoryService.Release(order.Items) // Compensate
        o.orderService.Cancel(orderID)          // Compensate
        return err
    }
    
    // Step 4: Create shipment
    if err := o.shippingService.CreateShipment(orderID); err != nil {
        o.paymentService.Refund(paymentID)      // Compensate
        o.inventoryService.Release(order.Items) // Compensate
        o.orderService.Cancel(orderID)          // Compensate
        return err
    }
    
    return nil
}

Pattern di Coreografia

Nella coreografia, non esiste un coordinatore centrale. Ogni servizio sa cosa deve fare e comunica attraverso eventi. I servizi ascoltano gli eventi e reagiscono di conseguenza. Questo approccio basato sugli eventi è particolarmente potente quando combinato con piattaforme di streaming di messaggi come AWS Kinesis, che forniscono un’infrastruttura scalabile per la distribuzione degli eventi tra i microservizi. Per una guida completa sull’implementazione di microservizi basati sugli eventi con Kinesis, vedi Costruire Microservizi basati sugli Eventi con AWS Kinesis.

Vantaggi:

  • Decentralizzato e scalabile
  • Nessun punto unico di fallimento
  • I servizi rimangono debolmente accoppiati
  • Adatto naturale per architetture basate sugli eventi

Svantaggi:

  • Più difficile comprendere il flusso complessivo
  • Difficile debug e tracciamento
  • Gestione complessa degli errori
  • Rischio di dipendenze cicliche

Esempio con Architettura basata sugli Eventi:

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

// 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 {
    // Compensation: refund payment
    return s.client.Refund(event.PaymentID)
}

Strategie di Compensazione

La compensazione è il cuore del pattern Saga. Ogni operazione deve avere una corrispondente compensazione che possa annullarne gli effetti.

Tipi di Compensazione

  1. Operazioni Reversibili: Operazioni che possono essere annullate direttamente

    • Esempio: Rilasciare l’inventario riservato, rimborsare i pagamenti
  2. Azioni di Compensazione: Operazioni diverse che raggiungono l’effetto opposto

    • Esempio: Annullare un ordine invece di eliminarlo
  3. Compensazione Pessimista: Pre-allocazione di risorse che possono essere rilasciate

    • Esempio: Riservare l’inventario prima di addebitare il pagamento
  4. Compensazione Ottimista: Eseguire le operazioni e compensare se necessario

    • Esempio: Addebitare il pagamento prima, rimborsare se l’inventario non è disponibile

Requisiti di Idempotenza

Tutte le operazioni e le compensazioni devono essere idempotenti. Questo garantisce che il riprovare un’operazione fallita non causi effetti duplicati.

func (s *PaymentService) Refund(paymentID string) error {
    // Check if already refunded
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }
    
    if payment.Status == "refunded" {
        return nil // Already refunded, idempotent
    }
    
    // Process refund
    return s.processRefund(paymentID)
}

Linee Guida per l’Implementazione

1. Gestione dello Stato del Saga

Mantieni lo stato di ogni istanza del saga per tracciare i progressi e abilitare il recupero. Quando si persiste lo stato del saga in un database, scegliere l’ORM giusto è cruciale per le prestazioni e la manutenibilità. Per le implementazioni basate su PostgreSQL, considera il confronto in Confronto tra ORMs Go per PostgreSQL: GORM vs Ent vs Bun vs sqlc per selezionare l’opzione migliore per le tue esigenze di archiviazione dello stato del saga:

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. Gestione dei Timeout

Implementa i timeout per ogni passo per prevenire che i sagas si blocchino indefinitamente:

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 occurred, compensate
        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 di Retry

Implementa un backoff esponenziale per gli errori transitori:

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 per lo Stato del Saga

Utilizza l’event sourcing per mantenere un registro completo. Quando si implementano archivi di eventi e meccanismi di replay, i generics in Go possono aiutare a creare codice di gestione degli eventi tipo-sicuro e riutilizzabile. Per modelli avanzati con generics in Go, vedi Generics in Go: 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. Monitoraggio e Osservabilità

Implementa un logging e un tracciamento completi:

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
}

Pattern Comuni e Anti-Pattern

Pattern da Seguire

  • Pattern del Coordinatore Saga: Utilizza un servizio dedicato per l’orchestrazione
  • Pattern dell’Outbox: Assicura una pubblicazione affidabile degli eventi
  • Chiavi di Idempotenza: Utilizza chiavi uniche per tutte le operazioni
  • Macchina a Stati Saga: Modella il saga come una macchina a stati

Anti-Pattern da Evitare

  • Compensazione Sincrona: Non attendere che la compensazione venga completata
  • Saghe Annidate: Evita che le saghe chiamino altre saghe (usa le sottosaghe invece)
  • Stato Condiviso: Non condividere lo stato tra i passaggi del saga
  • Passaggi di Lunga Durata: Suddividi i passaggi che durano troppo a lungo

Strumenti e Framework

Vários framework possono aiutare a implementare il pattern Saga:

  • Temporal: Piattaforma di orchestrazione dei workflow con supporto integrato per il pattern Saga
  • Zeebe: Motore di workflow per l’orchestrazione dei microservizi
  • Eventuate Tram: Framework Saga per Spring Boot
  • AWS Step Functions: Orchestratore di workflow serverless
  • Apache Camel: Framework di integrazione con supporto per il pattern Saga

Per i servizi orchestratori che necessitano di interfacce CLI per la gestione e il monitoraggio, Costruire Applicazioni CLI in Go con Cobra & Viper fornisce eccellenti pattern per creare strumenti CLI per interagire con gli orchestratori Saga.

Quando si distribuiscono microservizi basati su Saga in Kubernetes, l’implementazione di una rete di servizi può migliorare significativamente l’osservabilità, la sicurezza e la gestione del traffico. Implementare una Rete di Servizi con Istio e Linkerd copre come le reti di servizi complementano i pattern di transazioni distribuite fornendo preoccupazioni trasversali come il tracciamento distribuito e il circuit breaker.

Quando Utilizzare il Pattern Saga

Utilizza il pattern Saga quando:

  • ✅ Le operazioni si estendono a diversi microservizi
  • ✅ Processi aziendali di lunga durata
  • ✅ La coerenza eventuale è accettabile
  • ✅ Devi evitare i blocchi distribuiti
  • ✅ I servizi hanno database indipendenti

Evita quando:

  • ❌ È richiesta una coerenza forte
  • ❌ Le operazioni sono semplici e veloci
  • ❌ Tutti i servizi condividono lo stesso database
  • ❌ La logica di compensazione è troppo complessa

Conclusione

Il pattern Saga è essenziale per gestire le transazioni distribuite nell’architettura a microservizi. Sebbene introduca complessità, fornisce una soluzione pratica per mantenere la coerenza dei dati tra i confini dei servizi. Scegli l’orchestrazione per un controllo e una visibilità migliori, o la coreografia per scalabilità e accoppiamento debole. Assicurati sempre che le operazioni siano idempotenti, implementa una logica di compensazione corretta e mantieni un’osservabilità completa.

La chiave per un’implementazione riuscita del pattern Saga è comprendere i requisiti di coerenza, progettare con cura la logica di compensazione e scegliere l’approccio giusto per il tuo caso d’uso. Con un’implementazione corretta, il pattern Saga ti permette di costruire microservizi resilienti e scalabili che mantengono l’integrità dei dati nei sistemi distribuiti.