直列化可能スナップショット分離(SSI)の検出アルゴリズム
SI のまま直列化可能性を得たい――SSI が危険構造を実行時に検出する仕組みと、PostgreSQL の SIREAD ロック実装を内部から追えば、なぜ偽陽性が出てどう抑えるかまで腹落ちします。
- 1.SSI は SI 由来の直列化グラフの閉路には必ず連続2本の rw-依存があるという定理を使い、pivot に入り・出の rw が揃った瞬間を危険構造として検出する。
- 2.PostgreSQL は SIREAD ロック(述語ロック)で読んだ行・ページ・関係を記録し、各 SerializableXact に inConflict / outConflict(rw 相手のリスト)を持たせて危険構造の成立を監視する。
- 3.検出アルゴリズムはグラフの閉路全体を探さず2本の rw-依存の連続だけを見る近似なので偽陽性が出る。アプリは 40001 を捕捉して再試行する前提で設計する。
SSI が解く問題を一言で
スナップショット分離(SI)は読み取りがロックを取らず止まらない一方で、rw-依存(アンチ依存)を抑えないため Write Skew などを許し、直列化可能ではありません(→ スナップショット分離と並行性アノマリー)。直列化可能スナップショット分離(SSI)は、SI の実行モデルをそのまま使いながら、実行中に危険な依存構造だけを検出して片方を中断することで直列化可能性を保証します。鍵は「閉路を直接探さない」点にあります。本稿はこの検出アルゴリズムの理論的根拠と、PostgreSQL の SIREAD ロック実装を内部から解説します。
理論的根拠:危険構造の定理
並行実行が直列化可能かは、トランザクションを節点・依存を辺とする直列化グラフで判定でき、グラフに閉路がなければ直列実行と等価です。SSI の基盤は Cahill・Röhm・Fekete らの研究で、その核心は次の定理です。
SI 由来の直列化グラフに閉路が存在するなら、その閉路には必ず連続する2本の rw-依存を含む3つのトランザクション Tin -> Tpivot -> Tout(両辺とも rw)が現れる。さらに Tout は3者の中で最初にコミットする。
rw-依存 rw-依存
Tin ------------> Tpivot ------------> Tout
^ pivot に入りの rw と出の rw が揃う
なぜ rw が2本かというと、ww・wr 依存は SI が First-Committer-Wins とスナップショットで抑え、コミット順と一致する向きしか残せないからです。残る向きを逆走できるのは rw-依存だけで、閉路を作るにはこの「逆走辺」が連続して2本必要になります。Tpivot(軸)に入りの rw と出の rw が両方ある状態が、閉路に必要な最小の局所条件です。
この定理が実装上決定的に重要なのは、閉路全体を探索する必要がないことです。各トランザクションについて「自分宛ての rw(inConflict)」「自分発の rw(outConflict)」を記録し、その両方を持つ軸が現れたら危険とみなせば済みます。Cahill らの原論文は両者を 1 ビットずつに圧縮しましたが、PostgreSQL は偽陽性削減と早期回収のため各方向の相手の集合をリストで保持します。いずれにせよ、グラフ全体を走査せず各辺の発生時に局所的な判定で済む点が肝です。
検出アルゴリズムの骨子
PostgreSQL の実装は、各トランザクションを表す SerializableXact 構造体に概ね次を持たせます。
| 要素 | 意味 |
|---|---|
| inConflicts | 自分宛ての rw-依存(自分が読んだ行を他者が書いた)の集合 |
| outConflicts | 自分発の rw-依存(自分が書いた行を他者が先に読んでいた)の集合 |
| SIREAD ロック群 | このトランザクションが読んだ行・ページ・関係への述語ロック |
| commitSeqNo | コミット順序。Tout が最初にコミットという定理の判定に使う |
rw-依存の辺は2方向から生まれます。ひとつは読み手が先に読み、書き手が後で書く場合で、書き込み時に「この行に誰の SIREAD ロックが付いているか」を見て辺を張ります。もうひとつは書き手が先に書き、読み手が後でその古い版を読む場合で、読み取り時に「自分のスナップショットより新しい版を作った並行トランザクションがいるか」を見て辺を張ります。どちらの経路でも、辺を張った直後に次を判定します。
辺 Ti --rw--> Tj を追加するたびに:
# 新しい辺の両端それぞれが pivot になりうる
if Tj.outConflicts が非空: # 構造 Ti -> Tj -> Tout、Tj が pivot
危険構造の候補(pivot=Tj, Tin=Ti, Tout=Tj の出先)
if Ti.inConflicts が非空: # 構造 Tin -> Ti -> Tj、Ti が pivot
危険構造の候補(pivot=Ti, Tin=Ti の入元, Tout=Tj)
# いずれかが成立 -> Tout が既コミットなら即中断、未コミットでも条件成立側を中断
実際には「pivot より先に Tout がコミット済みか」を commitSeqNo で確認し、定理の条件を満たすときだけ中断します。これにより、危険構造のように見えても直列化可能と確定できる並びは中断しません。
SIREAD ロック:データを止めない述語ロック
SSI が rw-依存を観測するには「誰がどこを読んだか」を残す必要があります。これが SIREAD ロックで、名前にロックとあるものの他者の読み書きを一切ブロックしません。役割は「あとでここに書き込みが来たら rw-依存の辺を張れ」という見張りの印です。
SIREAD ロックは行(タプル)・ページ・関係(テーブル)の3粒度を持ちます。多数の行を読むと印が増えてメモリを圧迫するため、PostgreSQL は同一ページ内のタプルロックが一定数を超えるとページロックへ、ページが増えると関係ロックへ自動で昇格します。昇格は省メモリと引き換えに監視範囲を広げるので、その分偽陽性が増えるトレードオフがあります。max_pred_locks_per_transaction などのパラメータがこの上限を制御します。
ここで本質的なのは、SIREAD ロックが**述語ロック(predicate lock)**として働くことです。範囲検索やインデックススキャンでは「読んだ行」だけでなく「読んだ範囲・ギャップ」に印を付けるため、まだ存在しない行の挿入(ファントム)も rw-依存として捕捉できます。これが SSI が SI 上でファントムを含む直列化可能性を達成できる理由です。インデックスを使った絞り込み読み取りなら印は範囲に限定され、シーケンシャルスキャンだと関係全体に印が付き、後者ほど偽陽性が出やすくなります。
あるトランザクションがコミットしても、その SIREAD ロックは並行していた他のトランザクションが全て終わるまで残ります。危険構造は3者にまたがるため、すでに終わった読み手が後発の書き手と作る rw-依存を、コミット後に遡って評価する必要があるからです。このため長時間トランザクションは印の回収を遅らせ、述語ロックの蓄積と偽陽性の温床になります。SSI でもトランザクションは短く保つのが鉄則です。
なぜ偽陽性が出るのか
SSI は閉路の有無そのものではなく「連続2本の rw(危険構造)」という必要条件で判定します。危険構造は閉路の必要条件であって十分条件ではないため、危険構造があっても実際には閉路が無い(直列化可能だった)場合があり、これが偽陽性です。グラフ全体を走査すれば偽陽性は消せますが、それは各操作のたびに O(節点数) の探索を要し、SI の軽さを殺します。SSI は意図的に安全側へ倒す近似を選んでいます。
偽陽性の主因:
・SIREAD ロックの粒度昇格(行 -> ページ -> 関係)で読み範囲を過大評価
・シーケンシャルスキャンによる関係全体への印
・危険構造を閉路の十分条件と扱うことによる過剰検出
したがって SSI を使うアプリは、シリアライズ失敗(PostgreSQL では SQLSTATE 40001)を捕捉してトランザクション全体を自動再試行する作りが前提です。再試行時は片方が他方の結果を見られる順序で走り直すため整合します。これは OCC とタイムスタンプ順序 のアボート&リトライ前提と同じ思想で、SSI は SI に楽観的な検証を後付けした形と見なせます。
PostgreSQL 実装の要点
PostgreSQL は SERIALIZABLE 分離レベルでこの SSI を実装しており、書き込みにも明示ロックにも追加の待ちを入れません(→ トランザクション分離レベル)。実務で押さえる点を整理します。
| 観点 | PostgreSQL SSI の挙動 |
|---|---|
| 読み取り | ロックを取らず止まらない。SIREAD ロックは印のみで他者を待たせない |
| 検出単位 | rw-依存の辺ごとに inConflict/outConflict を定数時間で判定 |
| 失敗の通知 | 危険構造成立時に 40001 を返す。実コミット衝突を待たず先回りで中断 |
| READ ONLY 最適化 | 読み取り専用宣言や DEFERRABLE で安全なスナップショットを取れれば監視を省ける |
| メモリ制御 | 述語ロックが上限を超えると粒度昇格。max_pred_locks_per_transaction 等で調整 |
READ ONLY DEFERRABLE トランザクションは特別で、PostgreSQL が**安全なスナップショット(safe snapshot)**を取れるまで開始を待ち、取れた後は rw-依存の監視も中断もせずに走れます。読み取り専用なら出の rw を作らず pivot になり得ないため、危険構造に関与しないことを保証できるからです。レポート系の長い読み取りに有効です。
なお SSI は二相ロック(2PL)と違い、書き込み同士の衝突は MVCC の First-Committer-Wins に任せ、rw-依存だけを別建てで監視します(→ 二相ロック)。ロックで待たせて直列化する 2PL に対し、SSI は待たせず検出して中断する点が根本的に異なります。MVCC のバージョン可視性そのものは MVCC の内部実装 を参照してください。
まとめ
- SSI は「SI 由来の閉路には必ず連続2本の rw-依存(危険構造)があり、Tout が最初にコミットする」という定理を使い、pivot に入り・出の rw が揃った瞬間を定数時間で検出する。
- SIREAD ロックはデータを止めない述語ロックで、読んだ行・ページ・関係・範囲に印を付け、後続の書き込みと rw-依存の辺を張る。粒度は行→ページ→関係へ昇格する。
- 検出は閉路の必要条件による近似なので偽陽性が出る。
40001を捕捉して自動再試行するのが使う側の前提。 - PostgreSQL は
SERIALIZABLEでこれを実装し、READ ONLY DEFERRABLEの安全スナップショットで監視を省く最適化を持つ。読み範囲を絞る(適切なインデックス)ほど印が小さく偽陽性が減る。
データベース Article
直列化可能スナップショット分離(SSI)の検出アルゴリズムを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
データベース
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 6
導入後に効く点
PostgreSQL は SIREAD ロック(述語ロック)で読んだ行・ページ・関係を記録し、各 SerializableXact に inConflict / outConflict(rw 相手のリスト)を持たせて危険構造の成立を監視する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 6
判断チェックリスト
- 自社の用途が「データベース / トランザクション」に近いか確認する。
- 強みである「SSI は SI 由来の直列化グラフの閉路には必ず連続2本の rw-依存があるという定理を使い、pivot に入り・出の rw が揃った瞬間を危険構造として検出する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。