MVCC - 多版同時実行制御とスナップショット分離

中級 | 11分 で読める | 2026.04.24

公式ドキュメント

MVCC(Multi-Version Concurrency Control、多版同時実行制御)は、同じ行の古い版と新しい版を管理し、トランザクションごとに見える版を選ぶ仕組みです。

読み取りが更新の完了を毎回待たず、更新も通常の読み取りを止めずに進められることが、MVCCの中心です。 ただし、同じ行を更新する処理やDDLまでロック不要になるわけではありません。

行を上書きせず、新しい版を作る

口座残高が1000の行を、トランザクションAが1200へ更新するとします。概念上は古い版を消して直接書き換えるのではなく、新しい版を追加します。

旧版 v1: balance = 1000
新版 v2: balance = 1200

トランザクションBがどちらを見るかは、Bのスナップショットと各版の可視性で決まります。Aが未コミットなら、Bは通常v1を読みます。Aのコミット後に新しいスナップショットを取得した処理はv2を読めます。

MVCCでCOMMIT前のSnapshot Aはv1を参照し、COMMIT後のSnapshot Bはv2を参照する図

実装方法は製品で異なります。PostgreSQLは行の版にトランザクション情報を持たせ、MySQL InnoDBはundo logなどを使って過去の版をたどります。「MVCCなら全製品で同じ結果になる」とは考えず、採用DBの分離レベルとロック規則を確認します。

スナップショットとは

スナップショットは、ある処理から見えてよいトランザクションの範囲です。単純なデータコピーではありません。各行の版に付いた情報とスナップショットを照合して、表示する版を決めます。

PostgreSQLでは分離レベルにより、スナップショットを取得する単位が変わります。

分離レベル読み取りの概略
Read Committed(既定)各SQL文の開始時点で、コミット済みの行を見る
Repeatable Readトランザクション中、同じスナップショットを使う
Serializable直列実行と同等になるよう監視し、危険な競合では一方を失敗させる

PostgreSQLの挙動です。他製品で同じ名前の分離レベルが、まったく同じ現象を許すとは限りません。

PostgreSQLでの可視性

PostgreSQLの各行バージョンには、その版を作ったトランザクションと、削除または更新したトランザクションを判断するための情報があります。UPDATEも概念上は、既存版を更新済みと示し、新しい版を作る操作です。

読み取り時は、単に「最大のIDの版」を選ぶのではありません。自分のトランザクション、コミット済みかどうか、スナップショット取得時点で進行中だったかを照合します。このため、後からコミットされた新版が存在しても、古いスナップショットを使う処理には見えないことがあります。

行バージョンの内部情報を調査に使うことはできますが、アプリケーションが内部列へ依存する設計は避けます。可視性はDBへ任せ、アプリ側では分離レベル、明示的ロック、制約を選びます。

二つのセッションで確認する

最初にテーブルを用意します。

CREATE TABLE accounts (
  id integer PRIMARY KEY,
  balance integer NOT NULL
);

INSERT INTO accounts VALUES (1, 1000);

セッションAで更新を始め、まだコミットしません。

BEGIN;
UPDATE accounts SET balance = 1200 WHERE id = 1;

同時にセッションBで読むと、通常は待たずにコミット済みの1000が見えます。

SELECT balance FROM accounts WHERE id = 1;

AがCOMMITした後、BがRead Committedで新しい文を実行すれば1200を読めます。BがRepeatable Readのトランザクションを先に開始していた場合は、同じトランザクション中に旧版が見え続けます。

MVCCでも競合は起きる

MVCCが減らすのは、主に読み取りと更新の不要な待ちです。二つのトランザクションが同じ行を更新すれば、片方がロックを待つ場合があります。処理順が循環すればデッドロックも起こり得ます。

Write skew

スナップショット分離では、別々の行を更新する二つのトランザクションが、共通の条件を壊すことがあります。

条件: 当直者は最低1人必要
A: Bが当直中だと読んで、Aを当直から外す
B: Aが当直中だと読んで、Bを当直から外す
結果: 更新行は別なので、両方コミットすると当直者が0人になる

行単位の一意制約だけでは守れない業務条件です。Serializableを使う、対象行を明示的にロックする、条件をDB制約で表現できる設計へ変える、などを検討します。Serializableでは競合時の再試行も必要です。

Lost updateをどう防ぐか

画面で読んだ値を後から保存する処理では、別の利用者の更新を上書きするlost updateにも注意します。読み取り時の版番号を更新条件へ含める楽観的ロックが一つの方法です。

UPDATE documents
SET body = 'new body', version = version + 1
WHERE id = 10 AND version = 4;

更新件数が0なら、読み取り後に別の更新があったと判断し、再読み込みや利用者への競合表示を行います。これはDBエンジンのMVCCとは別に、アプリが「読んだ版を前提に更新する」ことを明示する仕組みです。

古い版は回収が必要

