分散トランザクションにおけるSagaパターン - Goでの例

マイクロサービスにおけるSagaパターンによるトランザクション

目次

Saga パターンは、分散トランザクションを補完アクションを伴う一連のローカルトランザクションに分割することで、エレガントな解決策を提供します。

サービス全体にわたって操作をブロックする可能性のある分散ロックに依存するのではなく、Sagaは可逆的なステップのシーケンスを通じて最終整合性を実現し、長期間実行されるビジネスプロセスに理想的なものです。

マイクロサービスアーキテクチャにおいて、サービス間でデータの一貫性を維持することは最も困難な課題の一つです。操作が独立したデータベースを持つ複数のサービスにまたがる場合、従来のACIDトランザクションは機能せず、データ整合性を確保するための代替アプローチを開発者が模索する状況に置かれます。

このガイドでは、オーケストレーションとキョレオグラフィの両方のアプローチをカバーする実践的な例を用いて、GoにおけるSagaパターンの実装を示します。Goの基礎知識を素早く参照したい場合は、Goチートシートが有用な概要を提供しています。

construction worker with distributed transactions この美しい画像は、AIモデルFlux 1 devによって生成されました。

Sagaパターンの理解

Sagaパターンは、1987年にHector Garcia-MolinaとKenneth Salemによって初めて記述されました。マイクロサービスの文脈において、それは各トランザクションが単一のサービス内のデータを更新する一連のローカルトランザクションです。どのステップでも失敗が発生した場合、補完トランザクションが実行され、先行するステップの影響を取り消します。

2フェーズコミット(2PC)を使用する従来の分散トランザクションとは異なり、Sagaはサービス全体にわたってロックを保持しないため、長期間実行されるビジネスプロセスに適しています。トレードオフは、強い整合性ではなく最終整合性です。

主要な特徴

  • 分散ロックなし: 各サービスは独自のローカルトランザクションを管理します
  • 補完アクション: すべての操作には対応するロールバックメカニズムがあります
  • 最終整合性: システムは最終的に一貫した状態に達します
  • 長期間実行: 秒、分、あるいは時間単位の処理に適しています

Sagaの実装アプローチ

Sagaパターンを実装するには、主に2つのアプローチがあります:オーケストレーションとキョレオグラフィです。

オーケストレーションパターン

オーケストレーションでは、中央のコーディネーター(オーケストレーター)がトランザクションフロー全体を管理します。オーケストレーターは以下の責任を負います:

  • サービスを正しい順序で呼び出す
  • 失敗を処理し、補完をトリガーする
  • Sagaの状態を維持する
  • リトライとタイムアウトを調整する

利点:

  • 集中された制御と可視性
  • 理解とデバッグが容易
  • より優れたエラー処理とリカバリ
  • 全体のフローのテストが単純

欠点:

  • 単一障害点(ただし、これは緩和可能です)
  • 追加のサービス維持が必要
  • 複雑なフローではボトルネックになる可能性があります

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
}

キョレオグラフィパターン

キョレオグラフィでは、中央のコーディネーターはありません。各サービスは行うべきことを知り、イベントを通じて通信します。サービスはイベントをリスンし、それに応じて反応します。このイベント駆動型のアプローチは、マイクロサービス全体にわたるイベント配布のためのスケーラブルなインフラストラクチャを提供するAWS Kinesisなどのメッセージストリーミングプラットフォームと組み合わせることで、特に強力です。Kinesisを使用したイベント駆動型マイクロサービスの実装に関する包括的なガイドについては、以下を参照してください。 Building Event-Driven Microservices with AWS Kinesis.

利点:

  • 分散型でスケーラブル
  • 単一障害点がない
  • サービスは疎結合のまま
  • イベント駆動型アーキテクチャに自然に適合

欠点:

  • 全体のフローを理解するのが難しい
  • デバッグとトレースが困難
  • 複雑なエラー処理
  • 循環依存のリスク

イベント駆動型アーキテクチャの例:

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

補完戦略

補完はSagaパターンの核心です。各操作には、その影響を逆転できる対応する補完が必要です。

補完の種類

  1. 可逆操作: 直接取り消すことができる操作

    • 例:予約済み在庫の解放、支払いの返金
  2. 補完アクション: 逆の効果をもたらす異なる操作

    • 例:削除するのではなく注文をキャンセルする
  3. 悲観的補完: 解放できるリソースを事前に割り当てる

    • 例:支払いをチャージする前に在庫を予約する
  4. 楽観的補完: 操作を実行し、必要な場合に補完する

    • 例:支払いを先にチャージし、在庫が利用できない場合は返金する

