分散トランザクションにおけるサガパターン - Goでの例を用いて

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

目次

Saga パターン
は、分散トランザクションを一連のローカルトランザクションと補償アクションに分割することで、洗練された解決策を提供します。

分散ロックに依存する代わりに、Saga はサービス間で操作をブロックしないことで、最終的な一貫性を一連の逆転可能なステップを通じて実現します。これにより、長期にわたるビジネスプロセスに最適です。

マイクロサービスアーキテクチャでは、サービス間でデータの一貫性を維持することは、最も困難な問題の一つです。伝統的な ACID トランザクションは、複数のサービスと独立したデータベースを持つ操作には機能しないので、開発者はデータの整合性を保証するための代替アプローチを探しています。

本ガイドでは、Go での Saga パターンの実装を、オーケストレーションとチャオレオグラフィーのアプローチを含む実用的な例を使って説明します。Go の基本についてのクイックリファレンスが必要な場合は、Go Cheat Sheet が役立ちます。

construction worker with distributed transactions
この素晴らしい画像は、AI model Flux 1 dev によって生成されました。

Saga パターンの理解

Saga パターンは、1987年にヘクター・ガルシア=モリナとケネス・サリムによって最初に記述されました。マイクロサービスの文脈では、Saga は各トランザクションが単一のサービス内でデータを更新する一連のローカルトランザクションです。もしステップのいずれかが失敗した場合、補償トランザクションが実行され、前のステップの影響を元に戻します。

伝統的な分散トランザクションが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()
    
    // ステップ1:注文を作成
    orderID, err := o.orderService.Create(order)
    if err != nil {
        return err
    }
    
    // ステップ2:在庫を予約
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // 補償
        return err
    }
    
    // ステップ3:支払いを処理
    paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
    if err != nil {
        o.inventoryService.Release(order.Items) // 補償
        o.orderService.Cancel(orderID)          // 補償
        return err
    }
    
    // ステップ4:配送を作成
    if err := o.shippingService.CreateShipment(orderID); err != nil {
        o.paymentService.Refund(paymentID)      // 補償
        o.inventoryService.Release(order.Items) // 補償
        o.orderService.Cancel(orderID)          // 補償
        return err
    }
    
    return nil
}

チャオレオグラフィー パターン

チャオレオグラフィーでは、中央の調整者は存在しません。各サービスは何をするべきかを知っており、イベントを通じて通信します。サービスはイベントをリッスンし、それに応じて反応します。このイベント駆動型のアプローチは、AWS Kinesis などのメッセージストリーミングプラットフォームと組み合わせて使用する場合に特に強力です。Kinesis を使用してイベント駆動型のマイクロサービスを実装するための包括的なガイドについては、 Building Event-Driven Microservices with AWS Kinesis を参照してください。

利点:

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

欠点:

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

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

// 注文サービス
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) // 補償
}

// 支払いサービス
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 {
    // 補償:支払いを返金
    return s.client.Refund(event.PaymentID)
}

補償戦略

補償は Saga パターンの中心です。各操作には、その影響を逆転させる補償が対応する必要があります。

補償の種類

  1. 逆転可能な操作:直接的に取り消せる操作

    • 例:予約された在庫を解放する、支払いを返金する
  2. 補償アクション:逆の効果を達成する異なる操作

    • 例:注文をキャンセルする代わりに削除する
  3. 楽観的な補償:リソースを事前に確保し、それを解放する

    • 例:支払い前に在庫を予約する
  4. 楽観的な補償:操作を実行し、必要に応じて補償する

    • 例:まず支払いを行い、在庫が利用不可の場合に返金する

イデムポテンシーの要件

すべての操作と補償はイデムポテンシー(idempotent)である必要があります。これにより、失敗した操作を再試行しても重複した効果が生じません。

func (s *PaymentService) Refund(paymentID string) error {
    // すでに返金されているか確認
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }
    
    if payment.Status == "refunded" {
        return nil // すでに返金済み、イデムポテンシー
    }
    
    // 返金処理
    return s.processRefund(paymentID)
}

ベストプラクティス

1. Saga ステート管理

各 Saga インスタンスのステートを維持し、進行状況を追跡し、回復を可能にします。データベースに Saga ステートを永続化する際、パフォーマンスと保守性の観点から適切な ORM を選ぶことが重要です。PostgreSQL を使用する実装では、Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc で比較されている内容を参考に、Saga ステートストレージのニーズに最適な ORM を選ぶことができます:

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():
        // タイムアウトが発生した場合、補償
        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 コーディネーターパターン:オーケストレーション用の専用サービスを使用
  • アウトボックスパターン:信頼性のあるイベントの公開を保証
  • イデムポテンシーのキー:すべての操作にユニークなキーを使用
  • 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 にデプロイする際、サービスメッシュを実装することで、観測性、セキュリティ、トラフィック管理を大幅に向上させることができます。Implementing Service Mesh with Istio and Linkerd では、サービスメッシュが分散トランザクションパターンとどのように補完し合うか、分散トレースや回路ブレーカーなどのクロスカットコンセプトを提供することを説明しています。

Saga パターンを使用するべきケース

Saga パターンを使用するべきケース:

  • ✅ マイクロサービスをまたがる操作
  • ✅ 長時間かかるビジネスプロセス
  • ✅ 最終的な一貫性が受け入れ可能
  • ✅ 分散ロックを避ける必要がある
  • ✅ サービスが独立したデータベースを持つ

Saga パターンを避けるべきケース:

  • ❌ 強い一貫性が必要
  • ❌ 操作が単純で速い
  • ❌ すべてのサービスが同じデータベースを共有
  • ❌ 補償ロジックが複雑すぎる

結論

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

Saga の成功的な実装の鍵は、一貫性の要件を理解し、補償ロジックを慎重に設計し、使用ケースに最適なアプローチを選択することです。適切な実装により、Saga は分散システム内でデータの整合性を保つ、頑健でスケーラブルなマイクロサービスを構築するのに役立ちます。

有用なリンク