スナップショット分離と並行性アノマリー
Repeatable Read なのに在庫が二重に引き当てられる謎が、スナップショット分離の原理から腑に落ちます。Write Skew の正体と SSI の解決法まで一気に理解できます。
- 1.スナップショット分離は各トランザクションが開始時点の一貫した版を読むため Lost Update やダーティリードを防ぐが、Write Skew だけは構造的に防げない。
- 2.Write Skew は2つのトランザクションが互いに別の行を読んで別の行を書くため書き込み衝突が起きず、スナップショット分離の検査をすり抜ける。
- 3.SSI は読み書きの依存(rw-依存)を実行中に追跡し、危険な構造が2本連続したときだけ片方を中断して直列化可能性を保証する。
スナップショット分離はなぜ「ほぼ安全」なのか
スナップショット分離(SI)は、各トランザクションが開始時点の一貫したスナップショットを読み、書き込みは MVCC で別バージョンとして積む方式です(→ MVCC の内部実装)。多くの製品が Repeatable Read として実装し、ダーティリード・ノンリピータブルリード・ファントムまで実質的に防ぎます(→ トランザクション分離レベル)。
ところがこれは直列化可能(Serializable)ではありません。SI の世界では、どの単独トランザクションから見ても整合しているのに、同時実行の組み合わせ全体としては「どんな直列順でも生じ得ない状態」が残ることがあります。本稿はその正体である Write Skew と、それを正攻法で潰す SSI の原理を掘り下げます。
まず Lost Update:SI が防げる側
Lost Update(更新の喪失)は、2本が同じ行を読んで計算し、上書きし合って片方の更新が消える異常です。
T1: read x=100 -> x = 100 - 10 = 90 -> write x=90
T2: read x=100 -> x = 100 - 10 = 90 -> write x=90 # 合計20減るはずが10しか減らない
SI ではこれを**First-Committer-Wins(先にコミットした者勝ち)**で防ぎます。同じ行に対する並行更新を検知し、後からコミットしようとした側を中断するためです。SELECT ... FOR UPDATE で読み取り時にロックを取るか、製品が自動でシリアライズ失敗を返します。重要なのは、両者が同じ行 x を書くため衝突として観測できる、という点です。
Write Skew:SI が構造的に防げない側
Write Skew は Lost Update とよく似ていますが、決定的な違いがあります。2本が読む行と書く行がずれていて、書き込み同士が衝突しないのです。
古典例は「オンコール医師は常に1人以上いること」という制約です。医師 A と B の2人が当直中で、それぞれが「自分が抜けても相方が残る」と判断して同時に抜けるケースです。
不変条件: count(on_call = true) >= 1
初期状態: A=true, B=true
T1(医師A): read {A,B} -> 当直は2人 -> 自分が抜けても1人残る -> write A=false
T2(医師B): read {A,B} -> 当直は2人 -> 自分が抜けても1人残る -> write B=false
両者コミット成功 -> 結果 A=false, B=false(当直0人で不変条件が破れる)
T1 は行 A を書き、T2 は行 B を書きます。書く対象が別の行なので First-Committer-Wins は発動せず、両方がコミットできてしまいます。しかし直列に実行していれば、後発の側は相方が抜けた後の状態を読み「自分は抜けられない」と判断したはずです。SI では各自が古い(相方がまだいる)スナップショットを読むため、この判断が成立しません。
Write Skew の本質は、判断の根拠にした行と実際に変更する行が異なり、かつ複数行にまたがる不変条件が絡むことです。在庫の二重引き当て、座席の二重予約、会議室のダブルブッキング、残高合算チェックなど、「複数行を読んで合計や件数で判断し、別の行を更新する」処理はすべて候補になります。アプリ側で SELECT してから UPDATE する典型パターンが危険です。
依存グラフで「なぜ直列化できないか」を見る
並行実行が直列化可能かどうかは、トランザクション間の依存を辺とする依存グラフ(直列化グラフ)で判定できます。グラフに閉路がなければ、辺の向きに従って並べた直列実行と等価です。逆に閉路があれば、どんな直列順とも等価にできない=直列化不可能です。
依存には3種類あります。
| 依存 | 意味 | 向き(Ti -> Tj) |
|---|---|---|
| ww-依存 | Ti が書いた行を Tj が上書き | Ti が先に書く |
| wr-依存 | Ti が書いた行を Tj が読む | Ti が先に書く |
| rw-依存(アンチ依存) | Ti が読んだ行を Tj が後から書き換える | Ti の読みが Tj の書きより先 |
SI は ww と wr を First-Committer-Wins とスナップショットで抑えますが、rw-依存だけは野放しです。Write Skew の依存グラフを描くと、T1 が読んだ B を T2 が書き(T1 -> T2 の rw)、T2 が読んだ A を T1 が書く(T2 -> T1 の rw)ため、rw-依存だけで閉路ができます。これが「SI では検知されないのに直列化不可能」という状態の正体です。
SSI:危険な構造を実行中に検知する
直列化可能スナップショット分離(SSI)は、SI の利点(読みがロックを取らず止まらない)を保ったまま、上の閉路を壊すアルゴリズムです。基盤は Cahill らの研究で、PostgreSQL の Serializable がこれを実装しています。
鍵は、SI 由来の閉路には必ず連続する2本の rw-依存を含む「危険構造(dangerous structure)」が現れる、という定理です。
rw rw
Tin --------> Tpivot --------> Tout
(入りと出の rw が pivot に揃う)
ある1本のトランザクション(pivot)が、自分への入り rw-依存と自分からの出 rw-依存を同時に持つとき、その並びは直列化不可能な閉路の一部になり得ます。SSI はこの「入りと出の rw が1本の pivot に揃った瞬間」だけを危険とみなします。
SSI は実行中、各読み取りに対して述語ロックに相当する SIREAD ロック(読み取った行・インデックス範囲・ギャップの印)を取得します。これはデータをブロックする排他ロックではなく、「あとで誰かがここを書いたら rw-依存が生じる」と記録するための軽量な見張りです。別トランザクションがその範囲に書き込むと rw-依存の辺が張られ、pivot に入り・出の rw が揃ったかを監視します。
危険構造を検知すると、SSI は関与するトランザクションのいずれかをシリアライズ失敗(PostgreSQL では SQLSTATE 40001)で中断します。実際のコミット衝突を待たず、依存構造だけで先回りして潰すのが特徴です。中断された側はアプリがそのまま再実行すれば、今度は片方がもう一方の結果を見られる順序で走り、整合します。
SSI は安全側に倒すため、実際には直列化できたはずの組でも中断する偽陽性があります。したがって SSI を使うアプリは 40001 を捕捉して自動リトライする作りが前提です。また見張りのコスト(SIREAD ロック)を抑えるには、不要に広い範囲を読まないこと・適切なインデックスで読む範囲を絞ることが効きます。明示的な SELECT ... FOR UPDATE で先に書く行を読み取りロックに変える手もありますが、それは設計でアンチ依存を消す行為で、SSI とは別アプローチです。
防ぎ方の比較
同じ Write Skew でも、対処の層が複数あります。実務では複数を組み合わせます。
| 手段 | 原理 | コスト・注意 |
|---|---|---|
| SERIALIZABLE(SSI) | rw-依存を追跡し危険構造を中断 | 偽陽性ありリトライ前提。読み範囲を絞ると軽い |
| SELECT ... FOR UPDATE | 判断に使った行を読み取り時にロック | rw をロック衝突に変換。デッドロックに注意 |
| 明示ロック / 物理化 | 制約をまたぐ行を1行に集約しロック競合化 | 設計変更が要るが確実。スケールしにくい |
| 一意制約・チェック制約 | DB の制約で不変条件を直接強制 | 件数下限など表現できない条件もある |
FOR UPDATE が効くのは、Write Skew の根が rw-依存(読んだ行が後で書き換わる)だからです。読み取りをロック付き読み取りに変えれば、そのアンチ依存は通常のロック衝突に化け、SI でも First-Committer-Wins と同じ土俵で衝突を検知できます。ただし読む行と書く行が完全にずれている場合は、判断に使った全行を確実にロックする必要があり、漏れると依然すり抜けます。
まとめ
- スナップショット分離は wr・ww 依存(ダーティリード・Lost Update など)を防ぐが、rw-依存(アンチ依存)を抑えないため Write Skew を防げない。
- Write Skew は「複数行を読んで判断し、別の行を書く」処理で起き、書き込みが衝突しないため First-Committer-Wins をすり抜ける。
- 直列化不可能な状態は依存グラフの閉路として現れ、SI 由来の閉路には必ず**連続2本の rw-依存(危険構造)**が含まれる。
- SSI は SIREAD ロックで rw-依存を追跡し、pivot に入り・出の rw が揃った瞬間に片方を中断(
40001)して直列化可能性を保証する。読みは止めないが偽陽性があるためリトライ前提で使う。
トランザクションの ACID 全体での位置付けは ACID、ロックと MVCC の基礎は ロックと MVCC も参照してください。
データベース Article
スナップショット分離と並行性アノマリーを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
データベース
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 5
導入後に効く点
Write Skew は2つのトランザクションが互いに別の行を読んで別の行を書くため書き込み衝突が起きず、スナップショット分離の検査をすり抜ける。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 5
判断チェックリスト
- 自社の用途が「データベース / トランザクション」に近いか確認する。
- 強みである「スナップショット分離は各トランザクションが開始時点の一貫した版を読むため Lost Update やダーティリードを防ぐが、Write Skew だけは構造的に防げない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。