デッドロック検出と待機グラフ・タイムアウト
膠着して進まないトランザクションを、いつ・どれを巻き戻せば全体が動き出すのか――待機グラフのサイクル検出と犠牲者選定の原理を、予防アルゴリズムまで含めて腹落ちさせます。
- 1.能動的検出は待機-for グラフ(wait-for graph)の閉路をたどって行う。閉路が見つかった時点でデッドロックが確定し、閉路上のどれか1件を犠牲者として強制アボートして解消する。
- 2.予防は wait-die / wound-wait の2方式。トランザクションのタイムスタンプ(年齢)を比較し、待つか/相手を倒すかを一意に決めることで、待ちの循環を最初から作らせない。どちらも飢餓を防ぐため再起動時は元のタイムスタンプを引き継ぐ。
- 3.犠牲者選定はアボートコスト最小化が原則。実行済み作業量・保有ロック数・残り処理・巻き戻しのカスケードを見て、最も損の小さい1件を選ぶ。タイムアウトは検出を持たない簡易代替で、誤検知と長すぎ待ちのトレードオフを抱える。
デッドロックはなぜ自然解消しないのか
ロックベースの同時実行制御では、各トランザクションが必要なロックを取得し、取れなければ相手の解放を待ちます。問題は、この「待ち」が循環したときです。T1 が項目 A の X ロックを持って B を要求し、T2 が B の X ロックを持って A を要求すると、両者は互いの解放を永遠に待ち続けます。これがデッドロックで、外から介入しない限り自然には解けません。
2相ロック(2PL)は直列化可能性を保証しますが、デッドロックは防ぎません(→ 2相ロックと直列化可能性)。したがってロック方式の DB は、デッドロックを「起きてから解く(検出)」か「起こさせない(予防)」かのどちらかの仕組みを必ず持ちます。
デッドロックは次の4つが同時に成り立つときだけ起きます。(1) 相互排他(ロックは排他保有できる)、(2) 保有して待つ(hold-and-wait、ロックを握ったまま別を要求)、(3) 横取り不可(no preemption、他者のロックを強制的に奪えない)、(4) 循環待ち(circular wait)。検出はこのうち (4) の循環を見つける方法、予防は (2) や (4) を構造的に崩す方法、と整理すると見通しが良くなります。
待機-for グラフによる能動的検出
能動的検出(detection)は、循環待ちそのものを探します。使うのは**待機-for グラフ(wait-for graph、WFG)**です。作り方は単純です。
- 実行中のトランザクションごとに頂点を1つ作る。
Tiが、Tjの保有するロックの解放を待っているなら、有向辺Ti → Tjを引く。
待機-for グラフに有向閉路(cycle)が存在することと、デッドロックが発生していることは同値です。閉路がなければ、辺をたどればいつか「何も待っていない頂点」に行き着き、そのトランザクションが進めば連鎖的に全体がほどけます。閉路があれば、その上の全員が互いの完了を待つため誰も進めません。
待機-for グラフは、直列化可能性の判定に使う**先行グラフ(precedence graph)**とは別物です。先行グラフは「正しさ」を、待機-for グラフは「膠着」を判定します。混同しないでください。
T1: lock-X(A) 取得済み → lock-X(B) を要求して待ち ⇒ 辺 T1 → T2(B は T2 が保有)
T2: lock-X(B) 取得済み → lock-X(A) を要求して待ち ⇒ 辺 T2 → T1(A は T1 が保有)
待機-for グラフ: T1 ⇄ T2 (閉路あり)⇒ デッドロック確定
実装上の検出タイミングには2系統あります。1つは継続検出で、辺を追加するたびに閉路ができたか確かめる方式。もう1つは周期検出で、一定間隔または「あるロック要求が一定時間ブロックした」契機でグラフ全体をスキャンする方式です。PostgreSQL は後者寄りで、ロック待ちが deadlock_timeout(既定 1 秒)を超えたときに限って初めてグラフを点検します。閉路検出は深さ優先探索(DFS)で back edge を探せば線形時間で済むため、検出自体は軽い処理です。頻繁に走らせないのは、デッドロックがまれな前提で「待ちが長引いたときだけ調べる」方が平均コストが低いからです。
複数ノードにまたがる分散デッドロックでは、各ノードのローカルグラフを集めて大域待機-for グラフを構成します。しかしグラフ収集に時間差があると、収集中にあるトランザクションが自発的にアボートして辺が消え、実在しない閉路を検出してしまうファントム・デッドロックが起きえます。Chandy-Misra-Haas のプローブ(探索メッセージ)法など、グラフ全体を1か所に集めずに循環を検出する分散アルゴリズムが用いられます。
予防:wait-die と wound-wait
予防(prevention)は、待ちの循環そのものを構造的に作らせません。代表がwait-die と wound-wait で、どちらもトランザクションに開始時刻のタイムスタンプ TS を割り当て、これを「年齢」として扱います。TS が小さいほど古い(年長)トランザクションです。考え方はタイムスタンプ順序制御に近く、循環を一意な順序で禁じる発想です(→ オプティミスティック並行性制御とタイムスタンプ順序)。
Ti が、Tj の保有するロックを要求してぶつかった状況を考えます。両方式の規則は次の通りです。
| 方式 | Ti が Tj より古い(年長) | Ti が Tj より新しい(年少) | 横取り |
|---|---|---|---|
| wait-die(非横取り) | Ti は待つ(wait) | Ti は自分を die(アボート) | なし。古い者だけが待てる |
| wound-wait(横取り) | Ti は Tj を wound(アボート)し奪う | Ti は待つ(wait) | あり。古い者が若い者を倒す |
両者に共通する原理は「待ちの向きをタイムスタンプ順に一方向へ揃える」ことです。wait-die では「古い→若い」の向きにしか待たない(若い者は待たずに死ぬ)。wound-wait では「若い→古い」の向きにしか待たない(古い者は待たずに奪う)。待ち辺の向きが TS の単調な大小に固定されるので、T1 → T2 → … → T1 のような循環は数の大小として矛盾し、原理的に作れません。これが予防が成立する理由です。
アボートされたトランザクションが新しい TS で再起動すると、wait-die ではいつまでも「年少」のまま何度も die し、wound-wait では常に新参として狙われ続け、永久に完了できない**飢餓(starvation)**に陥ります。これを防ぐ鉄則は、再起動時にも最初のタイムスタンプを引き継ぐことです。これにより待たされ続けたトランザクションはやがて系内で最古になり、優先的に進めるため、飢餓が起きない(liveness が保証される)ことが証明できます。
両方式の性質の差を押さえておきます。wait-die は非横取り型で、待つのは古い者だけ。若い者はロックをまだあまり持っていないことが多く、その時点で die させても巻き戻しが軽い傾向があります。wound-wait は横取り型で、古い(=すでに多くの作業を終えた)者を優先して生かすため、無駄になる作業量が小さくなりやすく、アボート総数も wait-die より少ない傾向があります。どちらも「古い者ほど生き残りやすい」点は共通で、長時間走るトランザクションが何度も犠牲になって完了できなくなる事態を避けています。
犠牲者選定とアボートコスト
検出方式では、閉路を見つけた後に「閉路上のどの1件を殺すか」を決めねばなりません。閉路上のトランザクションを1つアボートしてロックを解放すれば、その辺が消えて閉路がほどけ、残りが進めます。問題はどれを選べば損が最小かで、これが**犠牲者選定(victim selection)**です。
理想は「最も安く殺せる1件」を選ぶことです。実装が見る代表的な指標は次のとおりです。(1) これまでに実行した作業量――投じた CPU・I/O が少ない方が捨てても惜しくない。(2) 保有ロック数と残り処理量――完了間近の者を殺すと損が大きい。(3) 巻き戻しのカスケード――その abort で連鎖的に他者まで巻き戻るなら高コスト。(4) 既にアボートされた回数――何度も犠牲になっている者を再び選ぶと飢餓になるため避ける。多くの実装はこれらを重み付けして総アボートコスト最小の犠牲者を選びます。
犠牲者選定で同時に効くのがロールバック量の判断です。閉路を解くのに必要なのは「対象のロックを解放させる」ことだけなので、トランザクション全体を巻き戻す全ロールバックではなく、競合するロックを取得した地点まで戻す部分ロールバック(セーブポイントまで戻す)で足りる場合があります。戻す量が少ないほど再実行コストが下がります。ただし戻した先から再び同じ相手とぶつかれば、同じデッドロックが再発します。これを避けるため、犠牲者は短いランダム遅延を入れてから再試行する、取得順序を揃えるなどの工夫が併用されます。
最後に、これらの選定は MVCC とロックの併用環境でも本質は変わりません。読み取りがスナップショットを見てロックを取らない MVCC でも、行更新や明示ロック(SELECT ... FOR UPDATE)はロックを取るため、書き込み同士のデッドロックは依然として起こり、待機-for グラフで検出されます(→ ロックと MVCC)。
タイムアウト:検出を持たない簡易代替
待機-for グラフを維持せず、「一定時間待っても取れなければ自分をアボートする」だけの方式がタイムアウトです。実装が極めて軽く、分散環境でも大域グラフを構成せずに済む利点があります。多くの DB は、明示的な検出を行う前段としてロック待ちタイムアウト(例: lock_timeout)も提供します。
| 観点 | 待機-for グラフ検出 | タイムアウト |
|---|---|---|
| 正確さ | 実際のデッドロックだけを確実に解消(偽陽性なし) | 誤検知あり。混雑で遅いだけの正常な待ちも切ってしまう |
| 応答性 | 閉路ができ次第(または点検時に)即解消 | タイムアウト値ぶん必ず待たされる。短すぎると無駄なアボート、長すぎると膠着が長引く |
| 実装コスト | グラフ維持と閉路探索が必要。分散では収集が重い | タイマだけ。分散でもローカル完結で軽い |
| 主な用途 | 単一ノードの RDB の標準(PostgreSQL など) | 分散システムや、検出のフォールバック・補助 |
タイムアウトの本質的な弱点は、「デッドロックなのか、単に遅いだけなのか」を区別できないことです。閾値を短くすれば混雑時に正常なトランザクションまで巻き込んで殺し、長くすればデッドロックの膠着が閾値ぶんそのまま残ります。このトレードオフを避けたいからこそ、単一ノードの RDB は安価な閉路検出を実装します。PostgreSQL の deadlock_timeout は「タイムアウトでアボートする時間」ではなく「待機-for グラフを点検し始めるまでの猶予」である点に注意してください――タイムアウトと検出を組み合わせ、まれな検出コストを必要なときだけ払う設計です。
まとめ
- デッドロックは Coffman の4条件が揃うと成立し、ロックの循環待ちが核心。2PL では防げないため、検出か予防の仕組みが必須(→ トランザクション分離レベル)。
- 能動的検出は待機-for グラフの閉路 = デッドロックを DFS で見つけ、閉路上の1件をアボートして解く。点検は「待ちが長引いたときだけ」走らせると平均コストが低い。
- 予防の wait-die / wound-wait は
TS(年齢)で待ちの向きを一方向に固定し、循環を最初から禁じる。再起動で元のTSを引き継ぐことが飢餓回避の鉄則。 - 犠牲者選定はアボートコスト最小化が原則で、作業量・ロック数・カスケード・過去のアボート回数を見て選ぶ。部分ロールバックで再実行コストを抑えつつ、再発を遅延・順序統一で防ぐ。タイムアウトは検出を持たない軽量代替で、誤検知と応答性のトレードオフを抱える。
データベース Article
デッドロック検出と待機グラフ・タイムアウトを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
データベース
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 5
導入後に効く点
予防は wait-die / wound-wait の2方式。トランザクションのタイムスタンプ(年齢)を比較し、待つか/相手を倒すかを一意に決めることで、待ちの循環を最初から作らせない。どちらも飢餓を防ぐため再起動時は元のタイムスタンプを引き継ぐ。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 5
判断チェックリスト
- 自社の用途が「データベース / トランザクション」に近いか確認する。
- 強みである「能動的検出は待機-for グラフ(wait-for graph)の閉路をたどって行う。閉路が見つかった時点でデッドロックが確定し、閉路上のどれか1件を犠牲者として強制アボートして解消する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。