データベース

MVCCとは?多版型同時実行制御の仕組みと過去版の回収を実装目線で解説

MVCC(Multi-Version Concurrency Control、多版型同時実行制御)とは、1つの行に更新前後の複数の版を持たせ、読み取り側にはその時点で見えるべき版だけを見せることで、読み取りと書き込みが互いを待たない状態を作る同時実行制御の方式です。SELECTがUPDATEに止められないという挙動は、「不要になった過去版をいつ誰が捨てるか」という運用課題と引き換えに成り立つ仕組みです。この記事では、版の可視性がどう判定されるか、PostgreSQLの追記型とMySQL InnoDBのundo方式で置き場所がどう違うか、分離レベルによって見える版がどう変わるか、長時間トランザクションが過去版の回収を止める経路までを整理します。分離レベルの一覧やACIDの定義はトランザクションの基礎を実装目線でまとめた記事で扱うため、ここではその下層にある版管理の実装に絞ります。

まとめ|MVCCで消える待ちと、引き換えに増える過去版の回収コスト

先に結論を書きます。MVCCが外してくれるのは「読み取りが書き込みを待つ」待ちだけで、同じ行を同時に書き換える競合は何も解決しません。読み取り一貫性はDBMSが保証しますが、更新の正しさはアプリケーション側の責任として残ります。設計時に決めるべきことは2つです。参照系の処理をスナップショットに任せてよいか。そして在庫引当や採番のように同じ行へ集中する処理を、明示的な行ロックで直列化するかどうか。後者を一貫読み取りに任せた設計は、負荷をかけた瞬間に二重引当として表面化します。

運用面の要点は、過去版の回収が止まる条件に尽きます。PostgreSQLのVACUUMもMySQL InnoDBのpurgeも、回収できるのは「実行中で最も古いトランザクションより古い版」だけです。数時間開きっぱなしのセッションが1本あるだけで、その間の更新の残骸はすべて残り続けます。監視するのはPostgreSQLならpg_stat_user_tablesのdead tuple数と最古のバックエンドxmin、MySQLならSHOW ENGINE INNODB STATUSのHistory list length。この2つを常時グラフ化しておけば、テーブル肥大の大半は事故になる前に気づけます。

MVCCとは何か|ロックを取らずに読み取りを通す多版管理の仕組み

まず、MVCCが何をしていて、従来のロック方式と何が違うのかを押さえます。「読み取りがブロックされない」という結果だけでなく、その裏で何が積み上がるかまで見ておくと、後半の運用の話がつながります。

MVCCの定義|1行に複数の版を保持して読み書きの待ちを外す方式

MVCCは、行を上書きせずに新しい版を作り、各トランザクションには自分のスナップショットから見て有効な版だけを見せる制御方式を指します。多版方式の原型は、D. P. Reedが1978年にMITへ提出した学位論文で示された多版管理にさかのぼります。ここで押さえておきたいのは、MVCCが分離レベルの上位概念でも代替物でもないという点です。分離レベルという仕様を、ロックではなく版で実現する手段がMVCCだと捉えると位置づけを間違えません。なお本記事の記述はPostgreSQL 18.4(2026年5月14日リリース)とMySQL 8.0系以降のInnoDBを前提にしています。

ロック方式との違い|読み取りと書き込みが待ち合う箇所の比較と使い分け

厳密2相ロック(S2PL)で分離を実現する場合、読み取りは共有ロックを取るため、書き込み中の行に対しては待ちが発生します。逆に書き込みも、参照中の行に対して待たされる。この相互の待ちが同時実行数を頭打ちにします。MVCCはこの片側を消します。

方式 読み取りの待ち 同一行の書き込み 主なコスト
2相ロック 書き込み中は待つ 待つ ロック競合と待ち行列
MVCC 待たず過去版を読む 待つ 過去版の保持と回収

表の右下が本記事の主題にあたります。待ちを消した代償として、DBMSは「もう誰からも見えない版」を判定して捨てる仕事を抱え込みました。ひとつ確認しておくと、同じ行を書き換えるトランザクション同士は、MVCCでも行ロックで待ちます。書き込み競合の直列化までは版では代替できません。

スナップショットの判定手順|トランザクションIDと可視性ルールで版を選ぶ

PostgreSQLでは、各行の見えない場所にxminとxmaxという2つのシステム列が付いています。xminはその版を作ったトランザクションID、xmaxはその版を削除または更新したトランザクションIDです。可視性の判定は、自分が持つスナップショット(実行中トランザクションIDの集合)と、コミットログの状態を突き合わせて行われます。xminがコミット済みかつ自分より前で、xmaxが未設定か未コミットなら、その版が見えるという判定です。実物を確かめる手段もあります。

SELECT xmin, xmax, ctid, id FROM orders WHERE id = 100;

