Saga-mönstret för distribuerade transaktioner – med exempel i Go

Transaktioner i mikrotjänster med sagamönstret

Sidinnehåll

Mönstret Saga ger en elegant lösning genom att bryta upp distribuerade transaktioner i en serie lokala transaktioner med kompenserande åtgärder.

Istället för att förlita sig på distribuerade lås som kan blockera operationer över flera tjänster, möjliggör Saga slutlig konsistens genom en sekvens av reversibla steg, vilket gör det idealiskt för långvariga affärsprocesser.

I arkitekturer med mikrotjänster är upprätthållande av datakonsistens över tjänster ett av de mest utmanande problemen. Traditionella ACID-transaktioner fungerar inte när operationer sträcker sig över flera tjänster med oberoende databaser, vilket lämnar utvecklare att söka efter alternativa metoder för att säkerställa dataintegritet.

Denna guide demonstrerar implementering av Saga-mönstret i Go med praktiska exempel som täcker både orkestrering och koreografi. Om du behöver en snabb referens för Go-grundläggande, ger Go Cheat Sheet en hjälpsam översikt.

byggare med distribuerade transaktioner Detta fina bild är genererad av AI-modellen Flux 1 dev.

Förstå Saga-mönstret

Saga-mönstret beskrevs ursprungligen av Hector Garcia-Molina och Kenneth Salem 1987. I kontexten av mikrotjänster är det en sekvens av lokala transaktioner där varje transaktion uppdaterar data inom en enda tjänst. Om något steg misslyckas, körs kompenserande transaktioner för att ånka effekterna av föregående steg.

Till skillnad från traditionella distribuerade transaktioner som använder två-fas-kommitt (2PC), håller Saga inga lås över tjänster, vilket gör det lämpligt för långvariga affärsprocesser. Avvägningen är slutlig konsistens snarare än stark konsistens.

Viktiga egenskaper

  • Inga distribuerade lås: Varje tjänst hanterar sin egen lokala transaktion
  • Kompenserande åtgärder: Varje operation har en motsvarande rollback-mekanism
  • Slutlig konsistens: Systemet når slutligen en konsistent tillstånd
  • Långvarig: Lämplig för processer som tar sekunder, minuter eller till och med timmar

Implementeringsmetoder för Saga

Det finns två huvudsakliga metoder för att implementera Saga-mönstret: orkestrering och koreografi.

Orkestreringsmönstret

Vid orkestrering hanterar en central koordinator (orkestrator) hela transaktionsflödet. Orkestratorn är ansvarig för:

  • Att anropa tjänster i rätt ordning
  • Att hantera fel och utlösa kompensationer
  • Att underhålla sagans tillstånd
  • Att koordinera försök och tidsgränser

Fördelar:

  • Centraliserad kontroll och överskådlighet
  • Enklare att förstå och felsöka
  • Bättre felhantering och återhämtning
  • Enklare testning av det övergripande flödet

Nackdelar:

  • En enda felkälla (även om detta kan mildras)
  • En extra tjänst att underhålla
  • Kan bli en flaskhals för komplexa flöden

Exempel i Go:

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

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    sagaID := generateSagaID()
    
    // Steg 1: Skapa order
    orderID, err := o.orderService.Create(order)
    if err != nil {
        return err
    }
    
    // Steg 2: Reservera lager
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // Kompensera
        return err
    }
    
    // Steg 3: Bearbeta betalning
    paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
    if err != nil {
        o.inventoryService.Release(order.Items) // Kompensera
        o.orderService.Cancel(orderID)          // Kompensera
        return err
    }
    
    // Steg 4: Skapa leverans
    if err := o.shippingService.CreateShipment(orderID); err != nil {
        o.paymentService.Refund(paymentID)      // Kompensera
        o.inventoryService.Release(order.Items) // Kompensera
        o.orderService.Cancel(orderID)          // Kompensera
        return err
    }
    
    return nil
}

Koreografimönstret

