目次を表示する

RDB内部構造完全ガイド

MVCC ── 読み書きが互いをブロックしない仕組み

MVCC ── 読み書きが互いをブロックしない仕組み

このレイヤーの役割:トランザクション間のデータの可視性を制御する。「読み取りが書き込みをブロックしない」を実現する RDB の核心技術。


MVCC — マルチバージョン同時実行制御・スナップショット分離

この章で何ができるようになるか:「なぜ SELECT は UPDATE をブロックしないのか」を内部動作から説明でき、分離レベルごとの可視性ルールを理解できるようになる。


なぜ MVCC が必要か

MVCC なし(単純ロック):
  TX1: SELECT * FROM users WHERE id = 1;  ← 読み取りロック取得
  TX2: UPDATE users SET name = 'Bob' WHERE id = 1;  ← TX1 のロック解放を待つ(ブロック)
  → 読み取りが書き込みをブロック → スループット低下

MVCC あり:
  TX1: SELECT * FROM users WHERE id = 1;  ← TX1 開始時点のスナップショットを読む
  TX2: UPDATE users SET name = 'Bob' WHERE id = 1;  ← 新しいバージョンを作成
  → TX1 は古いバージョンを、TX2 は新しいバージョンを、それぞれ独立に参照
  → 読み取りと書き込みが互いをブロックしない

PostgreSQL の MVCC 実装

PostgreSQL は「タプル(行)に複数のバージョンを持つ」アプローチ。

タプルの可視性判定

各タプルが持つ情報:
  t_xmin: このタプルを INSERT したトランザクション ID
  t_xmax: このタプルを DELETE/UPDATE したトランザクション ID(未削除なら 0)
  t_infomask: コミット済みか、アボートかなどのビットフラグ

可視性ルール(Read Committed の場合):
  1. t_xmin がコミット済み AND t_xmax が 0(未削除)→ 見える
  2. t_xmin がコミット済み AND t_xmax がコミット済み → 見えない(削除済み)
  3. t_xmin が未コミット → 見えない(まだ確定していない)
  4. t_xmin が自分自身の TX → 見える(自分が INSERT した行)
# 可視性判定の擬似コード
def is_visible(tuple, current_tx, snapshot):
    xmin = tuple.t_xmin
    xmax = tuple.t_xmax

    # 挿入した TX がアボートされた → 見えない
    if is_aborted(xmin):
        return False

    # 挿入した TX がまだ実行中(自分以外)→ 見えない
    if is_in_progress(xmin) and xmin != current_tx:
        return False

    # 挿入した TX がコミット済み
    if is_committed(xmin):
        # 削除されていない → 見える
        if xmax == 0:
            return True
        # 削除した TX がコミット済み → 見えない
        if is_committed(xmax):
            return False
        # 削除した TX がまだ実行中 → 見える(削除はまだ確定していない)
        if is_in_progress(xmax):
            return True

    return False

UPDATE の内部動作

PostgreSQL の UPDATE は**「旧タプルを削除 + 新タプルを挿入」**として実装される。

-- 初期状態
-- Tuple A: (id=1, name='Alice', t_xmin=100, t_xmax=0)

BEGIN;  -- TX ID = 200
UPDATE users SET name = 'Bob' WHERE id = 1;

-- 内部で起きること:
-- 1. 旧タプル A の t_xmax を 200 に設定(削除マーク)
-- 2. 新タプル B を作成: (id=1, name='Bob', t_xmin=200, t_xmax=0)

COMMIT;

-- 結果:
-- Tuple A: (id=1, name='Alice', t_xmin=100, t_xmax=200) ← Dead Tuple
-- Tuple B: (id=1, name='Bob', t_xmin=200, t_xmax=0)     ← Live Tuple

Dead Tuple が蓄積する → テーブルが肥大化する(Bloat)→ VACUUM が回収する(Ch.11 で詳述)。


トランザクション分離レベル

-- 分離レベルの設定
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- デフォルト
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

Read Committed(デフォルト)

各文(Statement)の開始時にスナップショットを取得。同じ TX 内でも文ごとに最新のコミット済みデータを読む。

-- TX1
BEGIN;
SELECT * FROM users WHERE id = 1;  -- name = 'Alice'

-- TX2 が UPDATE + COMMIT
-- TX2: UPDATE users SET name = 'Bob' WHERE id = 1; COMMIT;

SELECT * FROM users WHERE id = 1;  -- name = 'Bob'(最新が見える)
COMMIT;

Repeatable Read

TX 開始時に1つのスナップショットを取得し、TX 全体でそれを使い続ける。

-- TX1
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM users WHERE id = 1;  -- name = 'Alice'

-- TX2 が UPDATE + COMMIT

SELECT * FROM users WHERE id = 1;  -- name = 'Alice'(TX開始時のスナップショット)
COMMIT;

Serialization Anomaly:Repeatable Read では Phantom Read は防げるが、書き込みスキューは防げない。

Serializable(SSI: Serializable Snapshot Isolation)

PostgreSQL の Serializable は **SSI(Serializable Snapshot Isolation)**で実装されている。

SSI の仕組み:
  Repeatable Read のスナップショットを基盤に、
  トランザクション間の「読み書きの依存関係」を追跡する。
  シリアライズ不可能な依存グラフ(rw-antidependency のサイクル)を
  検出したら、1つの TX をアボートする。

利点:
  - ロックベースの Serializable(2PL)と違い、読み取りがブロックされない
  - MVCC の「読み書き非ブロック」を維持したまま Serializable を実現
  
コスト:
  - 依存関係の追跡オーバーヘッド
  - False Positive でアボートが増えることがある

分離レベルの比較

異常現象Read CommittedRepeatable ReadSerializable
Dirty Read✅ 防止✅ 防止✅ 防止
Non-Repeatable Read❌ 起きる✅ 防止✅ 防止
Phantom Read❌ 起きる✅ 防止※✅ 防止
Serialization Anomaly❌ 起きる❌ 起きる✅ 防止

※ PostgreSQL の Repeatable Read は MVCC ベースのため Phantom Read も防止する(SQL 標準より厳しい)


MySQL InnoDB との MVCC 比較

PostgreSQLMySQL InnoDB
バージョン管理ヒープに複数バージョンが共存Undo ログに旧バージョンを退避
Dead Tupleヒープに残る → VACUUM で回収Undo ログから自動パージ
Bloat起きやすい(VACUUM 依存)起きにくい(Undo ログは自動回収)
Index の更新HOT で回避可能クラスタードインデックスの構造上必要
長時間 TX の影響Dead Tuple が回収できないUndo ログが肥大化(history list length)

MVCC のコスト

利点:
  - 読み取りと書き込みが互いをブロックしない
  - 一貫性のあるスナップショットを読める
  - ロックの待ち時間がない

コスト:
  - Dead Tuple の蓄積(Bloat)→ VACUUM が必要
  - タプルヘッダのオーバーヘッド(23B/行)
  - 可視性判定のCPUコスト
  - 長時間トランザクションが Dead Tuple の回収を妨げる

まとめ

概念PostgreSQL での実装
バージョン管理タプルに t_xmin / t_xmax を保持
UPDATE旧タプルに削除マーク + 新タプル挿入
可視性判定TX ID + スナップショットで判定
Read Committed文ごとにスナップショット
Repeatable ReadTX ごとにスナップショット
SerializableSSI(依存グラフで検出)
Dead Tuple 回収VACUUM(Ch.11 で詳述)