分散トランザクション

このページの目次

注文処理において、注文の作成、在庫の確保、および決済サービスの呼び出しを同時に行う必要があると仮定する。いずれかのステップが失敗したり、応答が消失したりした場合、システムは一部の処理のみを完了してしまう可能性がある。分散トランザクションとワークフロー設計は、どの効果が共同コミットされるべきか、どの中間状態が存在し得るか、完了していない操作をどのように信頼性を持って解釈可能な結果へ進めるかという問いに答えるものである。

分散システムはACIDトランザクションを提供できるが、それには対応するプロトコルと参加リソースのサポートが必要である。「マシン間でグローバルなACIDが天然に存在しない」と言うことはできず、また、データベースがシャード間トランザクションをサポートしているからといって、任意の外部HTTP呼び出しを自動的にその中に組み込むこともできない。

原子性、分離性、ビジネス復旧をまず区別する

原子コミットは、複数の参加者が同じトランザクションに対して一貫したコミットまたは中止の決定を下すかどうかを解決する。分離性は、並行トランザクションが何を見えるか、そしてそれが何らかの直列実行と同等かどうかを決定する。永続性は、成功した結果が故障をまたいでどのように保存されるかに関わる。これらには異なるメカニズムの連携が必要である。

例えば、2つのシャードが両方とも送金トランザクションをコミットした場合、それは共同コミットを満たすが、並行残高チェックが直列化可能であることを単独で証明するものではない。2PCは原子コミットプロトコルであり、2ラウンドのメッセージだけで自動的にすべての分離レベルを提供するものではない。ロック、MVCC、タイムスタンプ、または競合検証は、依然として並行制御の役割を担わなければならない。

2PC:なぜ「準備完了」フェーズが必要なのか

コーディネーターは、通常のCOMMITを順次送信するだけでは不十分である。最初のリソースが成功した後、2番目のリソースが制約競合のためにコミットできない可能性があるかもしれない。Prepareフェーズでは、各参加者に「最終決定に従ってコミットできる能力」を事前に確認し、永続的に保持させることを要求する。

2PC:投票前に永続的に準備し、通知前に永続的に決定する コーディネーターがPrepareを要求 参加者がローカルトランザクションをコミットできるか確認する 1 参加者はPreparedを永続化し、YESを投票する コミット能力を保持することを約束し、随意に中止できなくなる 2 コーディネーターが決定を形成し、永続化する すべてがYESの場合のみコミット、そうでなければ中止を決定 3 参加者に通知し、未確認の通知を再試行する 参加者は一意の決定に従ってコミットまたはロールバックし、リソースを解放する 4 Preparedは最終決定に従う準備が整っていることを示すが、すでにコミットされたことを意味するものではない。

参加者がYESを投票する前に、プロトコルで要求される永続化と制約チェックを完了し、必要なリソースを保持しなければならない。ロックベースの実装では、関連するロックを保持し続ける可能性があるが、これはデータベース全体がロックされていることを意味するものではない。コーディネーターはコミット決定を確実に保存してからコミット通知を発信しなければならない。そうしないと、復旧後にすでにコミットされた参加者に対して、何が行われたのか説明できなくなる可能性がある。

ブロックはどこから来るのか

AとBの両方がPrepared状態に入ったと考える。コーディネーターはすでにCOMMITを決定しており、Aは通知を受け取ってコミットしたが、Bへの通知が消失した。Bはタイムアウトしても独自にロールバックできない。なぜなら、それはAの結果と競合するためである。Bがコミットメッセージを何も受信していないからといって、コミット決定が存在しないことを推論することはできない。

参加者がまだYESの約束をしていない場合、プロトコルは拒否または中止を許可できる。復旧ログや他の参加者から信頼できる決定を取得できる場合は、完了を続けることもできる。古典的な2PCのブロッキング問題は、ある準備済み参加者が決定を取得できない場合に待機しなければならないことにある。「コーディネーターが故障すると、すべての参加者があらゆる状態において永久に待機しなければならない」というわけではない。

