4月17日、著者が「PostgreSQL MVCC, Byte by byte」と題した記事を公開した。この記事では、PostgreSQLのMVCC(Multi-Version Concurrency Control)の仕組みを、実際のバイトレベルでの動作まで掘り下げて解説している。以下に、その内容を紹介する。
なぜ今MVCCを理解すべきなのか
MVCC(Multi-Version Concurrency Control)は、PostgreSQLが高い並行性能を実現する核心技術である。近年のクラウドネイティブアプリケーションでは、複数のユーザーが同時にデータベースにアクセスすることが当たり前となり、MVCCの理解はパフォーマンスチューニングや運用において不可欠となっている。
特に、長時間実行されるクエリによるブロートや、VACUUMの効果が上がらない問題に直面したことがあるエンジニアにとって、MVCCの仕組みを理解することは根本的な解決策を見つける第一歩となる。
同じページ、同じバイト、異なる結果
50,000,000行を見る人と49,999,999行を見る人が、全く同じ瞬間に同じテーブルをクエリしている。どちらも間違っていないし、古いデータを見ているわけでもない。両者は全く同じ8KBヒープページ、同じディスク上のバイトを読んでいる。
これがPostgreSQLのMVCCが提供する約束だ。読み取りが書き込みをブロックすることも、書き込みが読み取りをブロックすることもない。この魔法の答えは、すべてのタプル(行)に含まれるたった8バイトに隠されている。
xminとxmax:たった2つのXIDがすべてを決める
PostgreSQLでは、すべてのタプルが23バイトのヘッダーで始まり、その最初の8バイトが2つの32ビットトランザクションID(XID)だ:
t_xmin: このバージョンを挿入したトランザクションt_xmax: これを削除または更新したトランザクション(生きている場合は0)
これがストレージレベルでのMVCCの核心である。PostgreSQLは「現在のバージョン」を別途管理しない。クエリがページを読むとき、PostgreSQLはタプルごとに、そのトランザクションがそのタプルを見ることが許可されているかを判断しなければならない。
実際に見てみよう:
CREATE TABLE mvcc_demo (id int, val text);
INSERT INTO mvcc_demo VALUES (1, 'alpha'), (2, 'beta');
-- 生のページ内容を確認
SELECT lp, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('mvcc_demo', 0));
lp | t_xmin | t_xmax | t_ctid
----+--------+--------+--------
1 | 100 | 0 | (0,1)
2 | 100 | 0 | (0,2)
2つのタプルがt_xmin = 100(INSERTを実行したトランザクション)とt_xmax = 0(削除されていない)でスタンプされている。
更新の瞬間:3つのタプルの物語
セッションAで更新を実行するが、コミットはしない:
-- session A
BEGIN;
UPDATE mvcc_demo SET val = 'alpha-new' WHERE id = 1;
-- コミットしない
再度ページを確認すると:
lp | t_xmin | t_xmax | t_ctid
----+--------+--------+--------
1 | 100 | 101 | (0,3)
2 | 100 | 0 | (0,2)
3 | 101 | 0 | (0,3)
1回の更新で3つのタプルが存在している。古いバージョン(line pointer 1)はt_xmax = 101でスタンプされ、新しいバージョン(line pointer 3)はt_xmin = 101を持つ。
セッションAはまだコミットしていない。トランザクション101は実行中だ。この時点でセッションBがSELECTを実行すると、まだ元のalphaが見え、alpha-newは見えない。ディスク上のバイトは変わらないが、可視性の判定が読み取り時にその場で適用される。
スナップショット:可視性を決める3つの数字
可視性判定の核心となるのがスナップショットだ。pg_current_snapshot()で現在のセッションが持つスナップショットを確認できる:
SELECT pg_current_snapshot();
-- 結果: 101:103:101
これはxmin:xmax:xip_listの形式で:
- xmin: まだ実行中の可能性がある最小XID
- xmax: まだ割り当てられていない最初のXID
- xip_list: xminとxmaxの間でまだ実行中のXID
PostgreSQLはタプルごとにこのテストを適用する。スナップショットがt_xminを中止済みまたは実行中と判断すれば、そのタプルは存在しない。t_xminがコミット済みなら、今度はt_xmaxが決定する。
READ COMMITTEDとREPEATABLE READ:スナップショットのタイミング
PostgreSQLの2つの主要な分離レベルの違いは、いつスナップショットが取得されるかに集約される。
READ COMMITTED(デフォルト)は文ごとに新しいスナップショットを取得する。他のセッションがコミットすれば、次のSELECTでその変更が見える。
REPEATABLE READはトランザクション開始時に1つのスナップショットを取得し、すべての文でそれを再利用する。他のセッションが何千回変更をコミットしても、クエリはBEGIN時点で見えていたものを返し続ける。
長時間実行トランザクションの罠
VACUUMが死んだタプルを回収できるのは、実行中のトランザクションがそれを必要としない場合のみだ。5分前にREPEATABLE READトランザクションを開始してアイドル状態のセッションがあれば、そのスナップショットは古いバージョンを生きていると判断する。VACUUMはそのセッションを壊すことなくそのタプルに触れることができない。
1時間かかる分析クエリは、その1時間の間に作られたすべてのタプルバージョンを事実上固定する。テーブルはブロートし続け、autovacuumは実行されるが掃除できるものを見つけられずに帰っていく。これが本番環境でしばしば観測される「VACUUMが効かない」問題の根本原因だ。
ヒントビット:なぜSELECTがページを汚すのか
新しい書き込み後にページに最初に触れるSELECTは、ページをディスクに書き戻す原因となることがある。SELECTがデータを変更したからではなく、ヒントビットを設定したからだ。
PostgreSQLがt_xmin = 101のタプルに遭遇し、101がコミットされたかを知る必要があるとき、魔法のようにそれを知るわけではない。コミットログpg_xactで101を調べなければならない。答えを見つけると、その答えをタプルのt_infomaskビット(HEAP_XMIN_COMMITTEDまたはHEAP_XMIN_INVALID)にキャッシュする。
これらのビットの設定は書き込みだ。ページが汚れ、最終的にフラッシュされる。無害なSELECTがI/Oを引き起こすことになる。
すべての更新が死んだタプルを残す理由
PostgreSQLのすべての更新は新しいタプルバージョンを作成する。古いバージョンは消えない。t_xmaxでスタンプされ、VACUUMが来て回収するまでページ上に残り続ける。
更新の多いビジーなテーブルでは、VACUUMが処理するよりも速く死んだタプルが蓄積される可能性がある。これが「ブロート」で、チームがPostgreSQLのチューニングが必要だと考える最も一般的な理由の1つだ。
MVCCの契約
すべてのタプルがt_xminとt_xmaxを持ち、すべてのトランザクションが(xmin, xmax, xip_list)のスナップショットを持つ。可視性はこの2つを比較する2段階の検索だ。UPDATEとDELETEはその場でバイトを変更しない。古いバージョンにt_xmaxをスタンプし、新しいバージョンを追加する。VACUUMは死んだバージョンを掃除するが、生きているトランザクションがまだ必要とする可能性があるものは除く。
タプルあたり8バイトのXID、トランザクションあたり3つの数字のスナップショット、そして1つの可視性関数。これが全体の仕組みだが、その結果はブロート監視からレプリケーション、autovacuumチューニングまで、PostgreSQL運用のあらゆる部分に波及する。
詳細はPostgreSQL MVCC, Byte by byteを参照していただきたい。