Pattern Saga nelle Transazioni Distribuite - Con Esempi in Go

Transazioni nei microservizi con il pattern Saga

Indice

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

Invece di affidarsi a lock distribuiti che possono bloccare le operazioni tra i vari servizi, Saga abilita la consistenza 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 tradizionali transazioni ACID non funzionano quando le operazioni si estendono su più servizi con database indipendenti, lasciando gli sviluppatori alla ricerca di approcci alternativi per garantire l’integrità dei dati.

Questa guida dimostra l’implementazione del pattern Saga in Go con esempi pratici che coprono sia l’approccio di orchestrazione che quello di coreografia. Se hai bisogno di un riferimento rapido per i fondamentali di Go, il Go Cheat Sheet fornisce una panoramica utile.

operaio edile con transazioni distribuite Questa bella immagine è generata dal modello AI Flux 1 dev.

Comprendere il Pattern Saga

Il pattern Saga è stato descritto originariamente 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 passaggio 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), Saga non mantiene lock tra i servizi, rendendolo adatto per processi aziendali di lunga durata. Il compromesso è la consistenza eventuale anziché la forte consistenza.

Caratteristiche Principali

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

Approcci di Implementazione Saga

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

Pattern di Orchestrazione

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

  • Invocare i servizi nell’ordine corretto
  • Gestire i fallimenti e attivare le compensazioni
  • Mantenere lo stato della saga
  • Coordinare i ritentativi e i timeout

Vantaggi:

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

Svantaggi:

  • Punto singolo di fallimento (anche se questo può 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: Crea ordine
    orderID, err := o.orderService.Create(order)
    if err != nil {
        return err
    }
    
    // Step 2: Riserva inventario
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // Compensa
        return err
    }
    
    // Step 3: Processa pagamento
    paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
    if err != nil {
        o.inventoryService.Release(order.Items) // Compensa
        o.orderService.Cancel(orderID)          // Compensa
        return err
    }
    
    // Step 4: Crea spedizione
    if err := o.shippingService.CreateShipment(orderID); err != nil {
        o.paymentService.Refund(paymentID)      // Compensa
        o.inventoryService.Release(order.Items) // Compensa
        o.orderService.Cancel(orderID)          // Compensa
        return err
    }
    
    return nil
}

Pattern di Coreografia

Nella coreografia, non c’è un coordinatore centrale. Ogni servizio sa cosa 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 dei 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 su eventi con Kinesis, vedi Costruire Microservizi Basati su Eventi con AWS Kinesis.

Vantaggi:

  • Decentralizzato e scalabile
  • Nessun punto singolo di fallimento
  • I servizi rimangono debolmente accoppiati
  • Adattamento naturale per architetture basate su eventi

Svantaggi:

  • Più difficile comprendere il flusso generale
  • Difficile da debuggare e tracciare
  • Gestione degli errori complessa
  • Rischio di dipendenze cicliche

Esempio con Architettura Basata su Eventi:

// Servizio Ordini
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) // Compensazione
}

// Servizio Pagamenti
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 {
    // Compensazione: rimborsa il pagamento
    return s.client.Refund(event.PaymentID)
}

Strategie di Compensazione

La compensazione è il cuore del pattern Saga. Ogni operazione deve avere una compensazione corrispondente che possa invertirne 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 ottengono l’effetto inverso

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

    • Esempio: Riservare l’inventario prima di addebitare il pagamento
  4. Compensazione Ottimistica: Eseguire 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 ritentativo di un’operazione fallita non causi effetti duplicati.

func (s *PaymentService) Refund(paymentID string) error {
    // Controlla se è già stato rimborsato
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }
    
    if payment.Status == "refunded" {
        return nil // Già rimborsato, idempotente
    }
    
    // Processa il rimborso
    return s.processRefund(paymentID)
}

Best Practices

1. Gestione dello Stato della Saga

Mantenere lo stato di ogni istanza della saga per tracciare i progressi e abilitare il recupero. Quando si persiste lo stato della saga in un database, la scelta dell’ORM giusto è cruciale per le prestazioni e la manutenibilità. Per le implementazioni basate su PostgreSQL, considerare il confronto in Confronto ORM Go per PostgreSQL: GORM vs Ent vs Bun vs sqlc per selezionare la soluzione migliore per le tue esigenze di archiviazione dello stato della 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

