キーポイント
- Sagas allow for the implementation of long-running, distributed business transactions, executing a set of operations across multiple microservices, applying consistent all-or-nothing semantics.
- For the sake of decoupling, communication between microservices should preferably happen asynchronously, for instance using distributed commit logs like Apache Kafka.
- The outbox pattern provides a solution for service authors to perform writes to their local database and send messages via Apache Kafka, without relying on unsafe “dual writes.”
- Debezium, a distributed open-source change data capture platform, provides a robust and flexible foundation for orchestrating Saga flows using the outbox pattern.
マイクロサービスに移行するときに最初に気付くのは、個々のサービスが独立して存在するわけではないということです。目標は、相互作用をできるだけ少なくして疎結合の独立したサービスを作成することですが、あるサービスが別のサービスが所有する特定のデータセットを必要とするか、私たちのビジネスの領域でのオペレーションの合致する成果を達成するために複数のサービスが協調して動作する必要があります。
変更データキャプチャを介して実装されるOutboxパターンは、マイクロサービス間のデータ交換の関心事に対処する実証済みのアプローチです。例えば、データベースやメッセージブローカなどの複数のリソースへの安全でない「二重書き込み」を回避するOutboxパターンは、すべての参加者の同期した機能に依存せず、XA (The Open Groupによって定義された分散トランザクション処理の広く使用される標準) のような複雑なプロトコルを必要とせずに結果整合性のあるデータ交換を実現します。
この記事では、Outboxパターンを次のレベルに引き上げ、それを使用してSaga (複数のマイクロサービスにまたがる潜在的に長期にわたるビジネストランザクション) を実装する方法を探求したいと思います。1つの一般的な例は、複数の部分で構成される旅行を予約する場合です。すべての航程と宿泊施設を一緒に予約するか、いずれもしないか。Sagaは、このような包括的なビジネストランザクションを、参加しているサービスによって実行される一連の複数のローカルデータベーストランザクションに分割します。
Saga入門
障害が発生した場合に包括的なビジネストランザクションを「ロールバック」するために、Sagaは補償トランザクションの考えに依存しています。以前に適用された各ローカルトランザクションは、以前に行われた変更の反転を適用する別のトランザクションを実行することによって「元に戻す」ことができなければなりません。
Sagaは新しい概念では決してありません。最初、Hector Garcia-Molina氏とKenneth Salem氏がSIGMOD ’87のSagas論文で論じました。しかし、マイクロサービスアーキテクチャへの継続的な動きの中で、参加するサービス内のローカルトランザクションに裏打ちされたSagaは、現在活発に開発されている MicroProfile specification for Long Running Actions に示されているように、最近人気が高まっています。Sagasは、特により長い時間を要するトランザクションフローの実装に適しています。これは、通常ACIDセマンティクスでは対処できません。
考えを具体的にするために、注文、顧客、支払いの3つのサービスがあるeコマースビジネスの例を考えてみましょう: 新しい注文書が注文サービスに送信されると、他の2つのサービスを含めて次のフローが実行されます。
図1. 注文状態トランザクション
まず、顧客サービスに、受注が顧客の与信限度額に収まるかどうかを確認する必要があります (1人の顧客の保留中の注文が特定のしきい値を超えることを望まないため)。顧客の与信限度額が$500で、価格が$300の新しい注文が入った場合、この注文は現在の限度額に収まり、残りの限度額は$200になります。後続の$250の価格の注文は、顧客のその時点で開かれた与信限度額を超えるために拒否 (reject) されます。
与信限度額の確認に成功した場合は、支払いサービスを介して注文の支払いを要求する必要があります。与信限度額の確認と支払い要求の両方が成功すると、注文は Accepted
状態に移行し、その履行が開始されます (これはここで説明するプロセスの一部ではありません)。
ただし、与信限度額の確認に失敗した場合、注文はすぐに Rejected
状態になります。そのステップは成功して、後続の支払い要求が失敗した場合は、Rejected
状態に移行する前に、以前に割り当てられた与信限度額を再度解放する必要があります。
実装の選択
分散Sagaを実装するには、コレオグラフィーとオーケストレーションの2つの一般的な方法があります。コレオグラフィーアプローチでは、参加している1つのサービスが、ローカルトランザクションを実行した後、次のサービスにメッセージを送信します。一方、オーケストレーションでは、参加者を次々に呼び出す1つの調整サービスがあります。
どちらのアプローチにも長所 (pros) と短所 (cons) があります (たとえば、詳細については、Chris Richardson氏によるこの投稿とYves do Régo氏によるこの投稿を参照してください) 。個人的には、オーケストレーションアプローチを好んでいます。オーケストレーションアプローチは、特定のSaga (オーケストレーター、またはSagaエグゼキューションコーディネータ、略してSEC) の現在のステータスを取得するために照会できる1つの中心的な場所を定義するためです。(オーケストレーター以外の) 参加者間のポイントツーポイント通信を回避するため、各参加者間の調整を必要とせずに、フロー内にさらに中間ステップを追加することもできます。
このようなSagaフローの実装に飛び込む前に、Sagaが提供するトランザクションセマンティクスについていくらか時間をかけて考える価値があります。(Jim Gray氏による初期の研究に基づく) Theo Härder氏とAndreas Reuter氏の基本的な論文「Principles of Transaction-Oriented Database Recovery」で定義されているように、Sagaがトランザクションの4つの古典的なACIDプロパティをどのように満たすかを調べてみましょう:
- Atomicity: ✅ — このパターンにより、すべてのサービスがローカルトランザクションを適用するか、障害が発生した場合に、すでに実行されているすべてのローカルトランザクションが補正され、データの変更が効果的に適用されなくなります。
- Consistency: ✅ — Sagaを構成するすべてのトランザクションが正常に実行され、システム全体が1つの一貫した状態から別の状態に移行後に、すべてのローカル制約が満たされることが保証されます。
- Isolation: ❌— Sagaの実行中にローカルトランザクションがコミットされるため、Sagaが最終的に失敗し、以前に適用されたすべてのトランザクションが補正される可能性があるにもかかわらず、それらの変更は他の並行トランザクションですでに表示可能です。つまり、Saga全体の観点からは、分離レベルは「非コミット読み取り (read uncommitted)」と同等です。
- Durability: ✅ — Sagaのローカルトランザクションがコミットされると、それらの変更は恒久的に永続化されます。たとえばサービスの障害が発生したり再起動した後など。
サービスコンシューマの観点からは (たとえば、注文サービスで注文書を出すユーザ)、システムには結果整合性があります。つまり、さまざまな参加サービスのロジックに従って、注文書が正しい状態になるまでに時間がかかります。
参加しているサービス間の通信の懸念に関する限り、これは (たとえばHTTPやgRPCを介して) 同期的に、あるいは (たとえばメッセージブローカやApache Kafkaなどの分散ログを介して) 非同期的に発生する可能性があります。可能な場合は常にサービス間に非同期通信を優先すべきです。これは、送信サービスを消費サービスの可用性から解くためです。また、次のセクションで説明するように、変更データキャプチャのおかげで、Kafka自体の可用性さえも問題になりません。
総括: Outboxパターン
さて、Outboxパターンと変更データキャプチャ (Debeziumによって提供される) は、これらすべてにどのように適合するのでしょうか? 上記のように、Sagaコーディネーターは、リクエストおよび応答メッセージチャネルを介して、参加しているサービスと非同期的に通信することが望ましいです。Apache Kafkaは、これらのチャネルを実装するための非常に人気のある選択肢です。ただし、オーケストレーター (および参加している各サービス) は、Sagaフロー全体の一部を実行するために、特定のデータベースにトランザクションを適用する必要もあります。
単にデータベーストランザクションを実行し、その直後に対応するメッセージをKafkaに送信したくなるかもしれませんが、これは良い考えではありません。これらの2つのアクションは、データベースとKafkaにまたがり単一のトランザクションで発生しません。たとえば、データベーストランザクションはコミットされても、Kafkaへの書き込みが失敗することがあり、一貫性のない状態になるのは時間の問題です。ただし、友だちは友だちへの二重書き込みを実行しません。Outboxパターンは、この問題に対処するための非常に洗練された方法を提供します:
図2. Outboxパターンを介した安全なデータベースの更新とKafkaへのメッセージ送信
データベースの更新時にKafkaに直接メッセージを送信する代わりに、サービスは単一のトランザクションを使用して、通常の更新を実行し、データベース内の特定のOutbox (送信トレイ) テーブルにメッセージを挿入します。これは単一のデータベーストランザクション内で行われるため、サービスのモデルへの変更が保持され、メッセージがOutboxテーブルに安全に保存されるか、これらの変更が適用されないかのいずれかになります。トランザクションがデータベースのトランザクションログに書き込まれると、Debezium変更データキャプチャプロセスはそこからOutboxメッセージを取得し、Apache Kafkaに送信できます。
これは、「少なくとも1回 (at-least-once)」のセマンティクスを使用して行われます。特定の状況では、同じOutboxメッセージがKafkaに複数回送信される可能性があります。コンシューマが重複したメッセージを検出して無視できるようにするには、各メッセージに一意のIDを付けなければなりません。これは、たとえば、Kafkaメッセージヘッダとして伝播される、各メッセージプロデューサ固有のUUIDや単調に増加するシーケンスで可能です。
Outboxパターンを使用したSagaの実装
ツールボックスのOutboxパターンを使用すると、考えが少し明確になります。Sagaコーディネーターとして機能する注文サービスは、(通常はREST APIを介して) 注文プレースメントへの着信後にフロー全体をトリガし、永続化した注文モデルとSaga実行ログを網羅してローカル状態を更新することにより、他の2つの参加サービスに次々にメッセージを送信します。
これら2つのサービスは、Kafkaを介して受信したメッセージに反応し、データ状態を更新するローカルトランザクションを実行し、Outboxテーブルを介してコーディネーターに応答メッセージを送信します。全体的なソリューション設計は次のようになります:
図3. Outboxパターンを使用したSagaオーケストレーション
このアーキテクチャの完全な概念実証の実装は、GitHubのDebeziumのサンプルリポジトリにあります。アーキテクチャの重要な部分は次のとおりです:
- 注文 (注文書の管理とSagaオーケストレーターとしての役割を果たす)、顧客 (顧客の与信限度額の管理)、支払い (クレジットカード支払いの処理) の3つのサービスには、それぞれ固有のローカルデータベース (Postgres) があります。
- メッセージングバックボーンとしてのApache Kafka
- Debeziumは、Kafka Connect上で実行され、3つの異なるデータベースの変更をサブスクライブし、DebeziumのOutboxイベントルーティングコンポーネントを使用して、対応するKafkaトピックに送信します。
3つのサービスは、JVMで実行されるか、(GraalVMを介して) ネイティブバイナリにコンパイルされるクラウドネイティブマイクロサービスを構築するためのスタックであるQuarkusを使用して実装されます。もちろん、Kafkaからのメッセージを消費してデータベースに書き込む手段が提供される限り、他のスタックや言語を使用してパターンを実装することもできます。また、異なる実装技術を組み合わせることも可能です。
関係する4つのKafkaトピックがあります。クレジット承認メッセージのリクエストとレスポンスのトピック、および支払いメッセージのリクエストとレスポンスのトピックです。Sagaの実行が成功した場合、正確に4つのメッセージが交換されます。ステップの1つが失敗し、補償トランザクションが必要な場合、補償される各ステップのリクエストとレスポンスメッセージの追加のペアがあります。
図4. 成功時のSagaフローの実行シーケンス
各サービスは、独自のデータベースのOutboxテーブルを介して送信メッセージを送信します。そこから、メッセージはDebeziumを介してキャプチャされ、Kafkaに送信され、最終的に受信サービスによって消費されます。メッセージを送受信すると、オーケストレーターとして動作する注文サービスは、Sagaの進行状況をローカルの状態テーブルに保持します。(詳細は以下を参照してください) さらに、すべての参加者は、消費したメッセージのIDをジャーナルテーブルに記録して、後で重複した場合に特定します。
さて、フローの1つのステップが失敗した場合はどうなるのでしょうか? 顧客のクレジットカードの有効期限が切れているために支払いのステップが失敗したとしましょう。その場合、顧客サービスで以前に予約した与信額を再度解放する必要があります。そのために、注文サービスは顧客サービスに補償リクエストを送信します。少しズームアウトすると (DebeziumとKafkaの詳細は以前と同じであるため)、この場合のメッセージ交換は次のようになります:
図5. 補償でのSagaフローの実行シーケンス
サービス間のメッセージフローについて説明したので、次に注文サービスの実装の詳細について詳しく見ていきましょう。概念実証の実装は、単純なステートマシンの形式で汎用のSagaオーケストレーターを提供し、注文固有のSaga実装を提供します。これについては、以下でさらに詳しく説明します。注文サービスの実装の「フレームワーク」部分は、スキーマが次のようになっている sagastate
テーブル内のSaga実行の現在の状態を追跡します:
図6. Saga state テーブルのスキーマ
このテーブルは、Sagaログの役割を果たします。列は次のとおりです:
id
: 特定の注文書の作成を表す、特定のSagaインスタンスの一意の識別子currentStep
: Sagaの現在のステップ、例えば、「クレジット承認 (credit-approval)」または「支払い (payment)」payload
: 特定のSagaインスタンスに関連付けられた任意のデータ構造。たとえば、対応する注文書のIDや、Sagaのライフサイクル中に役立つその他の情報が含まれます。実装例ではペイロード形式としてJSONを使用していますが、例えば、スキーマレジストリに格納されているペイロードスキーマを使用して、Apache Avro などの他の形式を使用することも考えられます。status
: Sagaの現在のステータス、STARTED、SUCCEEDED、ABORTING
のいずれか、または、ABORTED
stepState
: 個々のステップのステータスを説明する文字列化されたJSON構造。例:"{\"credit-approval\":\"SUCCEEDED\",\"payment\":\"STARTED\"}"
type
: Sagaの名目上の型、例えば「注文プレイスメント」。1つのシステムでサポートされているさまざまな種類のSagaを区別するのに役立ちますversion
: 1つのSagaインスタンスへの同時更新を検出して拒否するために使用される楽観的ロックバージョン (この場合、失敗した更新をトリガするメッセージを再試行して、Sagaログから現在の状態を再ロードする必要があります)
注文サービスが顧客と支払いサービスにリクエストを送信し、Kafkaから返信を受け取ると、Sagaの状態がこのテーブルを更新します。sagastate
テーブルを追跡するためのDebeziumコネクタを設定することで、KafkaでのSagaの実行の進行状況をうまく調べることができます。
支払いが失敗した注文書の状態遷移は次のとおりです。まず、注文が入り、「クレジット承認 (credit-approval)」ステップが開始されます:
{
"id": "73707ad2-0732-4592-b7e2-79b07c745e45",
"currentstep": null,
"payload": "\"order-id\": 2, \"customer-id\": 456, \"payment-due\": 4999, \"credit-card-no\": \"xxxx-yyyy-dddd-9999\"}",
"sagastatus": "STARTED",
"stepstatus": "{}",
"type": "order-placement",
"version": 0
}
{
"id": "73707ad2-0732-4592-b7e2-79b07c745e45",
"currentstep": "credit-approval",
"payload": "{ \"order-id\": 2, \"customer-id\": 456, ... }",
"sagastatus": "STARTED",
"stepstatus": "{\"credit-approval\": \"STARTED\"}",
"type": "order-placement",
"version": 1
}
この時点で、「クレジット承認 (credit-approval)」リクエストメッセージはOutboxテーブルにも保持されています。これがKafkaに送信されると、顧客サービスはそれを処理して返信メッセージを送信します。注文サービスは、Sagaの状態を更新し、支払いステップを開始することによってこれを処理します:
{
"id": "73707ad2-0732-4592-b7e2-79b07c745e45",
"currentstep": "payment",
"payload": "{ \"order-id\": 2, \"customer-id\": 456, ... }",
"sagastatus": "STARTED",
"stepstatus": "{\"payment\": \"STARTED\", \"credit-approval\": \"SUCCEEDED\"}",
"type": "order-placement",
"version": 2
}
ここでも、メッセージはOutboxテーブルを介して送信されます。これが「支払い」リクエストです。これは失敗し、支払いシステムはこの事実を示す応答メッセージで応答します。これは、「クレジット承認 (credit-approval)」ステップが顧客システムを介して補償される必要があることを意味します。
{
"id": "73707ad2-0732-4592-b7e2-79b07c745e45",
"currentstep": "credit-approval",
"payload": "{ \"order-id\": 2, \"customer-id\": 456, ... }",
"sagastatus": "ABORTING",
"stepstatus": "{\"payment\": \"FAILED\", \"credit-approval\": \"COMPENSATING\"}",
"type": "order-placement",
"version": 3
}
それが成功すると、Sagaは最終状態になり、ABORTED
になります:
{
"id": "73707ad2-0732-4592-b7e2-79b07c745e45",
"currentstep": null,
"payload": "{ \"order-id\": 2, \"customer-id\": 456, ... }",
"sagastatus": "ABORTED",
"stepstatus": "{\"payment\": \"FAILED\", \"credit-approval\": \"COMPENSATED\"}",
"type": "order-placement",
"version": 4
}
サンプルのREADMEファイルの手順に従って、これを自分自信で試すことができます。ここには、注文の作成の成功と失敗のリクエストがあります。また、さまざまなサービスのOutboxテーブルから供給されたKafkaトピックで交換されたメッセージを調べるための手順もあります。
それでは、ユースケース固有の実装のいくつかの部分を見てみましょう。Sagaフローは、次のように注文サービスのRESTエンドポイント実装で開始されます:
@POST
@Transactional
public PlaceOrderResponse placeOrder(PlaceOrderRequest req) {
PurchaseOrder order = req.toPurchaseOrder();
order.persist();
sagaManager.begin(OrderPlacementSaga.class, OrderPlacementSaga.payloadFor(order));
return PlaceOrderResponse.fromPurchaseOrder(order);
}
|
入力される注文書を永続化する |
|
受注のSagaフローの発注を開始します |
SagaMananger.begin()
は、sagastate
テーブルに新しいレコードを作成し、OrderPlacementSaga
実装から最初のOutboxイベントを取得して、Outboxテーブルに永続化します。OrderPlacementSaga
クラスは、Sagaフローのすべてのユースケース固有の部分を実装します。
- Sagaフローの一部を実行するために送信されるOutboxイベント
- Sagaフローの一部を補償するためのOutboxイベント
- 他のSaga参加者からの返信メッセージを処理するためのイベントハンドラ
OrderPlacementSaga
の実装は、ここに全体を表示するには少し長すぎます (GitHubで完全なソースを見つけることができます) が、ここにはいくつかの重要な部分があります:
@Saga(type="order-placement", stepIds = {CREDIT_APPROVAL, PAYMENT}) 1️⃣
public class OrderPlacementSaga extends SagaBase {
private static final String REQUEST = "REQUEST";
private static final String CANCEL = "CANCEL";
protected static final String PAYMENT = "payment";
protected static final String CREDIT_APPROVAL = "credit-approval";
// ...
@Override
public SagaStepMessage getStepMessage(String id) { 2️⃣
if (id.equals(PAYMENT)) {
return new SagaStepMessage(PAYMENT, REQUEST, getPayload());
}
else {
return new SagaStepMessage(CREDIT_APPROVAL, REQUEST, getPayload());
}
}
@Override
public SagaStepMessage getCompensatingStepMessage(String id) { 3️⃣
// ...
}
public void onPaymentEvent(PaymentEvent event) { 4️⃣
if (alreadyProcessed(event.messageId)) {
return;
}
onStepEvent(PAYMENT, event.status.toStepStatus());
updateOrderStatus();
processed(event.messageId);
}
public void onCreditApprovalEvent(CreditApprovalEvent event) { 5️⃣
// ...
}
private void updateOrderStatus() { 6️⃣
if (getStatus() == SagaStatus.COMPLETED) {
PurchaseOrder order = PurchaseOrder.findById(getOrderId());
order.status = PurchaseOrderStatus.ACCEPTED;
}
else if (getStatus() == SagaStatus.ABORTED) {
PurchaseOrder order = PurchaseOrder.findById(getOrderId());
order.status = PurchaseOrderStatus.CANCELLED;
}
}
// ...
}
|
1️⃣ 実行順のSagaステップのID |
2️⃣ 指定されたステップで発行されるOutboxメッセージを返す |
|
3️⃣ 指定されたステップを補償するために発行されるOutboxメッセージを返す |
|
4️⃣ 「支払い」応答メッセージのイベントハンドラ。注文書のステータスとSagaのステータスを (onStepEvent() コールバックを介して) 更新する。ステータスに応じて、Sagaを完了するか、すべての補償メッセージを適用してロールバックを開始する |
|
5️⃣ 「クレジット承認 (credit approval)」返信メッセージのイベントハンドラ |
|
6️⃣ 現在のSagaステータスに基づいて、注文書ステータスを更新 |
...
this.outboxEvent.fire(CreditEvent.of(sagaId, CreditStatus.CANCELLED));
...
顧客と支払いサービスの実装は根本的に新しいものではないため、簡潔にするためにここでは省略します。こことここで完全なソースコードを見つけることができます。
うまくいかないとき
Sagasのような分散相互作用パターンを実装するための重要な部分は、障害シナリオでそれらがどのように動作するかを理解し、そのような予期しない状況でも (結果) 整合性が達成されるようにすることです。
Sagaのいずれかの手順の否定的な結果 (たとえば、クレジットカードが無効であるために支払いサービスが支払いを拒否した場合) は、ここでは失敗のシナリオではないことに注意してください。参加者がフロー全体の一部を正常に実行できず、その結果、適切な補償ローカルトランザクションが実行されることが明示的に予想されます。これはまた、そのような一般的に予想される実行の失敗がローカルデータベーストランザクションのロールバックを引き起こしてはならないことを意味します。そうしないと、応答メッセージがOutboxを介してオーケストレーターに返送されません。
それを念頭に置いて、考えられるいくつかの障害シナリオについて説明しましょう:
Kafkaメッセージのイベントハンドラが例外を発生する
ローカルデータベーストランザクションがロールバックされ、メッセージコンシューマは、メッセージを処理できたことをKafkaブローカに確認しません。ブローカはメッセージが処理されたという確認を受け取らないため、しばらくすると、確認の応答がされるまでメッセージを繰り返し再送信します。メッセージが処理されるまでSagaフローは続行できないため、このような状況を検出するには、監視が行われなければなりません。
Debeziumコネクタが、OutboxメッセージをKafkaに送信した後、ソースデータベースのトランザクションログでオフセットをコミットする前にクラッシュする
コネクタを再起動した後、最後にコミットされたログオフセットから開始して、Outboxテーブルからメッセージを読み取り続けます。その結果、一部のOutboxイベントが2回送信される可能性があります。そのため、一意のメッセージIDを使用する例で実装されているように、すべての参加者がべき等である必要があり、コンシューマはジャーナルテーブルを介して正常に処理されたメッセージを追跡します。
ネットワークの分割などが原因で、Kafkaブローカが実行されていないか、到達できない
Debeziumコネクタは、Kafkaが利用可能になり、再びアクセス可能になった後、作業を再開できます。それまで、Safaフローは自然に進むことはできません。
メッセージは処理されるが、Kafkaでの確認が失敗する
メッセージは再びコンシューマサービスに渡されます。コンシューマサービスはジャーナルテーブルでメッセージのIDを見つけ、重複したメッセージを無視します。
複数のSagaステップを並行して処理する場合のSaga状態テーブルの同時更新
オーケストレーターが参加サービスを次々にトリガするシーケンシャルフローについて説明しましたが、複数のステップを並行して処理するSagaの実装を想像することもできます。この場合、同時に到着する応答メッセージは、Saga状態テーブルを更新するために競合する可能性があります。この状況は、そのテーブルに実装された楽観的ロックによって検出され、Saga状態の置き換えられたバージョンに基づいて更新をコミットしようとするイベントハンドラが失敗し、ロールバックして、再試行します。
さらにいくつかのケースについて説明することもできますが、全体的な設計の一般的なセマンティクスは、少なくとも1回 (at-least-once) の保証が付いた結果整合性のあるシステムのセマンティクスです。
ボーナス: 分散トレース
分散システム間のイベントフローを設計する場合、すべてが正しく効率的に実行されるようにするために、運用上のインサイトが不可欠です。分散トレースは、そのようなインサイトを提供します。それはそのような相互作用に貢献する個々のシステムからトレース情報を収集し、呼び出しフローの調査を可能にします。たとえば障害分析とデバッグのためのWeb UIは非常に貴重なツールになります。
DebeziumのOutboxサポートは、OpenTracing仕様との緊密な統合を通じてこの問題に対処します (OpenTelemetryサポートはロードマップにあります)。Jaegerなどのツールを導入することで、注文、顧客、支払いサービスからトレース情報を収集し、エンドツーエンドのトレースを表示するように構成するだけで済みます。
図7. Jaeger UIのSagaフロー
Jaegerの視覚化は、注文サービス (1) で着信RESTリクエストによってSagaフローがトリガされ、Outboxメッセージが顧客 (2) に送信されて、注文に戻され (3)、続いて別のメッセージが支払い (4) に送信され、そして最後に注文 (5) に戻る方法をうまく示しています。
トレース機能を使用すると、未完了のフロー (たとえば、参加しているサービスの1つのイベントハンドラがメッセージの処理に失敗したため) や、1つのイベントハンドラがSagaの流れの役割を果たすのに不当に時間がかかる場合などのパフォーマンスのボトルネックを簡単に識別できます。
まとめと展望
Sagaパターンは長期にわたる「ビジネストランザクション」を実装するための強力で柔軟なソリューションを提供します。これは、一連のデータ変更の適用または中止に同意するために複数の個別のサービスを必要とする、
CDC、Debezium、Apache Kafkaで実装されたOutboxパターンのおかげで、Sagaコーディネーターは他の参加サービスの可用性から切り離されています。単一の参加者の一時的な停止は、Sagaフロー全体に影響を与えません。コンポーネントが再び復旧すると、Sagaは以前に中断されたポイントから続行されます。
もちろん、リモートサービスとのやり取りの必要性を可能な限り減らすサービスの削減を目指す必要があります。たとえば、サンプルの与信限度額ロジックを注文サービスに移動して、顧客サービスとの調整を回避する選択肢があります。しかし、ビジネス要件によっては複数のサービスにまたがるこのような相互作用の必要性を回避できない場合があります。特にレガシーシステムや自身の管理下にないシステムを統合する場合です。
Sagaのような複雑なパターンを実装する場合、それらの制約とセマンティクスを正確に理解することが重要です。提案されたソリューションのコンテキストで注意すべき2つのことは、固有の結果整合性と、包括的なビジネストランザクションの制限された分離レベルです。たとえば、顧客の与信限度額の一部を割り当てると、最初の注文が最終的に処理されない場合でも、同時に送信されたその顧客からの別の注文が拒否される可能性があります。
この記事で説明するサンプルプロジェクトは、CDCとOutboxパターンに基づいたSagaオーケストレーションのPoCレベルの実装を提供します。これは2つの部分に分かれています:
- Saga実行ログとともに単純なステートマシンの形式でSagaオーケストレーションロジックを提供する汎用の「フレームワーク」コンポーネント
- 説明した注文プレースメントのユースケースの特定の実装 (上記の部分に示されている
OrderPlacementSaga
クラス、付随するRESTエンドポイントなど)
将来的には、たとえば既存のDebezium Quarkus拡張機能を使用して、前の部分を再利用可能なコンポーネントに抽出する可能性があります。これに興味がある場合は、Debeziumのメーリングリストに連絡してお知らせください。追加する可能性のある機能の1つは、複数のSagaステップを同時に実行する手段です。それが合理的かどうかはビジネス上の決定ですが、それをサポートすることは技術的な観点からは難しいことではありません。この場合、Saga状態の更新中の競合が重大な問題になる可能性があります。次のSagaの散乱収集を最適化では、これに対する潜在的な解決策について説明しています。しばらく経っても完了していないSagaを監視および識別するための機能があることも興味深いでしょう。
提案された実装は、複数のサービスにまたがる「オールオアナッシング」セマンティクスでビジネストランザクションを確実に実行する手段を提供します。条件付きロジックを使用したフローなど、より複雑な要件を持つユースケースの場合は、既存のワークフローエンジンや、Kogitoなどのビジネスプロセス自動化ツールを確認してください。注目すべきもう1つの興味深いテクノロジーは、現在開発中のMicroProfileの長時間実行アクティビティ仕様 (LRA) です。MicroProfileコミュニティは、DebeziumのようなトランザクションOutboxの実装との統合についても話し合っています。
この記事を書いている間、Hans-Peter Grahsl氏、Bob Roldan氏、Mark Little氏、Thomas Betts氏の広範なフィードバックに感謝します!
著者について
Gunnar Morling氏 は、ソフトウェアエンジニアであり、オープンソース愛好家です。彼は、変更データキャプチャ (CDC) のツールであるDebeziumプロジェクトを率いています。彼はJava Championであり、Bean Validation 2.0 (JSR 380) のスペックリーダであり、Layrry、Deptective、MapStructなどの複数のオープンソースプロジェクトを設立しています。Red Hatに入社する前は、氏はロジスティクスおよび小売業界でさまざまなJava EEプロジェクトに携わっていました。彼はドイツのHamburgを拠点としています。Twitter: @gunnarmorling