アーキテクチャ●●●●○

サーガ

Saga ・ さーが

複数サービスにまたがる処理をローカルトランザクションの列に分け、失敗時は補償で打ち消して整合させる。

✦ 30秒で試す — 航海注文は取り消されたのに「承りました」のメールだけが届く確定済みの在庫の引き当ては、どう元に戻る?予想してみる

概要

サーガは、複数のサービスにまたがる一連の処理を、全体としてつじつまが合った状態に保つための分散トランザクションのパターンです。「注文を受け付ける」「在庫を引き当てる」「決済する」「配送を手配する」といった、それぞれ別のサービスが受け持つステップを、途中で失敗しても矛盾が残らないように順に進めていく仕組み、と言い換えられます。

ふつうのデータベースのトランザクションなら「全部成功か、全部なかったことにする」を1つのコミットで保証できます。ところが処理が複数のサービス・複数のデータベースに分散していると、その一括のロールバックが効きません。そこでサーガは、失敗したときに「それまでの成功を打ち消す逆向きの処理(補償トランザクション)」を実行することで、全体の整合を取り戻します。マイクロサービスアーキテクチャで業務処理を組み立てるときの、事実上の定番パターンです。

なぜ生まれたか

モノリスで1つのデータベースにすべてのテーブルが同居していた時代は、業務処理がどれだけ複雑でも、最後に1回コミットすれば「注文・在庫・決済」がまとめて確定し、途中で例外が起きればまとめてロールバックできました。ACID のうちの原子性(Atomicity)が、サービスの外側からタダで手に入っていたわけです。

サービスを機能ごとに分割し、それぞれが自分のデータベースを持つマイクロサービス構成になると、この前提が崩れます。1つのビジネス処理が複数のデータベースへの書き込みに分かれ、それらを1つのトランザクションで束ねられなくなるのです。

モノリス — 1つのDBに同居1つのACIDトランザクション注文を更新決済を更新在庫を更新COMMIT 1回で全部まとめて確定途中で失敗すれば丸ごとロールバック分散 — サービスごとに別DB注文サービス注文DBコミット済決済サービス決済DBコミット済在庫サービス在庫DBここで失敗注文と決済だけ確定し、まとめて取り消す手段がない — 中途半端な状態
分割で失われた原子性 — 単一DBなら1回のコミットで済んだ処理が、分散すると途中失敗で中途半端な状態を残す

かつては複数のリソースを1つのコミットに束ねる2相コミット(2PC)という手法もありましたが、全参加者がコミット確定まで資源をロックし合うため、1つでも遅い・落ちた相手がいると全体が待たされ、可用性とスケーラビリティを大きく損ないます。そもそも各サービスが別々の種類のデータベースやメッセージブローカを使う構成では、全員が 2PC に参加できるとは限りません。

そこで発想を変え、「1つの大きなトランザクションを分割不能に保つ」のではなく、「小さなローカルトランザクションを連ねて、失敗したら補償で巻き戻す」ことで整合させる — このアプローチがサーガです。原型は1987年に Hector Garcia-Molina と Kenneth Salem が発表した論文「Sagas」で、もとは1つのデータベースの中で長時間かかる処理(ロックを長く握り続けてしまうトランザクション)を分割するための提案でした。それがマイクロサービスの時代に、サービスをまたぐ整合の手段として再発見されました。

詳細

ローカルトランザクションと補償の連鎖

サーガは、業務全体を「各サービスが自分のDBだけで完結できる小さなトランザクション(ローカルトランザクション)」の列に分解します。ステップが順に成功していけばそのまま完了です。問題はどこかで失敗したときで、サーガはそこから逆向きに、すでに成功した各ステップに対応する補償トランザクションを実行して打ち消していきます。

重要なのは、補償は「なかったことにする」ロールバックとは違い、「打ち消すための新しい操作」だという点です。すでにコミットされ、他から見えてしまった結果を消せはしないので、たとえば「決済した」を「返金する」で、「在庫を引き当てた」を「引き当てを解放する」で相殺します。次の図は、決済で失敗したときに手前の在庫引き当てを補償で戻す流れです。

配送決済在庫注文配送決済在庫注文ここから逆向きに補償を実行在庫を引き当て引き当て成功決済を実行残高不足で失敗補償 引き当てを解放解放完了注文をキャンセル状態にする

決済が失敗した時点でサーガは配送へは進まず、代わりに手前で成功していた在庫の引き当てを補償で解放し、注文自体をキャンセル状態に落とします。結果として「在庫は減っているのに決済も配送もされていない」といった中途半端な状態が残りません。ただし、補償できない操作が途中に紛れ込んでいると話が変わります。下の部品で、失敗させる位置を選んで実行し、各DBの状態がどう戻るか、そして「メール送信」の位置で結果がどう変わるかを確かめてください。

⚡ 体験: 失敗したら、逆向きに打ち消す
オーケストレーターピボット → 前進のみ—ここで失敗注文作成在庫 10ここで失敗在庫引当未送信ここで失敗メール送信⇆ つかんで移動未請求ここで失敗決済未手配ここで失敗配送手配
注文—失敗させる位置なしメール送信の順番3番目

