注文処理において、注文の作成、在庫の確保、および決済サービスの呼び出しを同時に行う必要があると仮定する。いずれかのステップが失敗したり、応答が消失したりした場合、システムは一部の処理のみを完了してしまう可能性がある。分散トランザクションとワークフロー設計は、どの効果が共同コミットされるべきか、どの中間状態が存在し得るか、完了していない操作をどのように信頼性を持って解釈可能な結果へ進めるかという問いに答えるものである。
分散システムは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を永続的に決定済み 投票がまだ集計されておらず、コミット決定なし
参加者A COMMITに従ってコミット済み まだ準備中で、NOを投票する可能性がある
Bのローカル状態 Prepared、通知待ち Prepared、通知待ち
Bがタイムアウトに基づいて独自にABORTした場合、実行1の原子性を破壊する。独自にCOMMITした場合、実行2において満たされていないコミット条件を飛び越えることになる。Bのローカルの沈黙だけでは、2つの履歴に対して安全な終状態を選択できない。
したがって、復旧では信頼できる決定またはプロトコルが許可する決定根拠 を探さなければならず、待機時間をある数値まで延ばして答えを推測してはならない。コミット済み参加者が信頼できるコミット証拠を提供できる場合、復旧に役立つ。しかし、連絡可能なすべての参加者がPreparedしか知らない場合、決定が依然として欠けている可能性がある。準備状態のリソースを保持することは、この不確実性による代償であり、通常のRPC再試行で解消できるウィンドウではない。
同じ沈黙の背後に、2つの決定がある可能性がある
ここでの待機は、アニメーションで次のステップが欠けているわけではなく、Bの知識が確かに不足しているためである。遠隔地をそれぞれ「COMMITが永続化済み」と「別の参加者が拒否し、ABORTが永続化済み」に切り替えると、Bは通知が届くまで同じPrepared証拠しか持たない。遠隔地の履歴を切り替えるのは2つの実行を比較するためのものであり、同じトランザクションがすでに下した決定を変更することを許可するものではない。
図を準備しています 再読み込み
同じ沈黙の背後に、2つの決定がある可能性がある Prepared後、タイムアウト自体がコミットまたは中止を証明するものではない。同じローカル状態は、遠隔地でコミット済みまたはまだ中止可能な実行に対応でき、復旧には信頼できる決定が必要である。
{"id":"twopc-prepared-uncertainty","title":"同じ沈黙の背後に、2つの決定がある可能性がある","summary":"Prepared後、タイムアウト自体がコミットまたは中止を証明するものではない。同じローカル状態は、遠隔地でコミット済みまたはまだ中止可能な実行に対応でき、復旧には信頼できる決定が必要である。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"history\"><button data-mode=\"commit\" data-i18n=\"commit\"></button><button data-mode=\"abort\" data-i18n=\"abort\"></button></div><div class=\"two\"><div class=\"card\"><header data-i18n=\"local\"></header><p id=\"local\" class=\"readout\"></p></div><div class=\"card\"><header data-i18n=\"remote\"></header><p id=\"remote\" class=\"readout\"></p></div></div><div class=\"actions\"><button id=\"timeout\" data-i18n=\"timeout\"></button><button id=\"deliver\" data-i18n=\"deliver\"></button></div><p class=\"mono\" id=\"timeouts\"></p><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"note\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"resolved\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.readout{min-height:12em}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let mode='commit',done=false,n=0;function draw(){pressed(mode);set('local',done?mode.toUpperCase():t('waiting'));set('remote',t(mode));set('timeouts','timeouts = '+n);set('explanation',t(done?'resolved':'note'));$('#deliver').disabled=done;$('#timeout').disabled=done;$('#local').dataset.state=done?mode:'prepared';}document.querySelectorAll('[data-mode]').forEach(e=>e.onclick=()=>{mode=e.dataset.mode;done=false;n=0;draw();});$('#timeout').onclick=()=>{n++;draw();};$('#deliver').onclick=()=>{done=true;draw();};draw();","audio":false,"strings":{"abort":"Aが拒否、ABORT決定済み","commit":"COMMIT決定済み","deliver":"永続決定を配信","heading":"同じ沈黙の背後に、2つの決定がある可能性がある","history":"外部履歴","intro":"BはすでにPrepared状態にある。外部履歴を切り替え、Bのローカル証拠を比較する。信頼できる決定が配信されて初めて待機が解除される。","local":"Bのローカル証拠","note":"タイムアウト回数は増加するが、証拠は増加しない。これに基づいて終状態を推測することはできない。","remote":"Bがまだ受信していない証拠","reset":"リセット","resolved":"信頼できる決定を取得したため、Bはその決定に従って完了できる。","scope":"2つの合法な実行のスナップショットを比較する。履歴の選択はBの観測をリセットする。2PCの全メッセージと参加者復旧はシミュレートしない。","scopeLabel":"モデル範囲と規約","timeout":"さらに1つのタイムアウト待機","waiting":"Prepared;YES投票済み;決定未受信"}}
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 = { }
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 ) 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を行う場合は、拒否されなければならない。ローカルブランチがこれらのルールを満たしても、グローバルコーディネーターの一貫した決定に取って代わることはできない。
A + R + C = 10
図を準備しています 再読み込み
再試行1回で、在庫が凭空に増えるか? Tryは販売可能を予約へ移動し、Confirmは予約を消費済みへ移動し、Cancelは予約を返す。空の取消は終状態のみを記録し、在庫を増加させない。遅れたTryは取消ブランチを復活させられない。
{"id":"tcc-resource-conservation","title":"再試行1回で、在庫が凭空に増えるか?","summary":"Tryは販売可能を予約へ移動し、Confirmは予約を消費済みへ移動し、Cancelは予約を返す。空の取消は終状態のみを記録し、在庫を増加させない。遅れたTryは取消ブランチを復活させられない。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"band\" id=\"stock\"></div><div class=\"metrics\"><div><span data-i18n=\"available\"></span><strong id=\"available\"></strong></div><div><span data-i18n=\"reserved\"></span><strong id=\"reserved\"></strong></div><div><span data-i18n=\"consumed\"></span><strong id=\"consumed\"></strong></div></div><div id=\"equation\" class=\"formula\"></div><p class=\"value\" id=\"state\"></p><div class=\"actions\"><button id=\"try\" data-i18n=\"try\"></button><button id=\"confirm\" data-i18n=\"confirm\"></button><button id=\"cancel\" data-i18n=\"cancel\"></button><button id=\"reset\" data-i18n=\"reset\"></button></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"ok\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"reject\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.band{height:35px}.band i:last-child{background:var(--muted)}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let state='initial',a=10,r=0,c=0;function draw(){set('available',a);set('reserved',r);set('consumed',c);set('equation',a+' + '+r+' + '+c+' = 10');set('state',t(state));$('#state').dataset.state=state;$('#stock').replaceChildren(...[a,r,c].map((v,i)=>{const e=document.createElement('i');e.style.width=v*10+'%';if(i===1)e.className='second';return e;}));}function act(k){let ok=false;if(k==='try'){if(state==='initial'){a-=3;r=3;state='prepared';ok=true;}else ok=state==='prepared'||state==='confirmed';}if(k==='confirm'){if(state==='prepared'){r=0;c=3;state='confirmed';ok=true;}else ok=state==='confirmed';}if(k==='cancel'){if(state==='initial'){state='cancelled';ok=true;}else if(state==='prepared'){a+=r;r=0;state='cancelled';ok=true;}else ok=state==='cancelled';}set('explanation',t(ok?'ok':'reject'));draw();}for(const k of ['try','confirm','cancel'])$('#'+k).onclick=()=>act(k);$('#reset').onclick=()=>{state='initial';a=10;r=c=0;set('explanation',t('ok'));draw();};$('#reset').click();","audio":false,"strings":{"available":"販売可能","cancel":"Cancel","cancelled":"取消済み","confirm":"Confirm","confirmed":"確認済み","consumed":"消費済み","heading":"再試行1回で、在庫が凭空に増えるか?","initial":"未確立","intro":"1つのブランチが3個を予約し、初期在庫は10である。Try、Confirm、Cancelの任意の試行において、重複と遅れた呼び出しは同じブランチIDを保持する。","ok":"アクションは満たされた。重複呼び出しは在庫の移動を重複しない。","prepared":"予約済み","reject":"状態がそのアクションを許可しない。在庫は変更されない。","reserved":"予約","reset":"リセット","scope":"単一のローカル原子ブランチ。数量はIDにバインドされ、3つの在庫コンポーネントは保存される。グローバルコーディネーター、永続化、または複数ブランチの一貫した決定はシミュレートしない。","scopeLabel":"モデル範囲と規約","try":"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