Pattern Saga nelle Transazioni Distribuite - Con Esempi in Go

Transazioni nei microservizi con il pattern Saga

Indice

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

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

Nelle architetture microservizi, mantenere la consistenza dei dati tra i servizi è uno dei problemi più complessi. Le tradizionali transazioni ACID non funzionano quando le operazioni coinvolgono 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 modello Saga in Go con esempi pratici che coprono sia gli approcci di orchestrazione che di coreografia. Se hai bisogno di un riferimento rapido per le fondamenta di Go, il Go Cheat Sheet fornisce una panoramica utile.

operaio edile con transazioni distribuite Questa bella immagine è generata da AI model Flux 1 dev.

Comprendere il Modello Saga

Il modello 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 passaggio fallisce, vengono eseguite transazioni compensative per annullare gli effetti dei passaggi precedenti.

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

Caratteristiche Principali

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

Approcci di Implementazione di Saga

Ci sono due approcci principali per implementare il modello Saga: orchestrazione e coreografia.

Modello 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 tentativi di ripetizione e i timeout

Vantaggi:

  • Controllo centralizzato e visibilità
  • Più facile da capire e debuggare
  • Migliore gestione degli errori e recupero
  • Test più semplici 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: 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
}

Modello 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 guidato dagli eventi è particolarmente potente quando combinato con piattaforme di streaming dei messaggi come AWS Kinesis, che forniscono infrastrutture scalabili per la distribuzione degli eventi tra microservizi. Per una guida completa sull’implementazione di microservizi guidati dagli eventi con Kinesis, vedi Building Event-Driven Microservices with AWS Kinesis.

Vantaggi:

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

Svantaggi:

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

Esempio con Architettura Guidata dagli 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
}

// Note: s.repo.Save followed by s.eventBus.Publish is a dual-write.
// In production, replace this with the transactional outbox pattern so the
// event is written atomically with the order row and published by a relay.

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 modello Saga. Ogni operazione deve avere una compensazione corrispondente che possa invertire i suoi effetti.

Tipi di Compensazione

  1. Operazioni Reversibili: Operazioni che possono essere annullate direttamente

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

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

    • Esempio: Riservare l’inventario prima di addebitare il pagamento
  4. Compensazione Ottimistica: Esegui operazioni e compensa se necessario

    • Esempio: Addebita il pagamento prima, rimborsa se l’inventario non è disponibile

Requisiti di Idempotenza

Tutte le operazioni e le compensazioni devono essere idempotenti. Questo assicura che la ripetizione di un’operazione fallita non causi effetti duplicati. È altrettanto importante assicurarsi che ogni partecipante alla saga pubblichi affidabilmente i suoi eventi dopo un commit locale — il transactional outbox pattern è il modo standard per chiudere quel divario tra una scrittura nel database e una pubblicazione nel broker.

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

Best Practices

1. Gestione dello Stato della Saga

Mantieni 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, scegliere il giusto ORM è cruciale per le prestazioni e la manutenibilità. Per le implementazioni basate su PostgreSQL, considerare il confronto in Comparing Go ORMs for 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

Implementa timeout per ogni passaggio per evitare che le sagas restino 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 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 Ripetizione

Implementa 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("operation failed after %d retries", maxRetries)
}

4. Event Sourcing per lo Stato della Saga

Utilizza l’event sourcing per mantenere una traccia di audit completa. Quando si implementano store di eventi e meccanismi di riproduzione, i tipi generici di Go possono aiutare a creare codice di gestione degli eventi tipo-safe e riutilizzabile. Per modelli avanzati utilizzando i generici in Go, vedi 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. Monitoraggio e Osservabilità

Implementa 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 started")
    
    // ... saga execution
    
    return nil
}

Modelli Comuni e Anti-Modelli

Modelli da Seguire

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

Anti-Modelli da Evitare

  • Compensazione Sincrona: Non aspettare che la compensazione si completi
  • Sagas Annidate: Evita che le sagas chiamino altre sagas (usa sub-sagas invece)
  • Stato Condiviso: Non condividere lo stato tra i passaggi della saga
  • Passaggi di Lunga Durata: Suddividi i passaggi che richiedono troppo tempo

Strumenti e Framework

Diversi framework possono aiutare a implementare i modelli Saga:

  • Temporal: Piattaforma di orchestrazione dei flussi di lavoro con supporto Saga integrato
  • Zeebe: Motore di flussi 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 orchestratori che necessitano di interfacce CLI per la gestione e il monitoraggio, Building CLI Applications in Go with Cobra & Viper fornisce modelli eccellenti per la creazione di strumenti a riga di comando per interagire con gli orchestratori 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. Implementing Service Mesh with Istio and Linkerd copre come i service mesh completano i modelli di transazione distribuita fornendo funzionalità trasversali come il tracing distribuito e il circuit breaking.

Quando Utilizzare il Modello Saga

Utilizza il modello Saga quando:

  • ✅ Le operazioni coinvolgono più microservizi
  • ✅ Processi aziendali di lunga durata
  • ✅ La coerenza eventuale è accettabile
  • ✅ Hai bisogno di evitare blocchi distribuiti
  • ✅ I servizi hanno database indipendenti

Evita quando:

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

Conclusione

Il modello Saga è essenziale per gestire le transazioni distribuite nelle architetture microservizi. Sebbene introduca complessità, fornisce una soluzione pratica per mantenere la consistenza dei dati attraverso i confini dei servizi. Scegli l’orchestrazione per un migliore controllo e visibilità, o la coreografia per scalabilità e accoppiamento debole. Assicurati sempre che le operazioni siano idempotenti, implementa una logica di compensazione appropriata e mantieni un’osservabilità completa.

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

Iscriviti

Ricevi nuovi articoli su sistemi, infrastruttura e ingegneria AI.