同じ行をUPDATEしてから再度実行すると、ctid(物理位置)とxminが変わり、新しい版が別の場所に作られたと分かります。MySQL InnoDBの場合はシステム列がDB_TRX_IDとDB_ROLL_PTRで、後者がundoログ上の1つ前の版を指します。読み取り時はread viewと突き合わせ、見えない版ならロールバックポインタをたどって過去へ巻き戻す。この入口の差が、後述する運用の差に直結します。

PostgreSQL・MySQL・Oracleで異なる過去版の置き場所と回収の方式

MVCCという同じ名前でも、過去版をどこに置いて誰が片づけるかは製品ごとに違います。この差はチューニングの勘所と監視項目にそのまま出ます。

PostgreSQLの追記型|テーブル本体に旧版が残りVACUUMが回収する構造

PostgreSQLはUPDATEを「新しいタプルの追加+旧タプルのxmax設定」として処理します。旧版は同じテーブルファイルの中に残ったままです。DELETEも同様に、行を消さずxmaxを立てるだけ。この不要タプルを回収して再利用可能な領域に戻すのがVACUUMで、通常はautovacuumが自動で走ります。その更新をディスクへ確定させる順序を担うのはライトアヘッドログ(WAL)で、PostgreSQLが復旧時にUNDOフェーズを持たない理由もそちらで整理しています。追記型の副作用として、更新のたびにインデックスも新しいタプルを指し直す必要が生じました。これを避ける仕組みがHOT更新(Heap Only Tuple)で、更新対象の列がどのインデックスにも含まれず同じページ内に空きがあれば、インデックスを触らずに済みます。更新が多いテーブルでfillfactorを70〜90程度に下げるのは、HOT更新の成立率を上げるためです。

MySQL InnoDBのundo方式|過去版をundoログに逃がしpurgeが切り離す

InnoDBは主キー索引の行そのものを新しい値で書き換え、直前の版を復元するための情報をundoログへ書きます。読み取り側はロールバックポインタをたどって必要な時点まで戻す。データ本体が肥大しにくい代わりに、undo表領域が伸びます。不要になったundoレコードを切り離すのはpurgeスレッドで、既定はinnodb_purge_threadsが4です。undo表領域の縮小はinnodb_undo_log_truncateで自動化され、MySQL 8.0では既定で有効になっています。ただし回収の条件はどちらの製品でも同じで、実行中の最も古いトランザクションが必要とする版は捨てられません。

Oracle・SQL Serverの版の置き場所|UNDO表領域とtempdb

OracleはUNDO表領域に過去イメージを保持し、SCN(システム変更番号)で時点を特定します。undo_retentionの設定を超えて上書きされた後に古い版を要求すると、ORA-01555(snapshot too old)が返ります。長時間バッチが終盤で落ちる典型的な原因です。SQL Serverは既定がロック方式で、データベース単位でREAD_COMMITTED_SNAPSHOTを有効にするとMVCC動作へ切り替わります。この場合の版はtempdbのバージョンストアに置かれるため、tempdbの容量とI/O設計が同時実行性能を左右する構成になります。SQL Server 2019以降のAccelerated Database Recoveryでは、版はデータベース内のPersistent Version Storeへ移りました。

製品 過去版の置き場所 回収の担当 主な監視値
PostgreSQL テーブル本体 VACUUM n_dead_tup
MySQL InnoDB undoログ purgeスレッド History list length
Oracle UNDO表領域 期限切れで上書き ORA-01555の発生数
SQL Server tempdb バージョンクリーナー バージョンストアサイズ

移行案件で見落としやすいのは3列目です。Oracleから移ってきたシステムでは「undoが足りなければ拡張する」という発想が残りがちですが、PostgreSQLに同じ考え方は通じません。回収の主役はストレージの余裕ではなくVACUUMの実行機会です。

READ COMMITTEDとREPEATABLE READで変わる見える版の境界

同じMVCCでも、スナップショットをいつ取り直すかで挙動が変わります。既定値がPostgreSQLとMySQLで異なる点が混乱の元です。

READ COMMITTED|文ごとにスナップショットを取り直す既定動作と副作用

PostgreSQLの既定はREAD COMMITTEDで、スナップショットはSQL文ごとに取り直されます。ひとつのトランザクションの中で同じSELECTを2回実行すると、その間に他がコミットした結果が2回目に現れる。これが反復不能読み取りです。もうひとつ知っておきたいのが更新時の挙動で、UPDATEが他トランザクションのロック待ちで止まった後、解放された時点の最新版に対してWHERE条件を評価し直します。UPDATE stocks SET qty = qty - 1 WHERE qty >= 1が待たされた後でも正しく判定されるのは、この再評価があるためです。SELECTしてからアプリ側で判定してUPDATEする書き方では、この保護が効きません。

