Sagaパターン:マイクロサービスにおけるデータ整合性の解決策

Database tutorial - IT technology blog
Database tutorial - IT technology blog

モノリスが与える偽りの安心感

単一データベースのモノリスから15個のサービスで構成されるマイクロサービス群へ移行した際、データ整合性の現実に直面するまでは、それが大幅なアップグレードのように感じられました。モノリスでは、ACIDトランザクションに頼ることができました。1つのBEGIN/COMMITブロックの中に10個の異なるデータベース呼び出しを詰め込むことができ、途中でサーバーがクラッシュしてもデータベースがロールバックを処理してくれました。すべてが魔法のように機能していたのです。

マイクロサービスでは、そのセーフティネットは失われます。注文サービス、決済サービス、在庫サービスはそれぞれ異なるデータベース(PostgreSQLやMongoDBの混在など)を使用している可能性が高いです。2フェーズコミット(2PC)を使用してこれらのノード間でグローバルなトランザクションを行おうとすると、通常、ネットワークリクエストの遅延ですぐに失敗する、低速で脆弱なシステムになってしまいます。これが、私がSagaパターンに依存している理由です。

リレー競技:Sagaの解剖学

Sagaをローカルトランザクションのリレー競技だと考えてみてください。各サービスが自身の処理を行い、ローカルデータベースを更新し、次のサービスにバトンを渡すための合図を送ります。クレジットカードの承認拒否や在庫切れなどでサービスが失敗した場合、Sagaは一連の「補償トランザクション」をトリガーして、前のステップを取り消します。

私は通常、ワークフローの複雑さに応じて2つの実装スタイルのいずれかを選択します。

1. コレオグラフィ:分散型のダンス

2、3ステップの単純なフローには、コレオグラフィを好んで使います。中央の「ボス」は存在しません。各サービスがイベントを発行し、他のサービスがそれに反応します。軽量ですが、注意しないとすぐに「イベントスパゲッティ」状態に陥る可能性があります。

  • 注文サービス: 注文を「保留中」として保存し、OrderCreatedを発行する。
  • 決済サービス: イベントを検知し、49.99ドルの決済を処理して、PaymentSuccessfulを発行する。
  • 在庫サービス: SKU-101の在庫を1ユニット割り当て、InventoryReservedを発行する。

2. オーケストレーション:中央の指揮者

ビジネスプロセスが5つ以上のサービスに関わる場合は、オーケストレーターに切り替えます。これは、各サービスに何をすべきかを明示的に指示する中央集権的なステートマシンです。5,000ドルのトランザクションの状態全体を一箇所で把握できるため、デバッグが非常に容易になります。

# Pythonでのオーケストレーターのロジック
class OrderSagaOrchestrator:
    def execute(self, order_id, amount):
        try:
            # ステップ1:ユーザーに課金する
            payment_ref = payment_api.charge(amount)
            # ステップ2:アイテムを確保する
            inventory_api.reserve(order_id)
            # ステップ3:確定する
            order_db.mark_as_paid(order_id)
        except Exception as error:
            self.rollback(order_id, payment_ref)

    def rollback(self, order_id, payment_ref):
        # 払い戻しを行う
        payment_api.refund(payment_ref)
        # 注文をキャンセルする
        order_db.cancel(order_id)

秘訣:補償トランザクション

成功パスは簡単です。Sagaの成否を分けるのは失敗パスです。SQLのロールバックとは異なり、補償は前の処理を論理的に逆転させる*新しい*トランザクションです。ユーザーに確認SMSを送信してしまった場合、それを「未送信」にすることはできません。キャンセルの理由を説明する2通目のSMSを送信する必要があります。

Sagaは**ACD**の原則に従います。原子性(Atomicity)、一貫性(Consistency)、耐久性(Durability)は提供しますが、**隔離性(Isolation)**を欠いています。つまり、Sagaの実行中に、他のサービスが「保留中」の中間状態を見ることができてしまいます。そのため、UIでは「確定」チェックマークをすぐに出すのではなく、「処理中」のスピンを表示するなど、これを考慮した設計にする必要があります。

「元に戻す」ボタンの設計

すべてのAPIエンドポイントに、対応する取り消し戦略を用意するようにしています。

  • アクション: ReserveStock(5ユニット減らす) -> 補償: ReleaseStock(5ユニット増やす)
  • アクション: ApplyDiscount -> 補償: RemoveDiscount
  • アクション: CreateShippingLabel -> 補償: VoidShippingLabel

これらのフローのモックデータを管理するのは面倒な作業です。ローカルのマイクロサービスをテストするために、大きなCSVカタログをJSONオブジェクトに変換する必要があるときは、toolcraft.app/ja/tools/data/csv-to-jsonを使用しています。ブラウザ上でローカルに動作するため、機密性の高いテストデータが外部サーバーに送信されるのを防ぎ、開発ループを高速化できます。

本番環境への強化:べき等性と信頼性

分散環境では、ネットワークの不具合により、サービスが同じメッセージを2回受信することが*必ず*起こります。もし在庫サービスがPaymentSuccessfulイベントを2回処理してしまうと、誤って在庫を2重に差し引いてしまいます。

1. べき等キー

一意の識別子(UUIDなど)なしでトランザクションを処理することはありません。サービスはデータベースを確認する必要があります。「order_6789は既に処理済みか?」とはい、であれば重複を無視し、キャッシュされた成功レスポンスを返します。

2. トランザクショナルアウトボックスパターン

データベースを更新してから、別のステップでRabbitMQにメッセージを送信しようとしてはいけません。ブローカーがダウンしていると、データベースと外部の世界との同期が取れなくなります。代わりに、ビジネスデータと同じローカルトランザクション内で、メッセージをoutboxテーブルに保存します。その後、バックグラウンドワーカーがそれらのメッセージを確実にブローカーにプッシュします。

現場で学んだ教訓

数千の同時リクエストを処理するシステムでSagaをスケールさせた経験から、重要なポイントを挙げます:

  • 観測可能性は必須: すべてのログにcorrelation_idを付加してください。注文が120秒間停止した場合、5つの異なるサービスのログを横断して、チェーンのどこが切れたのかを正確に特定する必要があります。
  • トランザクションは短く: 隔離性がないため、実行時間の長いトランザクションはレースコンディションのリスクを高めます。200ミリ秒以内で終わるローカルトランザクションを目指しましょう。
  • 厳格なタイムアウトの設定: 決済ゲートウェイが10秒以内に応答しない場合は、いつまでも待たないでください。自動的に補償フローをトリガーして、確保した在庫を解放します。
  • 循環依存を避ける: コレオグラフィにおいて、サービスAがサービスBを待ち、サービスBがサービスAを待つという状況を避けてください。分散デッドロックに陥ります。

Sagaは標準的なSQLトランザクションよりも複雑です。しかし、データの破損に悩まされない、回復力のあるマルチデータベースアーキテクチャを構築するために私が見つけた唯一の方法です。まずは小規模な2ステップのフローから始め、補償ロジックをマスターし、常にネットワークは失敗するものだと想定して設計してください。

Share: