複数サービス間でのデータの整合性維持に向けたSagaの実装

マイクロサービスアーキテクチャにおいては、個々が独立に選定したデータベースを持つ複数のサービスにまたがって、データの整合性を維持する必要があります。 そのための方法として、Sagaパターンと呼ばれる設計方法がありますが、Sagaでは分離性が欠如しておりLost Update等の異常が発生しかねません。 そこで本記事では、Sagaの分離性を高めるための実装におけるTipsを解説します。

目次

はじめに

この記事は、 NTT Communications Advent Calendar 2023 12日目の記事です。

こんにちは、クラウド&ネットワークサービス部の川瀬(GitHub: hkws)です。業務では主に、NTT Comで提供しているサービスのバックエンド開発を担当しており、特に社内外の複数サービスをオーケストレーションする部分を実装してきました。

本記事では、複数のサービス間でデータの整合性を維持するための方法であるSagaパターンを紹介し、Sagaの分離性の欠如により起きうる異常と、その予防・軽減策を解説します。

複数サービス間での整合性維持における課題

複数のサービス間で整合性を持ってデータを更新しなければならないケースとして、オンラインショッピングサービスでの商品の購入やキャンセルをするという場面を検討してみましょう。ここで、オンラインショッピングサービスは以下のように、注文管理サービス、在庫管理サービス、決済サービスにより構成され、個々のサービスが独自にデータベースを持つDatabase per Serviceの構成とします。

ユーザにより商品が購入されると、以下のような処理が実行されるとします。

  • A1 在庫管理サービスが、購入された商品の在庫を確保
  • A2 注文管理サービスが、注文内容を記録する
  • A3 決済サービスが、決済処理を行う
  • A4 配送サービスが、配送依頼を記録する
  • A5 メール送信サービスが、ユーザに購入完了メールを送信

その後、もし商品購入のキャンセルがリクエストされた場合には、以下のような処理を行うとします。

  • B1 配送サービスが、配送依頼を取り下げる
  • B2 在庫管理サービスが、購入された商品の在庫を解放する
  • B3 注文管理サービスが、注文のキャンセルを記録する
  • B4 決済サービスが、返金処理を行う
  • B5 メール送信サービスが、ユーザに購入キャンセル完了メールを送信

商品の購入(A1 ~ A5)、および商品購入のキャンセル(B1 ~ B5)は、複数のサービスにまたがってデータの変更が生じる処理です。これらの処理はトランザクショナルに、すなわちひとまとまりの処理として行われなければなりません。そうでなければ、決済処理が完了しなかったのに注文は記録された(A3で失敗した場合)、在庫が確保されているのに対応する注文が存在しない(A2で失敗した場合)といったデータの不整合が生じうるからです。

このように、複数のサービス(参加者)にまたがった処理をアトミックに行うため、2相コミット(2PC)のような分散トランザクション管理を利用することも選択肢の1つかもしれません。 しかしながら、2PCには以下のような課題があります。

  • レプリケーションされていないコーディネータは、システム全体にとっての単一障害点になってしまいます。
  • 各参加者は参加者全員がコミットできるまでロックを保持し続けるため、ロックが長期化する傾向にあります。特に、コーディネータに障害があると、参加者は未確定のトランザクションによるロックを保持したままになってしまいます。
  • トランザクションのコミットが成功するためには、すべての参加者が反応を返さなければなりません。参加者のいずれかが反応を返せなくなってしまうと、トランザクションは必ず失敗してしまうのです。
  • 一貫性を維持すべきサービスの全てが、2PCをサポートする必要があります。

このうち、2点目と4点目の課題を解決するためのアプローチが、Sagaパターンと呼ばれる方法です1。マイクロサービス間の疎結合性を損なわず、データの整合性を維持することを目指します。

Sagaパターン

Sagaとは、一連のローカルトランザクションのシーケンスによって、複数サービス間でのデータの整合性を維持する方法です。操作するリソースごとにトランザクションを分解してローカルトランザクションとし、それらは対応するデータベースを更新して、次のローカルトランザクションを実行するためのメッセージやイベントを発行します。これにより、各サービスにおいてロックの保持が長期化することを回避します。

Sagaパターンにおいては、個々のローカルトランザクションは単一データベースにおけるトランザクションと同等です。よって、個々のサービスが持つデータベースは、2PCのような分散トランザクションに対応する必要はありません。

ただし、独立にローカルトランザクションがコミットされてしまうことは、単一データベースのトランザクションや2PCのようなロールバックが、サービスに跨ってできないことを意味します。あるローカルトランザクションがロールバックされたとしても、それ以前にコミットされたローカルトランザクションによる変更は残ったままになってしまうのです。

そこでSagaでは、ローカルトランザクションによるコミットされた変更を打ち消すためのトランザクション(補償トランザクション)によりロールバックを行います。もちろん、補償トランザクションも失敗する可能性がありますし、ビジネス的な要件からそもそも補償トランザクションを実装できないこともありえます。その対策として、ローカルトランザクションが成功するまで自動的にリトライするよう実装しなければなりません。

Sagaを構成するトランザクション

Sagaは複数のローカルトランザクションのシーケンスとして構成されますが、各トランザクションはその振る舞いによって、以下の3つに分類できます。これらに対する理解は、後述する分離性の欠如への対策を検討する上で有用です。

  • 補償可能トランザクション(Compensatable Transaction)
    • 補償トランザクションによってロールバックされうるトランザクション
  • ピボットトランザクション(Pivot Transaction)
    • Sagaを最後まで実行するか、ロールバックするかを判断するポイントになるトランザクション
    • ピボットトランザクションがコミットされれば、Sagaは最後まで実行される
    • 補償可能トランザクションや再試行可能トランザクションとは異なるトランザクションになるとは限らず、Sagaにおける最後の補償可能トランザクションや、最初の再試行可能トランザクションになることがある
  • 再試行可能トランザクション(Retriable Transaction)
    • いつかは成功することが保証されているトランザクション
    • ロールバックが不要であるため、対応する補償トランザクションを持たない
    • ピボットトランザクションの前に配置される

前述したオンラインショッピングサービスにおける商品購入キャンセルSagaを、このモデルに照らし合わせると以下のようになります。

Step 種別 サービス トランザクション 補償トランザクション
1 補償可能 配送サービス 配送依頼の取り下げ 配送依頼取り下げのキャンセル
2 補償可能 在庫管理サービス 在庫の解放 在庫の再確保
3 補償可能 注文管理サービス 注文キャンセルの記録 注文キャンセルの取り消し
4 ピボット 決済サービス 返金処理の実施 -
5 再試行可能 メール送信サービス ユーザへの購入キャンセル完了メール送信 -

Sagaによって実現される安全性

2PCのような分散トランザクション管理を利用せず、Sagaという代替策を利用するデメリットとして、トランザクションが提供する安全性を享受できないことがあげられます。ここでは、ACID特性と呼ばれる4つの性質(原子性、整合性、分離性、永続性)をSagaがどれほど満たしているか検討します。

原子性(Atomicity)

原子性とは、いくつかの書き込みがオールオアナッシングで行われるという性質です。グループ化された複数の書き込みが障害のため完了しなかった場合、その時点までに行われた書き込みは全て破棄されます。 MySQLやPostgreSQLでは、START TRANSACTIONによってトランザクションを開始し、複数の書き込みを行っている最中に障害が発生した場合、 ROLLBACKによって変更を取り消すことができました。しかしSagaパターンの場合、複数のローカルトランザクションのシーケンスにより書き込みを行うため、今までに行った変更の全てを単一のコマンドで取り消すことはできません。Sagaでは、補償トランザクションを順々に実行していくことで、ロールバックを実現します。

一般には、補償トランザクションによりロールバックできる点を踏まえ、Sagaは原子性を満たすと考えられています。しかしながら、原子性の実現には補償トランザクションが行うような巻き戻し機能だけでなく、分散合意や状態遷移先の出力といった機能も必要であり、Sagaにはそれが不足しているという指摘もあります。

整合性(Consistency)

トランザクションの文脈において「整合性がある」とは、データについて常に真でなければならない何らかの言明(不変性)を満たしていることを示します。例えば、会計システムにおいて貸方と借方が等しくなければならないことは、会計システムにおける不変性の1つと言えるでしょう。 整合性は、原子性、分離性、永続性とは異なり、データベースのみで保証してくれる特性ではありません。外部キー制約やユニーク制約等があるとはいえ、アプリケーションがデータベースへ不変性に違反する書き込みを行ったとしても、データベースはそれを止めることはできません。 このことは、Sagaパターンの場合も同様です。Sagaパターンによって複数のサービスに書き込みを行う場合、単一のデータベースに対する書き込みと同じく、不変性を満たすようSagaを実装しなければなりません。

Sagaオーケストレーションフレームワーク Eventuate Tram の開発者であるChris Richardsonは、サービス内の参照完全性はローカルデータベースによって実現され、サービスの壁を超えた参照完全性はアプリケーションによって実現されるため、整合性を有すると主張しています。しかし、Sagaというアイデア自体が整合性を担保する仕組みを内包しているわけではないことから、筆者は整合性を有するとは言えないと考えています。

分離性(Isolation)

分離性とは、並行に実行されているトランザクション同士が、お互いに干渉できる度合いを示したものです。特に、複数のトランザクションを同時に実行した結果が、それぞれを順番に実行した時と同じ結果になることを保証する場合、直列化可能という最も強い分離性を有することになります。 Sagaは明らかに、分離性を満たしていません。Sagaの個々のローカルトランザクションによって変更がコミットされると、Sagaが全ステップを実行し終えていなくとも、その変更が他のSagaからも見えてしまいます。

永続性(Durability)

永続性とは、トランザクションのコミットが成功したら、仮にハードウェアの障害やデータベースのクラッシュがあったとしても、そのトランザクションで書き込まれた全てのデータは失われないという特性です。 Sagaパターンにおいて、永続性はローカルトランザクションのコミットによって実現されます。


このように、Sagaは分離性のみが欠如しているという意見がある一方、理論的な検討から原子性、整合性、分離性の3つが欠如しているという指摘もあるようです。 本記事では以降、両者が共通して指摘している分離性の欠如について、それが引き起こす異常とその軽減策を解説します。

異常を防止/軽減する実装

分離性の欠如が引き起こす異常

Sagaも単一のデータベースと同様、同時に複数のクライアントから実行される可能性があるものの、分離性を満たしていません。そのため、以下のような問題の発生が考えられます。

  • Lost Updates: ある Saga が、別の Saga による変更を読み取らずに書き込みを行うこと
  • Dirty Reads: Saga が更新をまだ完了しないうちに、トランザクションまたは Saga がその更新を読み取ること
  • Fuzzy/Nonrepeatable Reads: 読み取りと読み取りの間にデータ更新が発生したため、別の Saga ステップが別のデータを読み取ること

MySQLやPostgreSQLでは、このような分離性の問題に対応するため複数のトランザクション分離レベルを備えています。しかしSagaでは、これらの問題を防止・軽減するための実装をアプリケーション開発者が行わなければなりません。

分離性の欠如への対策

Semantic Lock

分離性を高める方法の1つは、アプリケーションレベルでロックすることです。 作成または更新するレコードにフラグを設定することで、このレコードがコミットされておらず、変更される可能性があることを示します。 Semantic Lockを導入する際、当該レコードに対して別のSagaが読み取りや書き込みを試みた場合に、そのSagaがどのように振る舞うかも検討しなければなりません。 最も素朴な方法は、変更される可能性のあるレコードに対する操作を試みたSagaの実行を失敗させ、クライアントに再試行させることでしょう。これは、実装が単純なものの、クライアントに再試行の負担を強いることになります。また、ロックが解放されるまでリクエストをキューイングするという方法もあります。こちらはクライアントの再試行の手間を無くすことができますが、ロックの管理やデッドロックの検出など、サーバ側の実装が複雑になります。

前述したオンラインショッピングサービスの商品購入は、Semantic Lockの導入により以下のようなシーケンスになるでしょう。

注文内容の記録を1ステップ目(A'1)で実施し、その際注文ステータスを「処理中」に設定するよう変更しました。そして、新たに6ステップ目(A'6)を追加し、注文ステータスを「出荷準備中」にしています。このようにすることで、ステータスが「処理中」である注文は変更される可能性があることを示すことができます。

その上で商品購入キャンセル Saga では、以下のようにキャンセルしようとする注文が処理中かどうかを確認し、処理中である場合はキャンセル処理を中止します。これにより、商品購入と商品購入キャンセルの分離性を高めることができます。

Commutative Updates

分離性の欠如によって生じるLost Updatesは、正しい順序で更新操作が実施されなかった場合に発生する問題です。そこで、どのような順序で実行されても正しい更新結果が得られるよう、更新の仕方を工夫しようというアイデアがCommutative Updatesです。

銀行口座への振り込みや引き落としは、(引き落とし額が預金額を上回らない限り)可換な更新の一例です。例えば、残高が300,000円である銀行口座に対して、給与210,000円の振込と家賃70,000円の引き落としが同時にあったとしましょう。このとき、「口座残高を510,000円にする」という操作A、および「口座残高を230,000円にする」操作Bは可換ではありません。A -> Bの順序で実行された場合には給与の振込操作Aが、B -> Aの場合は家賃引き落とし操作Bがロストしてしまいます。しかし、「口座残高を210,000円増やす」という操作A'と「口座残高を70,000円減らす」という操作B'は可換です。どちらが先に実行されても、口座残高は440,000円になります。

よって、補償可能トランザクションを「口座残高を210,000円増やす」、対する補償トランザクションを「口座残高を増やす前(ここでは300,000円)の値に設定し直す」と定義してしまうと、後者は可換な更新ではないためLost Updatesの可能性が生じます。しかし、その補償トランザクションを「口座残高を210,000円減らす」と定義することで、他のSagaによる更新を上書きする危険を低減できます。

Pessimistic View / Optimistic View

開発しているアプリケーションの問題領域によっては、特定の属性について上限や下限を設定しなければならないことがあります。例えばコンサートの残空席は、(ビジネス上オーバーブッキングを許容する場合を除き)0を下回ることは許されません。 しかし、分離性の欠如によりDirty Readsが発生することで、定められた下限や上限を超えてしまう可能性があります。

Sagaでは、原子性を実現するために補償トランザクションが定義されますが、通常直近に実行した補償可能トランザクションに対する補償トランザクションから順に実行されていきます。すべての補償トランザクションが即座に実行されるわけではないため、Sagaの初期のローカルトランザクションで更新される値ほど不整合な値をとる時間が長くなってしまいます。

以下のシーケンス図にて具体例を示します。これは、前述したオンラインショッピングサービスにおいて、ユーザAにより商品Xの購入のキャンセルと、ユーザBによる商品Xの購入が同時に発生したケースです。ユーザA、ユーザBともに注文した商品Xは1つであり、ユーザAによる購入によって商品Xは在庫がなくなっていたものと仮定します。

ユーザAは商品購入のキャンセルを試みましたが、決済処理でエラーが発生したため、補償トランザクションが実行開始されたとします。このときから在庫解放処理に対する補償トランザクションが実行されるまで、在庫は開放された状態になっています。よってその間に同じ商品の購入をユーザBが試みると、在庫があるように見えるため成功してしまいます。しかし実際は、ユーザAによる商品Xの購入キャンセルは失敗したため、依然としてユーザAも商品Xを購入できている状態です。結果、提供できる商品は1つしかないものの、ユーザA、ユーザBともに購入できてしまいます。

Pessimistic Viewは、Sagaのステップの並びを工夫することによって、Dirty Readsが発生する可能性を小さくしようというアイデアです。

先程の商品購入キャンセルを行うSagaの場合、以下のように並び替えることで、商品在庫のDirty Readsを低減できるでしょう。商品在庫の解放を再試行可能トランザクションとすることで、在庫の再確保を不要にできるからです。

Step 種別 サービス トランザクション 補償トランザクション
1 補償可能 配送サービス 配送依頼の取り下げ 配送依頼取り下げのキャンセル
2 補償可能 注文管理サービス 注文キャンセルの記録 注文キャンセルの取り消し
3 ピボット 決済サービス 返金処理の実施 -
4 再試行可能 在庫管理サービス 在庫の解放 -
5 再試行可能 メール送信サービス ユーザへの購入キャンセル完了メール送信 -

一般化すると、Pessimistic View / Optimistic Viewは以下のように実装されます。

  • 下限が存在するリソース(在庫や座席など)について
    • ユーザの選択肢を制限する操作(座席予約など)は、補償可能トランザクションで行う
    • ユーザの選択肢を増やす操作(座席予約のキャンセルなど)は、再試行可能トランザクションで行う
  • 上限が存在するリソース(利用可能な課金額など)について
    • ユーザの選択肢を増やす操作(利用可能な課金額の増加など)は、補償可能トランザクションで行う
    • ユーザの選択肢を制限する操作(利用可能な課金額の減少など)は、再試行可能トランザクションで行う

Reread Value

データ更新が発生する個々のステップの中でも、分離性を高めるための工夫が可能です。 他のサービスに対するデータ更新を要求する直前に、更新対象データを要求することで、直近の状態に対して更新をかけることができます。

ただし、他のサービスとの通信が増えてしまうため、速度が重要になるサービスにおいては適用すべきではないかもしれません。 また、更新対象データの取得後から更新するまでの間に、別のクライアントによってデータが更新された場合には対応できません。

Version File

同時並行に複数のSagaが実行される場合、連携する各サービスにどのような順序でリクエストが届くかはわかりません。 例えば、商品購入と商品購入キャンセルが同時に実行された場合を考えます。注文管理サービスには「注文を記録する」リクエストと「注文をキャンセルする」リクエストが届きますが、Semantic Lockを使っていなければ、「注文を記録する」リクエストのあとに必ず「注文をキャンセルする」リクエストが届くとは限りません。まだ注文が存在しないのに、注文のキャンセル要求だけが届いてしまうこともありえます。

この対策として、注文管理サービスにおいてどのようなリクエストが届いたかを記録しておき、正しい順序で実行するという手段がありえるでしょう。「注文をキャンセルする」リクエストが「注文を記録する」リクエストより先に到着したとしても、注文管理サービスは「注文を記録」した後に「注文をキャンセル」するのです。

By Value

By Valueは、リクエストの内容によって動的にロジックを切り替えるという手法です。例えばリスクの低い要求は saga で実行し、リスクの高い要求は分散トランザクションで実行するのです。 なにがリスクであり、それをどのように分類するかは、ビジネス要件を考慮しながら定義する必要があるでしょう。 オンラインショッピングサービスの場合は、法人からの大口の注文は分散トランザクションによって処理し、個人からの小口の注文についてはSagaで処理するといったロジックの切り替えがありえるかもしれません。

終わりに

本記事では、Sagaの弱点の1つである分離性の欠如に由来する問題を予防・軽減ためのさまざまな方法を解説しました。 これらの方法全てを活用することが、いつも正しいとは限りません。システムに対する要求と照らし合わせながら取捨選択し、安全性の高いサービス間連携を実現していきましょう。

それでは、明日の記事もお楽しみに!

参考文献


  1. Sagaを導入しても1点目と3点目の課題は残ります。Sagaは一般にオーケストレーションベースで実装されることが多く、オーケストレーターが単一障害点になります。また、Sagaでも参加者のいずれかが反応を返せなくなった場合は実行に失敗し(失敗箇所が補償可能な場合は)ロールバックされます。
© NTT Communications Corporation All Rights Reserved.