Day19 後半のゴール
「“MVCCの副作用”と“運用で気をつけるポイント”を理解する」
前半で「マルチバージョンで世界を見分ける」イメージは掴めました。
後半では、その裏側で何が起きているか――特に「古いバージョンはどうなるのか」「長時間トランザクションがなぜ危険なのか」「どの隔離レベルでどう見え方が変わるのか」を、実務寄りに整理していきます。
MVCCの裏側:古いバージョンはどこへ行くのか
「“消さないで残す”の次に、“いつ片付けるか”が問題になる」
MVCCは「更新時に新しいバージョンを追加し、古いバージョンも残す」仕組みでした。
ここで自然に出てくる疑問が、「じゃあ古いバージョンは永遠に残るの?」です。
答えは「残さない。ただし“すぐには”消さない」です。
PostgreSQLは、更新や削除で不要になった古いバージョンを「dead tuple(死んだ行)」としてマークし、
あとから VACUUM という仕組みで片付けます。
イメージとしては、
更新時点:
古い行は「もう誰も新しくは参照しない」候補になる。
ただし、まだ古いスナップショットを見ているトランザクションがいるかもしれない。
そのトランザクションが終わるまでは、古いバージョンを消せない。
という流れです。
ここで重要なのが、「長く生きているトランザクションがいると、古いバージョンをずっと消せない」という点です。
例題1:長時間トランザクションが“掃除”を止めてしまう
「1つの古いスナップショットが、テーブル全体を重くする」
具体的なシナリオで見てみましょう。
時刻 t1:
トランザクションAが BEGIN する(何もしていないが、開きっぱなし)。
時刻 t2〜t10:
別のトランザクションたちが、accounts テーブルの行を大量に UPDATE しまくる。
そのたびに、新しいバージョンが増え、古いバージョンが dead tuple になる。
時刻 t11:
VACUUM が走って、「もう誰からも見られない古いバージョン」を片付けたい。
しかし、ここで問題が起きます。
トランザクションAは t1 のスナップショットを持っているので、「t1時点で存在していたバージョン」は、
Aから見える可能性があります。
つまり、Aが終わるまで、「t1より前に作られた古いバージョン」は消せないのです。
その結果、
テーブルの中に dead tuple がどんどん溜まる。
インデックスも膨らみ、スキャンが重くなる。
ディスク使用量も増える。
という「じわじわ効いてくるパフォーマンス悪化」が起きます。
ここから導かれる実務的な教訓はシンプルです。
「BEGIN したまま放置する長時間トランザクションは、MVCC的にかなり危険」ということです。
VACUUMという“掃除係”
「MVCCが生む“ゴミ”をどう片付けるか」
MVCCが「バージョンを増やす」仕組みだとすると、
VACUUMは「不要になったバージョンを片付ける」仕組みです。
役割を整理すると、
dead tuple を見つけて、「もうどのトランザクションからも見えない」ものを物理的に再利用可能にする。
インデックスからも不要なエントリを取り除く(VACUUM FULL や REINDEX なども含めて)。
ここでのポイントは、「VACUUMは“勝手に全部消せるわけではない”」ということです。
さきほどのように、古いスナップショットを持つトランザクションが生きていると、
そのスナップショットより前のバージョンは消せません。
だからこそ、
アプリケーションで「BEGIN だけして何もしない」状態を作らない。
長時間トランザクション(バッチ・レポート系)は、本当に必要な範囲に絞る。
といった運用上の注意が、MVCCと直結してきます。
MVCCとトランザクション分離レベル
「“どこまで過去の世界を固定するか”の違い」
PostgreSQLには、いくつかのトランザクション分離レベルがあります。
MVCCの観点から、ざっくり2つだけ押さえておきましょう。
READ COMMITTED
デフォルトの分離レベルです。
各クエリごとに「そのクエリ開始時点のスナップショット」を見るイメージです。
同じトランザクション内でも、
1回目の SELECT と2回目の SELECT で、他人の COMMIT 済み更新が見えることがあります。
例:
トランザクションA:SELECT balance FROM accounts WHERE id = 1; → 1000
トランザクションB:UPDATE accounts SET balance = 800 WHERE id = 1; → COMMIT
トランザクションA:
もう一度 SELECT ... → 800 が見える可能性がある。
REPEATABLE READ
「トランザクション開始時点のスナップショット」をトランザクション全体で固定します。
同じトランザクション内では、何度 SELECT しても、
他人の COMMIT 済み更新は見えません(自分の更新は別)。
例:
トランザクションA(REPEATABLE READ):
BEGIN 時点で balance = 1000 のスナップショットを持つ。
何度 SELECT しても、他人の更新による 800 は見えない。
この違いも、MVCCの「どのスナップショットを使うか」の話です。
READ COMMITTED は「クエリ単位でスナップショットを取り直す」。
REPEATABLE READ は「トランザクション単位でスナップショットを固定する」。
例題2:REPEATABLE READで“世界が止まる”感覚
「集計中に他人が更新しても、自分の世界は揺れない」
REPEATABLE READ のイメージを、集計クエリで見てみます。
トランザクションA(REPEATABLE READ):
BEGIN;
SELECT SUM(balance) FROM accounts; → 10000
この間に、トランザクションBが何件か balance を更新して COMMIT したとします。
トランザクションA:
もう一度 SELECT SUM(balance) FROM accounts; → 10000 のまま
Aの中では、「BEGIN 時点の世界」が固定されているので、
途中で他人がどれだけ更新しても、集計結果は変わりません。
これは、「レポート・バッチ処理で一貫したスナップショットを見たい」ときに有効です。
逆に、「最新の値を常に見たい」APIには向きません。
ここでも、「スナップショットをどの単位で固定するか」が、
MVCCと分離レベルの接点になっています。
MVCCとセキュリティ・監査の視点
「“いつ・誰が・どのバージョンを見たか”という発想」
セキュリティや監査の観点から見ると、MVCCはこういう意味も持ちます。
あるユーザーがトランザクション内で見ていたデータは、「その時点のスナップショットに基づく」ものであり、
あとから「実はその時点では別の値だった」と言い張ることはできない。
つまり、「いつ・誰が・どのスナップショットを見ていたか」をログと組み合わせて追えると、
監査上の説明がしやすくなります。
また、長時間トランザクションが監査用のレポートで使われている場合、
「そのレポートは“いつ時点のスナップショット”に基づいているのか」を明示しておくと、
後からの検証がしやすくなります。
MVCCは単に性能の話ではなく、「時間と状態をどう扱うか」という意味で、
セキュリティ・監査とも地続きの概念です。
Day19 後半のまとめ
MVCCは「バージョンを増やす」仕組みであると同時に、「古いバージョンをいつ片付けるか」という運用課題を生み、その片付け役が VACUUM であり、長時間トランザクションが古いスナップショットを握り続けると dead tuple が掃除できず、テーブル・インデックスの膨張やパフォーマンス悪化につながる。
トランザクション分離レベルは、「スナップショットをクエリ単位で取り直す(READ COMMITTED)」か「トランザクション単位で固定する(REPEATABLE READ)」かの違いとして MVCCの上に乗っており、前者は“最新寄り”、後者は“一貫性重視”の読み取りになる。
最終的に、「MVCC=マルチバージョン+スナップショット+掃除(VACUUM)」「長時間トランザクションは掃除を止める」「分離レベルは“どのスナップショットをどの単位で固定するか”の設定」という3点をセットで理解しておくことが、Day19 後半の着地点になる。