コーディネーターが復旧した後、永続化された決定を再送信できる。参加者もトランザクションマネージャーによって復旧できる。PostgreSQLのprepared transactionは、外部トランザクションマネージャー用に提供されるメカニズムである。長期にわたって保留されるとリソースが保持されるため、信頼性の高いクリーンアップと復旧管理が必要である。PREPARE TRANSACTION ドキュメント

コーディネーターの状態を複製することで、単一ノードの故障による待機を減らすことができるが、到達不能な必須リソースを凭空に復元することはできない。コンセンサスとトランザクションコミットの関係については、GrayとLamportの論文を参照のこと。管理者による手動のコミットまたはロールバックの決定は、例外処理に属し、通常の復旧のデフォルトステップとして使用すべきではない。

Prepared参加者が目にする同じ沈黙は、2つの異なる履歴に対応できる

Bの視点から見ると、2つの実行は完全に同一である:自身は永続的に準備し、YESを投票したが、最終的な通知を受信できない。しかし、システムの他の部分は異なる状態にある可能性がある。

Bが観察できない部分実行1実行2
コーディネーターすべてのYESの後、COMMITを永続的に決定済み投票がまだ集計されておらず、コミット決定なし
参加者ACOMMITに従ってコミット済みまだ準備中で、NOを投票する可能性がある
Bのローカル状態Prepared、通知待ちPrepared、通知待ち

Bがタイムアウトに基づいて独自にABORTした場合、実行1の原子性を破壊する。独自にCOMMITした場合、実行2において満たされていないコミット条件を飛び越えることになる。Bのローカルの沈黙だけでは、2つの履歴に対して安全な終状態を選択できない。

したがって、復旧では信頼できる決定またはプロトコルが許可する決定根拠を探さなければならず、待機時間をある数値まで延ばして答えを推測してはならない。コミット済み参加者が信頼できるコミット証拠を提供できる場合、復旧に役立つ。しかし、連絡可能なすべての参加者がPreparedしか知らない場合、決定が依然として欠けている可能性がある。準備状態のリソースを保持することは、この不確実性による代償であり、通常のRPC再試行で解消できるウィンドウではない。

同じ沈黙の背後に、2つの決定がある可能性がある

ここでの待機は、アニメーションで次のステップが欠けているわけではなく、Bの知識が確かに不足しているためである。遠隔地をそれぞれ「COMMITが永続化済み」と「別の参加者が拒否し、ABORTが永続化済み」に切り替えると、Bは通知が届くまで同じPrepared証拠しか持たない。遠隔地の履歴を切り替えるのは2つの実行を比較するためのものであり、同じトランザクションがすでに下した決定を変更することを許可するものではない。

図を準備しています
同じ沈黙の背後に、2つの決定がある可能性がある

Prepared後、タイムアウト自体がコミットまたは中止を証明するものではない。同じローカル状態は、遠隔地でコミット済みまたはまだ中止可能な実行に対応でき、復旧には信頼できる決定が必要である。

Saga:一連のローカルトランザクションでビジネスプロセスを実現する

Sagaは、プロセスを独立してコミット可能なステップに分割する。例として注文プロセスを考える:未決済注文の作成、代金請求の申請、発送の手配。代金請求が明確に失敗した場合、注文をキャンセル状態に変更できる。代金請求は成功したが、発送が最終的に履行できない場合、返金を申請し、履行失敗としてマークできる。

補償は新しいビジネス操作であり、データベースを過去のバイト状態に戻すものではない。返金は代金請求の主要なビジネス効果を相殺できるが、決済記録、通知、監査履歴は依然として存在する。並行ビジネスはすでに中間状態を読み取っている可能性があるため、古いスナップショットでオブジェクト全体を上書きして「取り消す」ことはできない。これはSagasの元論文で強調されているセマンティクス補償の違いである。

中間状態はビジネスモデルの一部でなければならない