REPEATABLE READ|トランザクション単位のスナップショットと更新競合エラー

REPEATABLE READでは、最初の文を実行した時点のスナップショットがトランザクション終了まで固定されます。何度同じSELECTを流しても結果は同じです。問題は更新側で、自分のスナップショットより後に他がコミットした行を更新しようとすると、PostgreSQLはエラーを返します。メッセージはcould not serialize access due to concurrent update、SQLSTATEは40001。このエラーはバグではなく仕様で、アプリケーション側でトランザクションをやり直す前提の設計になっています。MySQL InnoDBの既定もREPEATABLE READですが、こちらは挙動が二重です。通常のSELECTはスナップショットを読む一方、SELECT … FOR UPDATEやUPDATEは最新版を読みに行きます(current read)。同じトランザクション内でSELECTとUPDATEの見ている値が食い違う現象は、この二重性から生まれます。

MVCCでも防げない書き込み異常|lost updateとwrite skewの発生条件

スナップショット分離は反復不能読み取りとファントムの多くを抑えますが、書き込みの正しさまでは保証しません。代表が2つあります。ひとつはlost update。2つのトランザクションが同じ在庫数を読み、それぞれ1を引いて書き戻すと、片方の更新が消えます。もうひとつがwrite skewで、互いに別の行を見て「自分が抜けても条件は満たされる」と判断し、両方が抜けてしまうパターンです。当直担当を2名以上に保つルールが、同時申請で1名になる例が分かりやすいでしょう。PostgreSQLのSERIALIZABLEは述語ロックを使うSSI方式でこれを検出し、40001で片方を落とします。MySQLのSERIALIZABLEは通常のSELECTを共有ロック付きへ格上げする実装で、待ちが増える方向の違いです。アプリ側でどう受けるかは楽観ロックと悲観ロックの使い分けを整理した記事に対応があります。

長時間トランザクションで過去版の回収が止まる経路と監視すべき指標

MVCCの運用トラブルは、ほぼすべてが「回収されない過去版」に集約されます。更新量が多いから太るのではなく、回収できない時間が長いから太る。この因果を取り違えると、対処の方向を間違えます。

テーブル肥大が起きる条件と、autovacuum発火式の読み方

PostgreSQLのautovacuumがテーブルを対象にする条件は、更新・削除された行数が「threshold + scale_factor × 行数」を超えたときです。18系の既定値はautovacuum_vacuum_threshold=50、autovacuum_vacuum_scale_factor=0.2。ここに落とし穴があり、1億行のテーブルでは2,000万行が更新されるまで発火しません。PostgreSQL 18ではautovacuum_vacuum_max_threshold(既定1億タプル)という上限が入り、極端に大きいテーブルでの遅延は緩和されました。更新が集中する台帳系ではテーブル単位で明示するほうが確実です。挿入のみのテーブル向けにはautovacuum_vacuum_insert_threshold(既定1000)が別途あります。

ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 1000);

回収を止める4つの経路|開きっぱなしのセッションとレプリカからの逆流

設定を詰めても、以下の状態が1つでもあるとVACUUMは不要タプルを消せません。回収可能なのは、実行中で最も古いトランザクションが必要とする版よりさらに古いものだけだからです。

  • アイドル状態のまま開いているトランザクション(idle in transaction)。例外時にロールバックし損ねると発生します
  • PREPARE TRANSACTIONしたまま放置された二相コミットの残骸。pg_prepared_xactsに残り続けます
  • レプリカ側の長い集計クエリと、hot_standby_feedbackを有効にした構成の組み合わせ。必要な版がプライマリ側で固定されます
  • 使われなくなったレプリケーションスロット。保持するxminがそのまま回収の下限になります

MySQLでも構図は同じで、長いSELECTを抱えたセッションがpurgeを止め、History list lengthが伸び続けます。優先して潰すべきは1つめです。肥大の多くは、アプリのエラーハンドリング漏れによる開きっぱなしのトランザクションが原因になっています。

常時グラフ化すべき指標|dead tuple数と最古のxminの経過時間

監視は3点で足ります。PostgreSQLではpg_stat_user_tablesのn_dead_tupとlast_autovacuumを見て、dead tupleが増え続けながらautovacuumの時刻が更新されない状態を検知します。次にpg_stat_activityから最古のバックエンドxminの経過時間を取り、閾値を超えたセッションを警告対象にする。3点目がトランザクションID周回への備えで、age(datfrozenxid)がautovacuum_freeze_max_age(既定2億)に近づくと強制的な凍結処理が始まります。MySQLはHistory list lengthが数百万規模で高止まりしていれば、長時間トランザクションを疑う段階です。

MVCCを前提に設計を変える場面と、行ロックで直列化すべき処理

ここからは判断の話です。MVCCの挙動をどこまで前提にしてよいかを、条件を付けて言い切ります。