Implementare timeout per ogni passaggio per evitare che le sagas rimangano in sospeso 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 verificatosi, compensa
        if err := step.Compensate(); err != nil {
            return fmt.Errorf("compensazione fallita: %w", err)
        }
        return fmt.Errorf("passaggio %s scaduto dopo %v", step.Name(), o.timeout)
    }
}

3. Logica di Ritentativo

Implementare il backoff esponenziale per i fallimenti 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("operazione fallita dopo %d tentativi", maxRetries)
}

4. Event Sourcing per lo Stato della Saga

Utilizzare l’event sourcing per mantenere una traccia di audit completa. Quando si implementano store di eventi e meccanismi di replay, i generics di Go possono aiutare a creare codice di gestione degli eventi type-safe e riutilizzabile. Per pattern avanzati usando generics in Go, vedi Generics in Go: Casi d’Uso e Pattern.

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("impossibile serializzare il payload: %w", err)
    }
    
    version, err := s.store.GetNextVersion(sagaID)
    if err != nil {
        return fmt.Errorf("impossibile ottenere la versione: %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("impossibile ottenere gli eventi: %w", err)
    }
    
    saga := NewSaga()
    for _, event := range events {
        if err := saga.Apply(event); err != nil {
            return nil, fmt.Errorf("impossibile applicare l'evento: %w", err)
        }
    }
    
    return saga, nil
}

5. Monitoraggio e Osservabilità

Implementare logging e tracing 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 avviata")
    
    // ... esecuzione saga
    
    return nil
}

Pattern Comuni e Anti-Pattern

Pattern da Seguire

  • Pattern Coordinatore Saga: Utilizzare un servizio dedicato per l’orchestrazione
  • Pattern Outbox: Garantire la pubblicazione affidabile degli eventi
  • Chiavi di Idempotenza: Utilizzare chiavi uniche per tutte le operazioni
  • Macchina a Stati Saga: Modellare la saga come una macchina a stati

Anti-Pattern da Evitare

  • Compensazione Sincrona: Non attendere il completamento della compensazione
  • Sagas Annidate: Evitare che le sagas chiamino altre sagas (usare sotto-sagas invece)
  • Stato Condiviso: Non condividere lo stato tra i passaggi della saga
  • Passaggi di Lunga Durata: Suddividere i passaggi che richiedono troppo tempo

Strumenti e Framework

Diversi framework possono aiutare a implementare i pattern Saga:

  • Temporal: Piattaforma di orchestrazione dei flussi di lavoro con supporto Saga integrato
  • Zeebe: Motore di flusso di lavoro per l’orchestrazione dei microservizi
  • Eventuate Tram: Framework Saga per Spring Boot
  • AWS Step Functions: Orchestrazione dei flussi di lavoro serverless
  • Apache Camel: Framework di integrazione con supporto Saga

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

Quando si distribuiscono microservizi basati su Saga in Kubernetes, l’implementazione di un service mesh può migliorare significativamente l’osservabilità, la sicurezza e la gestione del traffico. Implementare Service Mesh con Istio e Linkerd spiega come i service mesh completano i pattern di transazione distribuita fornendo funzionalità trasversali come il tracing distribuito e il circuit breaking.

Quando Utilizzare il Pattern Saga

Utilizzare il pattern Saga quando:

  • ✅ Le operazioni si estendono su più microservizi
  • ✅ Processi aziendali di lunga durata
  • ✅ La consistenza eventuale è accettabile
  • ✅ È necessario evitare i lock distribuiti
  • ✅ I servizi hanno database indipendenti

Evitare quando:

  • ❌ È richiesta forte consistenza
  • ❌ 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 nelle architetture a microservizi. Sebbene introduca complessità, fornisce una soluzione pratica per mantenere la coerenza dei dati attraverso i confini dei servizi. Scegliere l’orchestrazione per un migliore controllo e visibilità, o la coreografia per scalabilità e debol accoppiamento. Assicurarsi sempre che le operazioni siano idempotenti, implementare una logica di compensazione corretta e mantenere un’osservabilità completa.

La chiave per un’implementazione Saga di successo è comprendere i propri requisiti di consistenza, progettare attentamente la logica di compensazione e scegliere l’approccio giusto per il proprio caso d’uso. Con un’implementazione corretta, Saga consente di costruire microservizi resilienti e scalabili che mantengono l’integrità dei dati attraverso sistemi distribuiti.

Iscriviti

Ricevi nuovi articoli su sistemi, infrastruttura e ingegneria AI.