注文には「未決済」「決済結果確認待ち」「返金中」などの状態があり、どの状態から発送を許可するかを制限できる。失敗時にインターフェースを逆方向に呼び出すだけでは、補償前に他のプロセスが中間結果に基づいて実行を続けるのを防ぐことはできない。

補償も失敗、タイムアウト、または長期待機する可能性がある。ステップ、リクエストID、結果、および補償進捗を永続的に記録し、ポリシーに従って再試行し、必要に応じて手動処理状態に入る必要がある。ネットワークタイムアウトはまず結果が未知であることを意味するため、安定した操作IDに基づいて照会または再試行確認を行い、すぐに「代金請求が行われていない」と見なして新しい決済を作成してはならない。

オーケストレーション方式プロセスの進行方法注目すべき点
Orchestration(オーケストレーション)オーケストレーターが状態を保存し、ステップコマンドを発行するオーケストレーターの復旧、コマンドの重複排除、プロセスのバージョン管理
Choreography(コリオグラフィ)サービスがイベントに基づいて後続のステップを続けるイベントの関連付け、ループ、グローバル進捗と失敗の追跡

両方とも信頼性の高い状態とメッセージ処理を必要とする。イベント駆動は調整ロジックを自動的に消除するものではなく、それを各サービスに分散させるだけである。

TCC:実現可能なリソース予約をまず取得する

Try-Confirm-Cancelは、ビジネスリソースに対して3つのアクションを定義する:Tryでチェックと予約、Confirmで予約の消費、Cancelで予約の解放。例えば在庫が10個の場合、Tryで2個を予約すると、販売可能在庫は8個になる。Tryはすでにリソース状態を変更しており、「何も起こっていない」わけではない。

TCC:予約後に1つの終状態へ向かい、終状態は互いに上書きできない 未開始 予約済み 確認済み 取消済み Try Confirm Cancel Cancelが先に到着:取消を記録し、遅れたTryを拒否する 各状態遷移とリソース操作は原子コミットされる。重複リクエストは、そのブランチの既存の結果を返す。

Confirmを実現可能にするため、Tryはビジネスの前提条件を信頼できる予約に変換し、他のリクエストが同じリソースを消費しないようにする必要がある。Confirmでも一時的な技術的失敗が発生する可能性があるため、再試行が必要である。もしリソースが確認前に無条件で期限切れになる場合、期限切れと最終決定の調整を行わなければならず、「Tryが成功すれば必ずConfirm可能」という約束は成立しない。

3つの一般的な境界は、同じ永続状態マシンで説明できる。

  • 冪等性⁠:同じブランチからのTry、Confirm、Cancelの重複到達により、リソースの占有、消費、解放が重複して行われない。
  • 空ロールバック⁠:CancelがTryより先に到着した場合でも、取消済みとして記録され、リソースの解放は不要である。
  • ハング防止⁠:取消済みの後に遅れて到着したTryは拒否されなければならず、クリーンアップ対象のない予約が再構築されないようにする。

ConfirmとCancelは、一貫した最終決定に従わなければならず、原子状態遷移を通じて両方が有効になるのを防ぐ。確認済みの後にビジネスを取り消す必要がある場合は、新しい返金または返品プロセスに入るべきであり、TCCがいつでも直接Cancelできるわけではない。

「販売可能+予約+消費済み=初期在庫」でTCCブランチを検証する

以下では、1つのブランチ呼び出しを原子ローカル状態遷移として扱い、初期在庫を10とする。ブランチIDは数量にバインドされ、すべてのアクションはパラメータの一貫性をチェックする。Confirmは成功したTryがない場合に失敗を返し、Cancelは空の取消を先に記録できる。Trueを返すことは、そのアクションのセマンティクスが満たされたことを意味し、重複呼び出しがリソースを再度変更したことを意味するものではない。

