並行制御 — 同時に触っても壊さない

たくさんの利用者が同じデータを一斉に読み書きすると、答えが静かに壊れます。更新が消えるロスト更新、幻を読むダーティリード、同じ行が2度で違う反復不能読み取り。それを防ぐ2つの流儀 — 順番待ちの2相ロック(2PL)と、版を配るMVCC、そして安全さと速さを選ぶ分離レベルを、実行の交錯を1手ずつ動かしながら掴みます。

1. 答えが静かに壊れる — 3つの異常

2つのトランザクションが交互に同じ行を触ると、単独なら正しい処理でも結果が狂います。代表的な3つの異常を、実行の順番(スケジュール)を1手ずつ進めて観察しましょう。正しいのは「あるトランザクションを全部終えてから次」という直列実行と同じ結果になること。それを崩す交錯が「悪いスケジュール」です。

悪い交錯が結果を壊す — ロスト更新 / ダーティリード / 反復不能読み取り
水色=T1、桃色=T2。▶ が次に実行される操作。上の箱がレコード X の値(確定値=ディスク、現在値=未コミットも反映)。交錯を進めると、T1の更新がT2に上書きされて消える/T1が巻き戻す幻の値をT2が読む/T1が同じ X を2回で別の値で読む、が最下段に赤で現れます。
注意 — 異常は「たまに」しか出ない 同じSQLでも交錯の順番次第で壊れたり壊れなかったりする。ほとんどの順番は無事なので、テストで悪い順番を毎回引き当てるのは至難。だから並行のバグは本番で稀に、再現しにくく牙をむく。防ぐには「順番運」ではなく仕組みで直列化するしかない。
起こりうる交錯順序 = (m + n)! / (m! · n!) m,n=各トランザクションの操作数。数手ずつでも十数〜数十通りに膨れ、その中に必ず「壊す順番」が紛れている。

2. 順番に並べる — 2相ロック(2PL)

王道はロック。読む前に共有ロック、書く前に専有ロックを取り、他は空くまで待つ。ただ取って離すだけでは直列化できません。鍵は2相ロック(2PL) — ロックを取り続ける「成長相」→ 一度でも離したら二度と取らない「縮退相」の順を全トランザクションが守ること。これだけで、交錯していてもある直列実行と同じ結果(直列化可能)が保証されます。

下では T1・T2 が同じ X を更新します。2PLありだと T2 は T1 のロックが空くまでドア前で待ち(=直列化)、結果は正しく 110。ロックなしだと2人が同時に入り、更新が衝突して 90(=ロスト更新)に落ちます。

ロックで順番待ち — 2PLあり / ロックなし
横軸=時間、縦の白線=いまこの瞬間。T1が X を専有ロック(成長相・青)→ 書いてコミットで解放(縮退相・緑)。その間 T2 は待機(斜線)し、解放後にやっと入って正しく更新。ロックなしだと2人が重なって書き込み、更新消失が赤く点滅し X が 90 に狂います。
POINT — 2相であることが直列化を生む 「取り切ってから、離し切る」という山型の一峰が2PLの本質。途中で離してまた取ると、その隙に別トランザクションが割り込んで循環が生まれ、直列化が崩れる。実務では、コミットまでロックを離さない厳格2PL(S2PL)が主流 — 連鎖的な巻き戻し(カスケードアボート)まで防げる。
スケジュール S が直列化可能 ⟺ 優先グラフ G(S) が非巡回 競合する操作の順序を辺で結んだグラフに閉路が無ければ、S はある直列順と等価。2PL を守れば G(S) は必ず非巡回になる。

3. 待たせずに読ませる — MVCC

ロックの弱点は読み手と書き手が互いを待たせること。これを解くのがMVCC(多版並行制御)。データを上書きせず新しい版を追加し、読み手には自分が始めた瞬間の一貫したスナップショットを見せます。書き手が版を更新している最中でも、読み手は古い版をブロックされずに読める。読み手は書き手を待たず、書き手も読み手を待たない。

下で「読み取りトランザクション R の開始時刻」を動かしてみてください。R が見る値は開始時点で確定済みの最新版。書き込み W がコミットする前なら旧版(100)、後なら新版(200)。R はいつでも即座に読めます。「ロック方式と比較」を入れると、ロックだと R が W の完了までブロックされる様子が対比できます。

版を配って待たせない — スナップショット読み取り
上段=レコード X の版の履歴(v0=100 → 書込Wがコミットした時点で v1=200 が誕生)。中段=書込トランザクション W の稼働区間。縦線=読み取り R の開始。R は開始時点の確定版を指す矢印の値を読む。MVCCなら赤い待機は無し。比較を入れると、Wの稼働中に始めた R がロックでブロックされる(赤)のが分かります。
注意 — スナップショットは万能ではない MVCCの標準的な設定(スナップショット分離)は、ダーティリードも反復不能読み取りも防ぐが、書き込みスキューという異常が残る。互いに相手が読んだ行を書き換え、各自のスナップショットでは整合しているのに全体では制約が破れる例だ。完全な直列化には直列化可能スナップショット分離(SSI) など追加の検査が要る。

4. 安全さと速さを選ぶ — 分離レベル

「どこまで厳密に隔離するか」は分離レベルで選べます。厳しくするほど異常は消えますが、待ちが増えて並行性(スループット)は落ちる。逆に緩めれば速いが異常を許容する。SQL標準は下の4段の梯子で、上に登るほど防げる異常が増えます。

分離レベルの梯子 — 各段で防げる異常
行=分離レベル(下ほど厳格)、列=3つの異常。緑✓=防止、赤○=発生しうる。スライダーで選んだ段が強調され、右のゲージで「安全性↑・並行性↓」のトレードオフが見えます。多くのDBの既定は READ COMMITTED。MySQL(InnoDB)の既定は REPEATABLE READ で、次キーロックによりファントムも実質防ぎます。
POINT — 「一番安全」を常用しない理由 SERIALIZABLE は全異常を防ぐが、待ちや再試行が増えて遅くなりがち。だから実務は「このトランザクションはどの異常を許せるか」で段を選ぶ。2PL は待たせて守る悲観的、MVCC/スナップショットは版で衝突を避ける楽観寄り。現代DBの多くは両者を組み合わせて、読みは待たせず、書きの競合だけをロックや検査でさばく。

5. まとめ — 並行制御の地図

一歩先へ — 悲観と楽観のあいだ ロックで先回りして待たせる悲観的方式に対し、まず走らせて衝突したらやり直す楽観的(OCC)方式もある。PostgreSQL や Oracle は MVCC を基盤に、書き込み競合だけを検出。SSI は依存の危険な組を見つけて片方を中止し、スナップショットのまま真の直列化を達成する。分散DBでは各ノードの時計やタイムスタンプ順序付けで全体の順序を作る。並行制御の正しさは、速さではなく「順序をどう設計するか」で決まる。