行の古い版を永遠に残すと、テーブルとインデックスが膨らみます。PostgreSQLではVACUUMが不要になった版を回収し、トランザクションIDの周回に備える処理も担います。

長時間開いたトランザクションは古いスナップショットを保持し、VACUUMが版を回収できない原因になります。次を観測します。

  • 長時間実行中またはidle in transactionの接続
  • dead tuplesとテーブルサイズの増加
  • autovacuumの実行状況と遅れ
  • 更新競合、デッドロック、serialization failure

VACUUMは単なる「容量削減コマンド」ではありません。停止や無効化を安易に行わず、原因となる長時間トランザクションや設定を確認します。

インデックスにも影響する

行を更新して新しい版が作られると、変更内容によってはインデックスにも新しいentryが必要です。古い行版が残る間は、インデックスから候補を見つけた後にheap側の可視性を確認する処理も発生します。

PostgreSQLには、インデックス対象列を変更せず、同じページに余裕がある時にインデックス更新を省けるHOT updateがあります。しかし、常に使われるとは限りません。更新が多いテーブルでは、dead tuple、index size、autovacuumだけでなく、ページの空きと更新対象列も確認します。

「MVCCだから読み取りが速い」と一律には言えません。版の増加と回収遅れはI/Oを増やします。EXPLAIN (ANALYZE, BUFFERS)などで実際のqueryを測り、VACUUMやindex再構築を症状だけで繰り返さないようにします。

トランザクション設計の目安

  • トランザクション内で利用者入力や外部APIを長く待たない
  • 複数行にまたがる業務条件は、同時実行時の反例を作る
  • Serializableやdeadlockの失敗は、処理全体を安全に再試行できるようにする
  • メール送信などDB外の副作用は、再試行で重複しない設計にする
  • 読み取り専用の長い分析処理は、本番更新系への影響を測る

分離レベルを上げるだけでは、副作用の重複や無制限の再試行は解決しません。トランザクションを短くし、再試行回数と失敗時の応答を決めます。

明示的ロックを選ぶ場面

現在値を読んだ後、その値を前提に同じ行を必ず更新する処理では、SELECT ... FOR UPDATEで対象行を先にロックする方法があります。

BEGIN;

SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;

UPDATE accounts
SET balance = balance - 300
WHERE id = 1;

COMMIT;

この方法は同じ行の更新を直列化しやすくしますが、待ち時間とdeadlockの可能性が増えます。複数行をロックする処理では取得順をそろえ、ロック中に外部APIを呼ばないようにします。

存在しない行を含む範囲条件、複数テーブルの合計、集計結果を守りたい場合、行ロック一つでは不足することがあります。制約、Serializable、処理を一つの集約へ寄せる設計などを比較します。

分離レベルを試す手順

分離レベルの説明は、一つのセッションだけでは確認できません。二つ以上の接続を用意し、次を記録します。

  1. AとBがいつBEGINしたか
  2. 最初のSELECTをいつ実行したか
  3. どの行を更新し、いつCOMMITしたか
  4. 待機、読めた値、エラーコード
  5. 同じ手順を別の分離レベルで行った結果

pg_stat_activitypg_locksは待機調査に役立ちますが、本番でSQL全文を共有する時は個人情報や秘密値を含めないようにします。

SELECT pid, state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE datname = current_database();

テストは「最終値が合った」だけで終えず、途中で何を読めたか、片方が失敗したか、再試行後に業務条件が守られたかまで確認します。

製品差を読む時の問い

MVCCという名称が同じでも、過去版の保存場所、既定の分離レベル、locking readの構文、競合時のエラーは異なります。比較表の「対応・非対応」より、次の問いを公式文書で確認します。

  • 一つのSQL文とトランザクション全体のどちらでsnapshotが固定されるか
  • 同じ行への並行UPDATEは待つか、失敗するか、再評価するか
  • 範囲検索と新規行の競合をどう扱うか
  • 古い版を誰が、どの条件で回収するか
  • serializable failureをアプリへどう通知するか

これらが分かれば、製品名をまたいだ曖昧な「MVCC対応」より、アプリに必要な処理を具体的に設計できます。

よくある誤解

  • MVCCならロックはない:更新同士、DDL、明示的ロックは待つことがあります。
  • Repeatable Readなら必ず業務条件を守れる:write skewなど、複数行にまたがる条件は別途対策が必要です。
  • 古い版は自動なので気にしなくてよい:長時間トランザクションは回収を妨げます。
  • Snapshot IsolationとSerializableは同じ:PostgreSQLのSerializableはSSIで危険な実行を検出し、失敗させることがあります。

まとめ

MVCCは、行の複数バージョンとスナップショットを使い、読み取りと更新の並行性を高めます。一方で、更新ロック、write skew、古い版の回収は残ります。分離レベルを名前だけで選ばず、守りたい業務条件、許容する再試行、長時間トランザクションをセットで設計します。

参考リソース

関連記事

← 一覧に戻る
PR
PR
PR
PR