class InventoryTCC:
    def __init__(self, stock=10):
        self.initial = self.available = stock
        self.consumed = 0
        self.branches = {}  # branch_id -> (state, quantity)

    def call(self, action, branch_id, quantity):
        if action not in {"try", "confirm", "cancel"}:
            raise ValueError("未知のアクション")
        if type(quantity) is not int or quantity <= 0:
            raise ValueError("数量は正の整数でなければなりません")
        previous = self.branches.get(branch_id)
        if previous is not None and previous[1] != quantity:
            raise ValueError("同じブランチで数量を変更することはできません")
        state = previous[0] if previous else "new"
        if action == "try":
            if state == "cancelled":
                return False
            if state in {"reserved", "confirmed"}:
                return True
            if self.available < quantity:
                return False
            self.available -= quantity
            self.branches[branch_id] = ("reserved", quantity)
        elif action == "confirm":
            if state == "confirmed":
                return True
            if state != "reserved":
                return False
            self.consumed += quantity
            self.branches[branch_id] = ("confirmed", quantity)
        else:
            if state == "confirmed":
                return False
            if state == "cancelled":
                return True
            if state == "reserved":
                self.available += quantity
            self.branches[branch_id] = ("cancelled", quantity)
        return True

    def check(self):
        reserved = sum(q for state, q in self.branches.values() if state == "reserved")
        assert self.available >= 0 and self.consumed >= 0
        assert self.available + reserved + self.consumed == self.initial
        return self.available, reserved, self.consumed

stock = InventoryTCC()
assert stock.call("try", "order-1", 2)
assert stock.call("try", "order-1", 2)
assert stock.check() == (8, 2, 0)
assert stock.call("confirm", "order-1", 2)
assert stock.call("confirm", "order-1", 2)
assert not stock.call("cancel", "order-1", 2)
assert stock.check() == (8, 0, 2)

assert stock.call("cancel", "order-2", 3)  # 空ロールバック、終状態も残す
assert not stock.call("try", "order-2", 3)  # 遅れたTryを拒否
assert stock.check() == (8, 0, 2)
assert stock.call("try", "order-3", 4)
assert stock.call("cancel", "order-3", 4)
assert stock.call("cancel", "order-3", 4)
assert stock.check() == (8, 0, 2)
assert not stock.call("try", "too-large", 9)
assert stock.check() == (8, 0, 2)

Tryは数量を販売可能から予約へ移動する。Confirmは予約から消費済みへ移動する。Cancelは予約から販売可能へ戻す。Cancelを重複して呼び出し、availableを再度増加させると、在庫が凭空に生成され、保存則が直接このエラーを捉える。同様に、空の取消は在庫を増加させない。なぜなら、以前に予約がなかったからである。

これはスレッドセーフなメモリモデルであり、ディスク永続化、プロセスクラッシュ、または並行ロックは実装されていない。実際の実装では、ブランチ状態、数量、および在庫変更を共同で原子コミットし、操作が実際にそのトランザクションを推進する権限を持つコーディネーターからのものであることを検証しなければならない。複数のトランザクションが在庫を共有する場合、販売可能量を原子チェックしなければならず、在庫を最初に読み取ってから無条件で減算してはならない。

さらに重要なのは:⁠ローカルの終状態の排他性は、グローバルな決定の一貫性を意味しない⁠。コーディネーターが誤ってAにConfirmを送り、BにCancelを送った場合、両側は各自の終状態に合法に進むことができるが、システムは依然として原子性を持たない。終状態チェックは、同じブランチが繰り返し決定を変更するのを防ぐことしかできない。コーディネーターの永続決定、復旧、および再試行は、必要不可欠な別の層のメカニズムであり、SeataのTCCモードもカスタムブランチ動作をグローバルトランザクション管理に組み込んでいる。TCCブランチとグローバルトランザクション

取消済みのブランチの記録をすぐに削除してはならない。そうしないと、遅れたTryが新しいリクエストとして扱われてしまう。保持期間は、許可された再試行とリプレイウィンドウをカバーするか、またはプロトコルによって古いブランチがもはや出現しないことが証明されなければならない。これはメッセージ消費の重複排除の保持問題と同じである。

再試行1回で、在庫が凭空に増えるか?