冪等性要件

すべての操作と補完は冪等である必要があります。これにより、失敗した操作を再試行しても重複した影響が発生しないことが保証されます。

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

ベストプラクティス

1. Saga状態管理

進捗を追跡し、リカバリを有効化するために、各Sagaインスタンスの状態を維持します。Saga状態をデータベースに永続化する際、適切なORMを選択することはパフォーマンスと保守性の観点から重要です。PostgreSQLベースの実装では、Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlcの比較を参照し、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. タイムアウト処理

Sagaが無限に停止しないように、各ステップのタイムアウトを実装します:

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. リトライロジック

一時的な失敗に対して指数バックオフを実装します:

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. Saga状態のためのイベントソーシング

完全な監査証跡を維持するためにイベントソーシングを使用します。イベントストアとリプレイメカニズムを実装する際、Goジェネリクスは型安全で再利用可能なイベント処理コードを作成するのに役立ちます。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. モニタリングと可観測性

包括的なロギングとトレーシングを実装します:

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
}

一般的なパターンとアンチパターン

従うべきパターン

  • Sagaコーディネーターパターン: オーケストレーションのために専用サービスを使用する
  • Outboxパターン: 信頼性の高いイベント発行を確保する
  • 冪等キー: すべての操作に一意のキーを使用する
  • Sagaステートマシン: Sagaをステートマシンとしてモデル化する

避けるべきアンチパターン

  • 同期的補完: 補完が完了するのを待たない
  • ネストされたSaga: Sagaが他のSagaを呼び出すのを避ける(代わりにサブSagaを使用する)
  • 共有状態: Sagaステップ間で状態を共有しない
  • 長時間実行ステップ: 時間のかかるステップを分割する

ツールとフレームワーク

Sagaパターンを実装するのに役立ついくつかのフレームワークがあります:

  • Temporal: 組み込みのSagaサポートを備えたワークフローオーケストレーションプラットフォーム
  • Zeebe: マイクロサービスオーケストレーション用のワークフローエンジン
  • Eventuate Tram: Spring Boot用のSagaフレームワーク
  • AWS Step Functions: サーバーレスワークフローオーケストレーション
  • Apache Camel: Sagaサポートを備えた統合フレームワーク

管理とモニタリングのためにCLIインターフェースが必要なオーケストレーターサービスの場合、Building CLI Applications in Go with Cobra & Viperは、Sagaオーケストレーターと対話するためのコマンドラインツールを作成するための優れたパターンを提供します。

KubernetesでSagaベースのマイクロサービスを展開する場合、サービスメッシュを実装することで、可観測性、セキュリティ、トラフィック管理を大幅に改善できます。Implementing Service Mesh with Istio and Linkerdでは、サービスメッシュが分散トレーシングやサーキットブレイキングなどの横断的関心事を提供することで、分散トランザクションパターンをどのように補完するかについて説明しています。

Sagaパターンを使用するタイミング

Sagaパターンは次の場合に使用します:

  • ✅ 操作が複数のマイクロサービスにまたがる
  • ✅ 長期間実行されるビジネスプロセス
  • ✅ 最終整合性が許容される
  • ✅ 分散ロックを回避する必要がある
  • ✅ サービスに独立したデータベースがある

次の場合は避けます:

  • ❌ 強い整合性が要求される
  • ❌ 操作が単純で高速である
  • ❌ すべてのサービスが同じデータベースを共有している
  • ❌ 補完ロジックが複雑すぎる

結論

Sagaパターンは、マイクロサービスアーキテクチャにおける分散トランザクションの管理に不可欠です。複雑さを導入する一方で、サービス境界を跨ってデータの一貫性を維持するための実用的な解決策を提供します。より良い制御と可視性のためにオーケストレーションを選択するか、スケーラビリティと疎結合のためにキョレオグラフィを選択します。常に操作が冪等であることを確保し、適切な補完ロジックを実装し、包括的な可観測性を維持してください。

Saga実装の成功の鍵は、整合性要件を理解し、補完ロジックを慎重に設計し、ユースケースに適したアプローチを選択することです。適切な実装により、Sagaは分散システム全体でデータ整合性を維持する堅牢でスケーラブルなマイクロサービスを構築することを可能にします。

参考リンク

購読する

システム、インフラ、AIエンジニアリングの新記事をお届けします。