PostgreSQLにおけるMVCCの深掘り
Grace Collins
Solutions Engineer · Leapcell

PostgreSQLにおけるMVCCの理解
導入
データベース管理システムの世界では、複数の同時操作を処理しながら、データの整合性と分離性を確保することは、途方もない課題です。堅牢なメカニズムがなければ、同時トランザクションは、更新の消失、ダーティリード、非再現読み取り、ファントムリードなど、数多くの問題を引き起こす可能性があります。これらの問題は、データを破損させ、アプリケーションロジックを破壊し、パフォーマンスを著しく低下させる可能性があります。ここで、同時実行制御メカニズムが不可欠になります。その中でも、マルチバージョン同時実行制御(MVCC)は、特にPostgreSQLのようなリレーショナルデータベースにおいて、非常に効果的で広く採用されているアプローチとして際立っています。MVCCは、異なるトランザクションがデータベースの異なる「スナップショット」を見ることができるようにすることで、読み取り操作中の従来のロックメカニズムの必要性を事実上排除し、それによって同時実行性を大幅に向上させます。PostgreSQLでのMVCCの仕組みを理解することは、データベースパフォーマンスの最適化、同時実行問題のトラブルシューティング、または単に最新RDBMSのエンジニアリングの驚異に対する深い評価を得ようとする人にとって不可欠です。この記事では、PostgreSQL内のMVCCの複雑さを掘り下げ、そのコア原則を解き明かし、効率的でノンブロッキングな操作をどのように可能にしているかを説明します。
MVCCのコアコンセプト
PostgreSQLの特定の実装に飛び込む前に、MVCCを支える主要な概念の基礎的な理解を確立しましょう。
- トランザクションID(XID): PostgreSQLのすべてのトランザクションには、一意で単調増加する32ビットのトランザクションID(XID)が割り当てられます。このXIDはMVCCの基本であり、トランザクションの開始時点を示します。
- タプルバージョン: レコードをインプレースで更新する代わりに、MVCCシステム(PostgreSQLなど)は、行が変更または削除されるたびに、行の新しいバージョン(タプル)を作成します。古いバージョンは、まだそれを見る必要がある可能性のある他の同時トランザクションのために、データベース内に保持されます。
- 可視性ルール: これらのルールは、特定のトランザクションがどのタプルバージョンを「見ることができる」かを決定します。タプルの可視性は、現在のトランザクションのXIDと比較した、その作成XID(
xmin
)と失効XID(xmax
)に基づいて判断されます。 xmin
(作成XID): これは、タプルのこの特定のバージョンを作成したトランザクションのXIDです。xmax
(削除/更新XID): これは、このタプルバージョンを「論理的に」削除した、または更新した(これは事実上、新しいバージョンを作成し、古いバージョンをそのトランザクションによって「削除済み」としてマークすることを意味します)トランザクションのXIDです。xmax
が0の場合、タプルは削除も更新もされていません。- トランザクションステータス: アクティブなXIDに加えて、PostgreSQLは
pg_clog
(コミットログ)も維持しており、過去のトランザクションのステータス(コミット済み、中止済み、進行中)を格納します。これは、可視性ルールを正確に適用するために不可欠です。
実践におけるMVCC:PostgreSQLの仕組み
PostgreSQLのMVCC実装は、これらのタプルバージョンと可視性ルールの周りに構築されています。トランザクションがデータを読み取る必要がある場合、他の書き込みをブロックするロックを取得しません。代わりに、どのバージョンがそれに対して可視であるかを判断するために、各タプルバージョンのxmin
とxmax
、およびトランザクションステータスを参照します。
更新プロセス
products
という名前のテーブルに対する単純なUPDATE
操作を考えてみましょう。
UPDATE products SET price = 100.00 WHERE id = 1;
このステートメントがトランザクション(XID 100としましょう)内で実行されると、PostgreSQLは既存の行を直接変更しません。代わりに、次の手順を実行します。
- 古いタプルをマーク:
id = 1
の既存のタプルを見つけ、そのxmax
を100(現在のトランザクションのXID)に設定します。これにより、古いバージョンがトランザクションXID 100によって「削除済み」として効果的にマークされます。 - 新しいタプルを挿入:
price = 100.00
の新しいタプルをid = 1
に対して作成します。この新しいタプルのxmin
は100に設定され、xmax
は最初は0(まだ削除されていないことを意味します)です。
重要なのは、トランザクションXID 100がCOMMITした場合、古いタプルのxmax
はコミットされたトランザクションに関連付けられ、新しいタプルのxmin
もコミットされたトランザクションに関連付けられることです。トランザクションXID 100がROLLBACKした場合、両方の変更は効果的に元に戻されます。古いタプルのxmax
はクリアされ、新しいタプルは非表示になり、ガベージコレクションの対象となります。
可視性ルール
タプルバージョンは、これらの条件が満たされている場合にのみ、トランザクション(現在のXIDがcurrent_xid
であるとしましょう)に対して可視です。
-
作成XID(
xmin
)チェック:xmin
はcurrent_xid
より小さい。- または
xmin
はcurrent_xid
と同じ(現在のトランザクションが作成したことを意味します)。 - かつ
xmin
はコミットされたトランザクションである(xmin
がcurrent_xid
でない限り)。
-
削除XID(
xmax
)チェック:xmax
は0(まだ削除されていないことを意味します)。- または
xmax
はcurrent_xid
より大きい。 - または
xmax
はcurrent_xid
と同じ(現在のトランザクションが削除したことを意味しますが、古いバージョンはこのトランザクション自身が自身の変更を読み取っている場合、まだ可視である可能性があります)。 - かつ
xmax
は中止されたトランザクションである(xmax
がcurrent_xid
でなく、現在のトランザクションがこれを削除した場合を除く)。
これらのルールは、各トランザクションが開始時点のスナップショットとして一貫したデータビューを参照することを保証し、ダーティリードや非再現読み取りから保護します。
例で示しましょう。
-- 初期状態: -- productsテーブル: -- (id=1, name='Laptop', price=999.99, xmin=50, xmax=0) -- XID 50によってコミット済み -- トランザクションA(XID 100)開始 BEGIN TRANSACTION; -- 他のトランザクション(XID 90、95)が同時に読み取っている可能性があります。それらはXID 50のバージョンを見ます。 -- トランザクションB(XID 105)開始。ラップトップの価格を更新しようとします。 BEGIN TRANSACTION; UPDATE products SET price = 1050.00 WHERE id = 1; -- PostgreSQLの状態(簡易版): -- (id=1, name='Laptop', price=999.99, xmin=50, xmax=105) -- XID 105によってマークされた古いバージョン -- (id=1, name='Laptop', price=1050.00, xmin=105, xmax=0) -- XID 105によって作成された新しいバージョン -- トランザクションA(XID 100)内部: SELECT * FROM products WHERE id = 1; -- 出力: (id=1, name='Laptop', price=999.99) -- なぜなら? XID 100はXID 50によって作成されたバージョン(`xmin=50 < 100`、`xmax=105` > `100`だが、XID 105はXID 100が読み取る際にはまだ進行中かコミットされていない)。 -- XID 105によって作成されたバージョン(`xmin=105` > `100`)は、XID 100には見えません。 -- トランザクションB(XID 105)コミット COMMIT; -- ここで、新しいトランザクションC(XID 110)が開始します。 BEGIN TRANSACTION; SELECT * FROM products WHERE id = 1; -- 出力: (id=1, name='Laptop', price=1050.00) -- なぜ? XID 110: -- - (xmin=50, xmax=105)について: xmin=50 < 110(コミット済み)、xmax=105 < 110(コミット済み)。このバージョンは、コミット済みXID 105によって削除されたため、可視ではありません。 -- - (xmin=105, xmax=0)について: xmin=105 < 110(コミット済み)、xmax=0。このバージョンは可視です。 COMMIT;
pg_clog
とトランザクションステータス
pg_clog
(pg_xact
とも呼ばれます)は、過去のトランザクションのコミットステータスを格納する重要なコンポーネントです。これは、XIDをその状態(コミット済み、中止済み、進行中)にマッピングするファイルのディレクトリです。トランザクションがxmin
またはxmax
のステータスを確認する必要がある場合、pg_clog
を参照します。これにより、PostgreSQLはトランザクション(したがってタプルバージョン)が「アクティブ」であるかどうかを迅速に判断できます。
MVCCでのインデックスの動作
PostgreSQLのインデックスもMVCCの原則に従います。インデックスエントリは、特定のタプルバージョンを指します。これは、行が更新された場合、元のインデックスエントリが古いタプル(xmax
でマークされている)を指し続ける可能性があることを意味します。新しいタプルを指す新しいインデックスエントリも作成される場合があります。これは、VACUUM
が(次に説明するように)これらの「デッド」エントリをクリーンアップするために不可欠な理由です。
VACUUM
の役割
MVCCの「新しいバージョンを書き込む」戦略の重要な結果の1つは、「デッドタプル」(アクティブなトランザクションからはもはや見えない古い行のバージョン)の蓄積です。そのままにしておくと、これらのデッドタプルはデータベースを肥大化させ、ディスクスペースを消費し、クエリパフォーマンスを低下させる可能性があります(インデックスがそれらを指し、追加のチェックが必要になる場合があるため)。
そこでVACUUM
が登場します。VACUUM
はPostgreSQLのガベージコレクタです。その主な責任は次のとおりです。
- デッドタプルの削除: デッドタプルによって占有されたストレージを特定して再利用します。
- 可視性マップの更新: インデックスオンリースキャンの高速化に役立つ可視性マップを更新します。
- 古いトランザクションのフリーズ: トランザクションIDのラップアラウンドを防ぐためにトランザクションIDカウンターを更新します。これは、長時間実行されるデータベースにとって重要な問題です。
VACUUM FULL
は、テーブルをより徹底的に書き直し、テーブルファイルのディスク上のスペースを再利用して縮小しますが、排他ロックが必要なため、より破壊的です。AUTOVACUUM
は、データベースを健全に保つためにVACUUM
とANALYZE
(統計情報収集)を自動的に実行するバックグラウンドプロセスです。
MVCC一貫性レベル(分離レベル)
PostgreSQLは、さまざまなSQL分離レベルをサポートするためにMVCCを実装しています。
- Read Committed(デフォルト): トランザクション内の各ステートメントは、データベースの新しいスナップショットを観測します。トランザクションが
A=1
を読み取り、別のトランザクションがA=2
にする変更をコミットし、最初のトランザクションが再度A
を読み取ると、A=2
が見えます。これはダーティリードを防ぎますが、非再現読み取りやファントムリードは防ぎません。 - Repeatable Read: トランザクション全体に対して一貫したスナップショットを提供します。トランザクション内のすべてのステートメントは、そのトランザクションの開始時点のデータベースを観測します。これにより、ダーティリードと非再現読み取りが防止されます。
- Serializable: 最上位の分離レベルであり、トランザクションの同時実行が、それらがシリアルに実行された場合と同じ結果を生み出すことを保証します。PostgreSQLは、「Serializable Snapshot Isolation」(SSI)と呼ばれる手法を使用してこれを実現しており、シリアル化異常を引き起こす可能性のあるトランザクションを検出し、ロールバックします。これにより、ファントムリードを含む、一般的なすべての同時実行問題が防止されます。
-- Read CommittedとRepeatable Readを示す例 -- 'accounts'テーブル(id INT、balance INT)を想定。 -- 初期状態: (1, 1000) -- セッション 1 (Read Committed) BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T1時点 SELECT balance FROM accounts WHERE id = 1; -- 1000を返します。 -- セッション 2 BEGIN TRANSACTION; UPDATE accounts SET balance = 1200 WHERE id = 1; COMMIT; -- T2でコミット済み -- セッション 1 に戻る -- T3時点(T2コミット後) SELECT balance FROM accounts WHERE id = 1; -- 1200を返します。 -- これは非再現読み取りです。同じトランザクション内で同じクエリが異なる結果を生成しました。 COMMIT; -- セッション 3 (Repeatable Read) BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- T4時点 SELECT balance FROM accounts WHERE id = 1; -- 1000を返します。 -- セッション 4 BEGIN TRANSACTION; UPDATE accounts SET balance = 1500 WHERE id = 1; COMMIT; -- T5でコミット済み -- セッション 3 に戻る -- T6時点(T5コミット後) SELECT balance FROM accounts WHERE id = 1; -- 1000を返します。 -- 他のトランザクションが変更をコミットしても、トランザクション内ではデータは一貫したままです。 COMMIT;
結論
MVCCはPostgreSQLの同時実行モデルの基盤であり、従来のロックに過度に依存することなく、複数のトランザクションを効率的かつ堅牢に処理する方法を提供します。インプレースで更新するのではなくタプルの新しいバージョンを作成し、高度な可視性ルールを適用することで、PostgreSQLは各トランザクションが一貫したデータスナップショットで動作することを保証します。この設計は同時実行性を向上させるだけでなく、強力な分離保証の基盤を形成します。MVCCはデッドタプルのオーバーヘッドとVACUUM
の必要性を導入しますが、パフォーマンス、信頼性、データ整合性における利点により、現代のデータベースシステムにとって不可欠なテクノロジーとなっています。PostgreSQLのMVCC実装は、そのエンジニアリングの卓越性の証であり、ACID特性を維持しながら高同時実行性操作を可能にし、数多くのアプリケーションでデータ整合性とピークパフォーマンスを保証します。