採用条件|参照が書き込みより多い基幹業務でMVCCを既定に置く根拠

受発注・在庫・販売のような基幹業務では、MVCCを備えたRDBMSを既定として選んで差し支えありません。判断の根拠は、これらの業務が「1件の更新に対して数十倍の参照が走る」構造を持つためです。月次締めの集計が走っている間も現場の入力を止めずに済む価値は、過去版の回収という運用コストを上回ります。逆に、参照がほとんど無く追記だけが続くログ用途では、MVCCの恩恵は薄く回収の手間だけが残る。こうした同時実行の要件を含む業務システムの設計や刷新は、基幹システム開発としてご相談を受けています。

見送る場面|在庫引当や採番を一貫読み取りに任せてはいけない理由

同じ行へ短時間に集中し、正確に1回だけ成立させたい処理は、スナップショットに任せず明示的に直列化してください。在庫引当、座席確保、伝票番号の採番が該当します。理由は単純で、REPEATABLE READのスナップショットは「自分が読んだ後に他が引き当てた事実」を見せないためです。テストデータでは通り、同時実行数が上がった本番で二重引当として現れる。対処は2通りで、読んだ値を使わずUPDATE stocks SET qty = qty - 1 WHERE id = 100 AND qty >= 1のように単一文で条件と更新を同時に成立させるか、SELECT … FOR UPDATEで行ロックを先に取るかです。前者が使えるなら前者を選びます。もうひとつ、分析用の重い集計をプライマリで長時間流す運用も採りません。回収が止まって本番テーブルが肥大するためで、レプリケーションの構成と遅延設計を扱った記事の通りレプリカへ寄せるのが定石です。

アプリ側で必ず用意するもの|40001の再試行とべき等性の担保

REPEATABLE READ以上を使うなら、シリアライズ失敗(SQLSTATE 40001)とデッドロック(40P01)を捕捉して、トランザクション全体をやり直す実装が要ります。部分的なリトライでは整合が取れません。実装の目安は、再試行3回まで、待ち時間は指数バックオフ。気をつけたいのが副作用の扱いで、メール送信や外部決済APIの呼び出しをトランザクション内に置くと、再試行のたびに二重実行されます。副作用はコミット後に回すか、べき等キーで重複を吸収してください。デッドロック側の原因分析と予防はデッドロックの発生原因と検出・解消を解説した記事にまとめてあります。

よくある質問

MVCCの設計・運用で実際に問い合わせの多い論点を5つ取り上げます。

MVCCとロック方式では、どちらが高速ですか?

参照の比率が高いワークロードではMVCCが有利で、更新が同じ行に集中する処理では差がほとんど出ません。MVCCが消すのは読み取りと書き込みの間の待ちだけで、書き込み同士の競合は行ロックで直列化されるためです。過去版の保持と回収というコストは常時かかるため、更新が極端に多く参照が少ない処理では、回収が追いつかずかえって遅くなる場面もあります。

PostgreSQLのVACUUMは手動で実行すべきですか?

通常はautovacuumに任せ、手動実行は特定の状況に限ります。大量削除の直後に領域をOSへ返したい場合はVACUUM FULLですが、テーブル全体に排他ロックがかかるため停止時間の確保が前提です。日常的に手動実行が必要になっているなら、まずテーブル単位のscale_factorを下げ、それでも追いつかない場合に長時間トランザクションを調べる順序をおすすめします。

MySQLのREPEATABLE READでファントムリードは起きますか?

通常のSELECTではスナップショットを読むため発生しません。ただしSELECT … FOR UPDATEやUPDATEは最新版を読みに行くため、同じトランザクション内で見える行が変わることがあります。InnoDBはこれをギャップロックとネクストキーロックで抑え込む設計で、範囲条件の更新では存在しない行の隙間までロックされます。想定より広い範囲がロックされて待ちが増える点は把握しておいてください。

MVCCを使えばデッドロックは起きなくなりますか?

起きます。MVCCが排除するのは読み取りと書き込みの待ちであって、書き込み同士のロック待ちは残るためです。複数トランザクションが複数行を異なる順序でロックすれば循環待ちが成立します。予防策も従来と同じで、更新対象の行を常に同じ順序(主キー昇順など)でロックする、トランザクションを短く保つ、の2点が基本です。

スナップショットを長く保つと、ディスクはどれくらい増えますか?

増加量は保持時間そのものではなく、その間に発生した更新・削除の行数に比例します。1時間あたり100万行が更新されるテーブルで3時間トランザクションを開けば、300万行分の旧版が残る計算です。行サイズ200バイトなら概算で600MB、実際にはインデックスの肥大も加わります。dead tupleの割合が20%を超えたあたりで原因調査に入る運用が扱いやすいでしょう。

関連記事

資料請求

RELATED POSTS 関連記事