モノリスが与える偽りの安心感
単一データベースのモノリスから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ステップのフローから始め、補償ロジックをマスターし、常にネットワークは失敗するものだと想定して設計してください。

