分散トランザクションにおけるサガパターン - 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()
    
    // ステップ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を使用したイベント駆動型マイクロサービスの実装に関する包括的なガイドについては、 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
}

// 注記: s.repo.Saveの後にs.eventBus.Publishが続くのは二重書き込みです。
// 本番環境では、トランザクショナルアウトボックスパターンに置き換えて、
// イベントが注文行とアトミックに書き込まれ、リレーによって公開されるようにします。

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. 楽観的補償: 操作を実行し、必要に応じて補償する

    • 例: まず支払いを請求し、在庫が利用できない場合は返金する

冪等性要件

すべての操作と補償は冪等である必要があります。これにより、失敗した操作を再試行しても重複した影響が発生しないようになります。同様に重要なのは、各Saga参加者がローカルコミット後にイベントを確実に公開することです。データベース書き込みとブローカー公開の間のギャップを埋めるための標準的な方法は、トランザクショナルアウトボックスパターンです。

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ベースの実装の場合、Saga状態ストレージのニーズに最も適したものを選択するために、PostgreSQL用Go ORMの比較: GORM vs Ent vs Bun vs sqlcの比較を検討してください:

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ジェネリクス: 使用例とパターンを参照してください。

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インターフェースを必要とするオーケストレーターサービスの場合、Cobra & ViperによるGoでのCLIアプリケーションの構築は、Sagaオーケストレーターと対話するためのコマンドラインツールを作成するための優れたパターンを提供しています。

KubernetesでSagaベースのマイクロサービスをデプロイする際、サービスメッシュを実装すると、可観測性、セキュリティ、トラフィック管理が大幅に向上します。IstioとLinkerdによるサービスメッシュの実装は、サービスメッシュが分散トレーシングやサーキットブレーキングなどの横断的な懸念事項を提供することで、分散トランザクションパターンをどのように補完するかを説明しています。

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

次の場合にSagaパターンを使用してください:

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

次の場合は避けてください:

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

結論

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

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

有用なリンク

購読する

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