Vid koreografi finns det ingen central koordinator. Varje tjänst vet vad den ska göra och kommunicerar via händelser. Tjänster lyssnar på händelser och reagerar därefter. Denna händelsedrivna metod är särskilt kraftfull när den kombineras med plattformar för händelseströmning som AWS Kinesis, som tillhandahåller skalbar infrastruktur för händelsedistribution över mikrotjänster. För en omfattande guide om att implementera händelsedrivna mikrotjänster med Kinesis, se Bygg händelsedrivna mikrotjänster med AWS Kinesis.

Fördelar:

  • Decentraliserad och skalbar
  • Inga enskilda felkällor
  • Tjänsterna förblir löst kopplade
  • Naturlig passform för händelsedrivna arkitekturer

Nackdelar:

  • Svårare att förstå det övergripande flödet
  • Svårt att felsöka och spåra
  • Komplex felhantering
  • Risk för cykliska beroenden

Exempel med händelsedrivna arkitekturer:

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

// Obs: s.repo.Save följt av s.eventBus.Publish är en dubbel-skrivning.
// I produktion, byt ut detta mot det transaktionella outbox-mönstret så att
// händelsen skrivs atomiskt tillsammans med order-raden och publiceras av en relay.

func (s *OrderService) HandlePaymentFailed(event PaymentFailedEvent) error {
    return s.repo.Cancel(event.OrderID) // Kompensation
}

// 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 {
    // Kompensation: återbetal betalning
    return s.client.Refund(event.PaymentID)
}

Kompensationsstrategier

Kompensation är hjärtat i Saga-mönstret. Varje operation måste ha en motsvarande kompensation som kan reversera dess effekter.

Typer av kompensation

  1. Reversibla operationer: Operationer som kan ångras direkt

    • Exempel: Att frigöra reserverat lager, återbetalning av betalningar
  2. Kompenserande åtgärder: Olika operationer som uppnår den motsatta effekten

    • Exempel: Att avbeställa en order istället för att ta bort den
  3. Pessimistisk kompensation: Fördela resurser som kan frigöras

    • Exempel: Reservera lager innan betalningen debiteras
  4. Optimistisk kompensation: Utför operationer och kompensera vid behov

    • Exempel: Debitera betalningen först, återbetal om lagret inte finns tillgängligt

Krav på idempotens

Alla operationer och kompensationer måste vara idempotenta. Detta säkerställer att ett försök att köra en misslyckad operation igen inte orsakar dubbla effekter. Det är lika viktigt att se till att varje saga-deltagare pålitligt publicerar sina händelser efter en lokal commit — det transaktionella outbox-mönstret är det standardiserade sättet att stänga luckan mellan ett databasskrivning och en broker-publicering.

func (s *PaymentService) Refund(paymentID string) error {
    // Kontrollera om redan återbetald
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }
    
    if payment.Status == "refunded" {
        return nil // Redan återbetald, idempotent
    }
    
    // Bearbeta återbetalning
    return s.processRefund(paymentID)
}

Bästa praxis

1. Hantering av Saga-tillstånd

Underhåll tillståndet för varje saga-instans för att spåra framsteg och möjliggöra återhämtning. När man persistar saga-tillstånd till en databas, är valet av rätt ORM avgörande för prestanda och underhållbarhet. För implementeringar baserade på PostgreSQL, överväg jämförelsen i Jämförelse av Go ORM för PostgreSQL: GORM vs Ent vs Bun vs sqlc för att välja den bästa passformen för dina behov av saga-tillståndslagring:

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. Hantering av tidsgränser

Implementera tidsgränser för varje steg för att förhindra att sagor hänger kvar oändligt:

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():
        // Tidsgräns överskriden, kompensera
        if err := step.Compensate(); err != nil {
            return fmt.Errorf("kompensation misslyckades: %w", err)
        }
        return fmt.Errorf("steg %s tog för lång tid efter %v", step.Name(), o.timeout)
    }
}

3. Logik för försök

Implementera exponentiell backoff för övergående fel:

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("operationen misslyckades efter %d försök", maxRetries)
}

4. Händelsekällning för Saga-tillstånd