保存則は、再試行エラーを数量変化としてチェック可能にする。あらゆる合法なアクションの後、販売可能、予約、および消費済みの合計は10のまま維持されるはずである。先にCancelしてからTryを行うことで、ハング防止をチェックできる。Cancelを重複して行うことで、誤った返還がないかチェックできる。Confirmの後にCancelを行う場合は、拒否されなければならない。ローカルブランチがこれらのルールを満たしても、グローバルコーディネーターの一貫した決定に取って代わることはできない。

図を準備しています
再試行1回で、在庫が凭空に増えるか?

Tryは販売可能を予約へ移動し、Confirmは予約を消費済みへ移動し、Cancelは予約を返す。空の取消は終状態のみを記録し、在庫を増加させない。遅れたTryは取消ブランチを復活させられない。

冪等:同じビジネスリクエストは1回だけの効果を生む

冪等インターフェースは、SQLが繰り返し可能に見えるだけでなく、「これが同じリクエストである」ことを識別する必要がある。(ビジネススコープ, idempotency_key) で操作を一意に識別し、リクエストパラメータの要約、ビジネス効果、および結果を一緒に信頼性高く保存する。

重複排除記録とビジネス変更は、同じ原子境界を共有しなければならない。処理済みとしてマークしてから実行すると、クラッシュ時に実行漏れが発生する可能性がある。実行してからマークすると、クラッシュ時に重複実行が発生する可能性がある。外部サービスの場合、相手がサポートする安定した操作IDと結果照会メカニズムを使用する必要がある。Stripeのインターフェースは、冪等キーに基づく結果の保存、パラメータの検証、および保持期間などの具体的な契約を示している。Stripe 冪等リクエストドキュメント

楽観的ロック WHERE version = expected は競合を検出するために使用されるが、完全な重複排除ではない。更新影響行数が0の場合、それは元のリクエストがすでに成功したためかもしれないし、別のリクエストがレコードを変更したためかもしれない。クライアントは依然として自らの操作結果を識別する必要がある。同じ冪等キーを異なるリクエストパラメータに使用した場合、エラーを返すべきであり、無音で無関係な結果を返してはならない。

Outbox:ビジネス状態と送信待ちイベントを一緒にコミットする

注文データベースとメッセージシステムが共同トランザクションコミットできない場合、1つのローカルトランザクション内で注文の変更とOutboxイベントの挿入を同時に行い、フォワーダーによって送信できる。これにより、成功したローカルビジネス変更には、送信待ちの永続記録が残る。

フォワーダーはメッセージが送信され、送信状態がまだ更新されていない状態でクラッシュすると、再送信を行う。したがって、Outboxは通常、消費者の冪等処理を必要とし、外部副作用に対する天然の「ちょうど1回」の保証を提供するものではない。完全な例はメッセージセマンティクスを参照のこと。

方案クイックリファレンス

メカニズム主に解決するもの自動的に提供しないもの
2PCプロトコルをサポートするリソースによる共同コミット決定任意の外部呼び出し、すべての分離レベル、無条件の可用性
Saga複数コミット済みステップの継続的実行とビジネス補償全中間状態の隠蔽、物理的ロールバック、ブロッキングの永不発生
TCCビジネスリソース予約後の確認または取消無コストの予約、確認後の任意の取り消し
冪等同じ操作の再試行による効果の重複生成を防止複数独立リソースの共同コミット
Outboxローカル変更とイベント記録の原子コミット消費側および任意の外部効果に対するちょうど1回の保証

選択は制約から出発すべきである。強結合データを同じトランザクション境界に配置できる場合、まずこの簡略化を評価する。リソースをまたぐ必要がある場合、参加能力、許可された中間状態、失敗復旧、およびビジネス補償を明確にし、「強一貫性」または「高可用性」という2つのラベルだけで方案を選択してはならない。

参考

Keywords: 分散トランザクション, 原子コミット, 2PC, Saga, TCC, 補償, 冪等性, outbox