Day19 前半のゴール
「“同時に更新しても壊れない”仕組みをイメージで理解する」
ここからはトランザクション編です。
Day19 のテーマは PostgreSQL の心臓部、「MVCC(Multi Version Concurrency Control:マルチバージョン制御)」。
前半のゴールはこうです。
なぜ MVCC という仕組みが必要なのかを、直感レベルで理解する。
「同じ行に“バージョン”がいくつも存在する」というイメージを持てる。
トランザクションごとに「見えている世界が違う」ことを、簡単な例で説明できる。
まずは、難しい用語より「ストーリー」で掴みにいきます。
なぜMVCCが必要なのか
「同時アクセスで“ケンカしない”ためのルール」
データベースは、同時にたくさんのクライアントからアクセスされます。
あるユーザーが商品を購入して在庫を減らしている。
別のユーザーが同じ商品ページを開いて在庫数を見ている。
さらに別のバッチ処理が、売上集計のために大量に読み取っている。
もし「1つの行を誰かが触っている間、他の人は一切触れない」というルールにすると、
すぐにロックだらけになって、システム全体が詰まります。
逆に、「みんな好き勝手に読み書きしていい」とすると、
在庫がマイナスになったり、更新が消えたり、整合性が壊れます。
そこで PostgreSQL が採用しているのが、「MVCC」という考え方です。
ざっくり言うと、
同じ行の“過去バージョン”を残しておき、
トランザクションごとに「どのバージョンが見えるか」をコントロールすることで、
読み取りと書き込みがケンカしないようにする仕組み
です。
MVCCのざっくりイメージ
「1行に“時間軸で並んだコピー”があると思ってみる」
普通に考えると、「テーブルの1行」は1つだけ、と思いがちです。
でも PostgreSQL の内部では、「同じ行のバージョン」が時間軸でいくつも並んでいるイメージになります。
ユーザーID 1 の行を例にします。
最初の状態:
id = 1, name = ‘Taro’, balance = 1000
誰かがトランザクションAで balance を 1000 → 800 に更新したとします。
UPDATE が走った瞬間、内部的にはこうなります。
古いバージョン:id = 1, name = ‘Taro’, balance = 1000
新しいバージョン:id = 1, name = ‘Taro’, balance = 800
「上書き」ではなく、「新しい行を追加して、古い行も残しておく」というのがポイントです。
そして、「どのトランザクションから見たときに、どのバージョンが見えるか」を MVCC が決めています。
具体例1:同時に読む人と書く人がいる場合
「書き込み中でも“安定した読み取り”ができる理由」
タイムラインを使ってイメージしてみましょう。
時刻 t1:
トランザクションAが開始する(ユーザーA)。SELECT * FROM accounts WHERE id = 1;
結果:balance = 1000
時刻 t2:
トランザクションBが開始する(ユーザーB)。UPDATE accounts SET balance = 800 WHERE id = 1;
まだ COMMIT していない。
時刻 t3:
トランザクションAがもう一度同じ行を読む。SELECT * FROM accounts WHERE id = 1;
ここで、トランザクションAは balance をいくつとして見るべきでしょうか。
MVCC では、「トランザクションAが開始した時点で“見えていた世界”を維持する」ように動きます。
つまり、A からはずっと balance = 1000 が見えます。
一方、トランザクションBの中では、更新後の balance = 800 が見えています。
A と B で「見えている世界」が違うわけです。
このおかげで、
読み取り側(A)は、書き込み中のBに邪魔されず、安定した結果を見続けられる。
書き込み側(B)は、新しいバージョンを作るだけで、読み取りを止めなくて済む。
という状態が実現できます。
具体例2:COMMITされた後に新しく来た人
「新しく来たトランザクションは“最新バージョン”を見る」
さきほどの続きです。
時刻 t4:
トランザクションBが COMMIT する。
これで balance = 800 のバージョンが「正式な最新」として確定します。
時刻 t5:
トランザクションCが新しく開始する。SELECT * FROM accounts WHERE id = 1;
このとき、トランザクションCは balance = 800 を見ます。
なぜなら、「Cが開始した時点では、すでにBの更新がコミット済み」だからです。
整理すると、
トランザクションA(t1開始)
→ 一生 balance = 1000 の世界を見続ける。
トランザクションB(t2開始)
→ 自分の中では balance = 800 を見て、COMMITで確定させる。
トランザクションC(t5開始)
→ 最初から balance = 800 の世界を見てスタートする。
この「開始したタイミングで“どのバージョンまで見えるか”が決まる」という考え方が、
MVCC の超重要ポイントです。
「スナップショット」という考え方
「トランザクションは“世界の写真”を1枚持っている」
今の話を、もう少し抽象化してみます。
トランザクションが開始した瞬間に、その時点のデータベースの状態を「スナップショット」として切り取る。
そのトランザクションは、基本的にそのスナップショットに基づいてデータを見る。
さっきの例で言うと、
トランザクションAのスナップショット
→ balance = 1000 の世界。
トランザクションCのスナップショット
→ balance = 800 の世界。
という感じです。
この「スナップショットを持っている」というイメージを持つと、
なぜ途中で他人が更新しても、自分の SELECT の結果がブレないのか。
なぜトランザクションを長く開きっぱなしにすると、古いバージョンが溜まり続けてしまうのか。
といった話にもつながっていきます(後半で触れます)。
MVCCとロックの関係
「“全部ロック”ではなく“バージョン+必要最小限のロック”」
「バージョンを増やすなら、ロックいらないの?」という疑問も出てくると思います。
答えは、「ロックも使うけど、読み取りのために行ロックを強制しないようにしている」です。
更新系(UPDATE / DELETE / INSERT)は、当然ながら競合を避けるためにロックを使います。
ただし、MVCC のおかげで「読み取りのために更新をブロックする」必要が減ります。
読み取りは「自分のスナップショットに合うバージョン」を見ればいいので、
更新中の最新バージョンを無理に見に行かなくていい、という発想です。
これが、PostgreSQL が「読み取りが多いシステムでも比較的スムーズに動く」理由のひとつです。
ざっくりまとめると「こういう世界」
1行は1つではなく、「時間軸に沿って複数バージョンが並んでいる」と考える。
トランザクションが開始した瞬間に、「どのバージョンまで見えるか」が決まる(スナップショット)。
読み取りはそのスナップショットに基づいて行われるので、途中で他人が更新しても結果がブレない。
更新は新しいバージョンを追加する形で行われ、古いバージョンはすぐには消されない(後半でVACUUMの話につながる)。
この「マルチバージョン+スナップショット」のイメージが、MVCC のコアです。
Day19 前半のまとめ
PostgreSQL の MVCC は、「同じ行を“上書き”するのではなく、“新しいバージョンを追加し、古いバージョンも残す”ことで、トランザクションごとに見える世界を変える」仕組みであり、トランザクション開始時点の状態を“スナップショット”として固定することで、「読み取り中に他のトランザクションが更新しても、自分の SELECT の結果がブレない」状態を実現している。
その結果、読み取りは基本的に更新をブロックせずに進められ、更新側は新しいバージョンを作るだけで済むため、「同時に読む人と書く人がいてもケンカしない」世界が作られている――この「1行に複数バージョン」「トランザクションごとに違うスナップショット」というイメージを持つことが、Day19 前半の着地点になる。