Använd händelsekällning för att underhålla en komplett revisionshistorik. När man implementerar händelselager och återuppläsningsmekanismer, kan Go-generika hjälpa till att skapa typsäker, återanvändbar kod för händelsehantering. För avancerade mönster som använder generika i Go, se Go Generics: Användningsområden och mönster.

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("lyckades inte marshala payload: %w", err)
    }
    
    version, err := s.store.GetNextVersion(sagaID)
    if err != nil {
        return fmt.Errorf("lyckades inte hämta 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("lyckades inte hämta händelser: %w", err)
    }
    
    saga := NewSaga()
    for _, event := range events {
        if err := saga.Apply(event); err != nil {
            return nil, fmt.Errorf("lyckades inte applicera händelse: %w", err)
        }
    }
    
    return saga, nil
}

5. Övervakning och observabilitet

Implementera omfattande loggning och spårning:

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 startad")
    
    // ... saga-exekvering
    
    return nil
}

Vanliga mönster och anti-mönster

Mönster att följa

  • Saga-koordinatormönstret: Använd en dedikerad tjänst för orkestrering
  • Outbox-mönstret: Säkerställ pålitlig publicering av händelser
  • Idempotensnycklar: Använd unika nycklar för alla operationer
  • Saga-tillståndsmaskin: Modellera saga som en tillståndsmaskin

Anti-mönster att undvika

  • Synkron kompensation: Vänta inte på att kompensation ska vara klar
  • Nested Sagas: Undvik sagor som anropar andra sagor (använd undersagor istället)
  • Delat tillstånd: Dela inte tillstånd mellan saga-steg
  • Långvariga steg: Bryt ner steg som tar för lång tid

Verktyg och ramverk

Flera ramverk kan hjälpa till att implementera Saga-mönster:

  • Temporal: Plattform för arbetsflödesorkestrering med inbyggt stöd för Saga
  • Zeebe: Arbetsflödesmotor för mikrotjänstorkestrering
  • Eventuate Tram: Saga-ramverk för Spring Boot
  • AWS Step Functions: Serverless arbetsflödesorkestrering
  • Apache Camel: Integrationsramverk med stöd för Saga

För orkestreringstjänster som behöver CLI-gränssnitt för hantering och övervakning, Bygg CLI-applikationer i Go med Cobra & Viper ger utmärkta mönster för att skapa kommandotolksverktyg för att interagera med saga-orkestratorer.

När man distribuerar saga-baserade mikrotjänster i Kubernetes, kan implementering av ett service mesh avsevärt förbättra observabilitet, säkerhet och trafikhantering. Implementering av Service Mesh med Istio och Linkerd täcker hur service meshes kompletterar mönster för distribuerade transaktioner genom att tillhandahålla tvärgående bekymmer som distribuerad spårning och circuit breaking.

När man ska använda Saga-mönstret

Använd Saga-mönstret när:

  • ✅ Operationer sträcker sig över flera mikrotjänster
  • ✅ Långvariga affärsprocesser
  • ✅ Slutlig konsistens är acceptabel
  • ✅ Du behöver undvika distribuerade lås
  • ✅ Tjänsterna har oberoende databaser

Undvik när:

  • ❌ Stark konsistens krävs
  • ❌ Operationer är enkla och snabba
  • ❌ Alla tjänster delar samma databas
  • ❌ Kompensationslogiken är för komplex

Slutsats

Saga-mönstret är avgörande för att hantera distribuerade transaktioner i mikrotjänstarkitekturer. Även om det introducerar komplexitet, ger det en praktisk lösning för att upprätthålla datakonsistens över tjänstegränser. Välj orkestrering för bättre kontroll och överskådlighet, eller koreografi för skalbarhet och lös koppling. Säkerställ alltid att operationer är idempotenta, implementera korrekt kompensationslogik och underhåll omfattande observabilitet.

Nyckeln till framgångsrik Saga-implementering är att förstå dina konsistenskrav, noggrant designa kompensationslogiken och välja rätt metod för ditt användningsfall. Med korrekt implementering möjliggör Saga att du bygger motståndskraftiga, skalbara mikrotjänster som upprätthåller dataintegritet över distribuerade system.

Användbara länkar

Prenumerera

Få nya inlägg om system, infrastruktur och AI-ingenjörskonst.