まず「サーガを実行」を押して、注文の流れを最後まで通してみてください。そのあと「ここで失敗」で失敗させる位置を選びます。

ピボット — 補償できない操作をどこに置くか

部品で見たとおり、送ってしまったメールや出庫してしまった現物のように、原理的に補償できない操作があります。これを整理するのが、ステップを3種類に分ける考え方です。失敗したら補償で戻す「補償可能なステップ」、サーガ全体の成否が決まる「ピボット(pivot)トランザクション」、そしてピボットの後ろに置く「再試行可能なステップ」です。ピボットが成功したら、もう後戻りはしません。後ろのステップは失敗しても補償せず、成功するまでリトライして前進させます。

補償できるステップピボット再試行するステップ注文作成在庫引当決済メール送信配送手配失敗したら補償で逆向きに戻す成功すれば後戻りしない失敗してもリトライで前進取り消せない操作(メール送信・出庫)は、ピボットの後ろに回す
ピボットの手前は補償で戻れる領域、後ろはリトライで前進する領域。取り消せない操作は後ろに置く

再試行するステップが本当に「いつか必ず成功する」ためには、同じ指示が2回届いても結果が変わらないよう冪等性を持たせる必要があります。補償トランザクションも同じで、ネットワーク断で補償の完了通知が届かなければもう一度送ることになるので、二重に返金しないよう冪等に作ります。

隔離性がないことと、その対策

サーガがローカルトランザクションの列である以上、ACID の I(隔離性、Isolation)は手に入りません。各ステップのコミットは即座に他の処理から見えるので、全ステップが揃うまでの間、「注文は作られたが決済はまだ」という中間状態が外に漏れます。同時に走る別のサーガがその中間状態を読んで判断すると、後で補償された値にもとづいて動いてしまう(ダーティリードに似たことが起きる)、互いの更新を上書きしてしまう、といった異常が起こりえます。

実務ではこれを設計で抑えます。代表的なのがセマンティックロックで、部品の「受付中(仮)」のように、処理中のレコードに「まだ確定していない」という状態を持たせ、他の処理がそれを見たら待つか断るかを決めます。ほかにも、順番に依存しない更新(残高の上書きではなく「+5,000」「−3,000」の加算で表す)にする、読んだ値をコミット直前に再確認する、リスクの高いステップを後ろに回すといった対策があります。いずれもトランザクション分離レベルがデータベースの中でやってくれていたことを、アプリケーションの設計で肩代わりしているのだと捉えると見通しがよくなります。

オーケストレーションとコレオグラフィ

サーガの進行を「誰が指揮するか」で、大きく2つの実装スタイルに分かれます。1つはオーケストレーション型で、部品のように中央にサーガの進行を管理する調整役(オーケストレーター)を置き、その1か所が「次は在庫、次は決済」と各サービスに指示し、失敗を検知したら補償を発火します。流れが1か所に集約されて追いやすい反面、調整役が肥大化しやすく、調整役自身の状態(どこまで進んだか)を永続化しておかないと、途中で落ちたときに再開できません。

もう1つはコレオグラフィ型で、中央の指揮者を置かず、各サービスが自分の完了をメッセージキューにイベントとして流し、それを購読した次のサービスが自律的に動きます。各サービスの結合が緩く拡張しやすい一方、全体のフローがコードのどこにも書かれず、処理の連鎖が分散して見通しづらくなります。どちらの型でも、どこで詰まったかを追うには分散トレーシングのような可観測性の仕組みがほぼ必須です。進行そのものをイベント列として記録するイベントソーシングと組み合わせると、各サーガの経緯を後から再生して確かめられます。

システム全体としては、サーガの世界は結果整合性で動きます。強い一貫性を前提にした UI や業務ルールはそのままでは成り立たないので、「処理中」「キャンセルされました」といった中間の状態を、画面と業務の両方で正式な状態として扱う設計が伴って初めて完成します。

試問

注文作成・在庫引当・メール送信(「ご注文を承りました」)・決済・配送手配の5つのステップを、オーケストレーター(指揮役)が順に実行します。失敗したときは、それまでに成功したステップへ打ち消しの操作(補償)を逆向きに送ります。いまは決済で失敗するように設定してあり、メール送信はつかんで並べ替えられます。

✦ 命題挑戦中

決済が失敗しても、お客さまに矛盾した知らせが1つも残らないように、サーガの並びを直して実行せよ

  • 決済で失敗させて、サーガを実行する
  • 手前のステップがすべて補償で戻り、取り消せないものが残らない
⚡ 体験: 失敗したら、逆向きに打ち消す
オーケストレーターピボット → 前進のみ—ここで失敗注文作成在庫 10ここで失敗在庫引当未送信ここで失敗メール送信⇆ つかんで移動未請求⚡ ここで失敗決済未手配ここで失敗配送手配
注文—失敗させる位置決済メール送信の順番3番目

決済で失敗させます。「サーガを実行」を押してください。