読み取りと書き込みを待たせ合わない
MVCC(Multi-Version Concurrency Control、マルチバージョン並行制御)は、読み取りと書き込みを同時に走らせながら、読み取りには一貫したスナップショットを見せる仕組みです。InnoDBでは、行を更新・削除しても古いバージョンをすぐには物理削除せず、undoログに残します。読み取り側はRead View(読み取りビュー)というスナップショットを持ち、各レコードに付いたトランザクションIDを手がかりに、自分が見るべきバージョンを選びます。おかげで、読み取りは書き込みを待たず、書き込みも読み取りを待たずに進めます。
なぜ古いバージョンをすぐ消さないのか、具体例で考えます。トランザクションAがSELECTを実行している最中に、トランザクションBが同じ行をUPDATEしてコミットしたとします。Aは、Bのコミット前のデータを同じスナップショットとして見続ける必要があります。そこでBが書き込んだ最新レコードはAには見せず、Bの更新前のバージョンをundoログから復元して返します。つまり、Bがコミットしたあとでも、Aが参照を続けている間は古いバージョンを保持しておかなければなりません。この保持と、不要になったあとの回収を担うのがpurgeです。
Read Viewが持つ情報
Read Viewは、ある時点で「どのトランザクションの変更を見るか、見ないか」を決めるための情報です。SELECTを実行するとき、あるいはカーソル(クエリ結果を1行ずつ取り出す仕組み)を開くときに、そのトランザクション用のRead Viewが作られます。保持する情報は次のとおりです。
| 情報 | 役割 |
|---|---|
m_up_limit_id | これ未満のトランザクションIDの変更はすべて見る(コミット済みとみなす) |
m_low_limit_id | これ以上のトランザクションIDの変更は一切見ない(ビュー作成後に開始したトランザクション) |
m_ids | ビュー作成時点でアクティブだった読み書きトランザクションのID集合。これらの未コミット変更は見ない |
m_creator_trx_id | このRead Viewを作ったトランザクション自身のID。自分の変更は常に見る |
m_low_limit_no | purge用。これ未満のトランザクション番号のundoは、このビューから参照されないとみなす |
m_up_limit_idは、アクティブなトランザクションがあればm_idsの最小値、なければm_low_limit_idになります。m_low_limit_idは、Read Viewを作った時点で「次に割り当てられる」トランザクションIDです。
可視性の判定
レコードに格納されたトランザクションID(DB_TRX_ID)を、Read Viewの情報と照らして、そのバージョンが見えるかどうかを判定します。ロジックは次のとおりです。
id < m_up_limit_idまたはid == m_creator_trx_idなら、その変更は見る。id >= m_low_limit_idなら、その変更は見ない(ビュー作成後に開始したトランザクションの変更)。- 上記以外で、
m_idsが空なら、その変更は見る。 m_idsにidが含まれるなら見ない。含まれないなら見る。
考え方としては、m_idsに含まれるということは、そのトランザクションがビュー作成時点でまだアクティブ(未コミット)だったということです。アクティブなトランザクションの未コミット変更は、読み取り側には見せません。逆に、m_idsに含まれず、かつm_up_limit_id以上m_low_limit_id未満のトランザクションは、ビュー作成前にコミット済みなので、その変更は見ます。
クラスタ化インデックスに付くシステムカラム
undoチェーンをたどるための情報は、クラスタ化インデックス(主キー)のリーフレコードに付くシステムカラムが持っています。システムカラムとは、CREATE TABLEで定義した列とは別に、InnoDBが内部管理のために持たせる列のことです。公式マニュアルが挙げるシステムカラムは次の3つです。
| カラム | サイズ | 役割 |
|---|---|---|
DB_TRX_ID | 6バイト | このレコードの最新バージョンを最後に更新したトランザクションのID |
DB_ROLL_PTR | 7バイト | undoログ内の前バージョンへのポインタ(roll pointer) |
DB_ROW_ID | 6バイト | 挿入のたびに単調増加する行ID。主キーも適切なUNIQUE NOT NULLインデックスもないとき、InnoDBが自動生成する隠れクラスタキーになる |
可視性の判定とundoチェーンのたどりに使うのはDB_TRX_IDとDB_ROLL_PTRです。クラスタキーとは、クラスタ化インデックス(行本体を載せるB-tree)を並べる基準のキーのことで、通常は主キーがそれにあたります。DB_ROW_IDはその代替で、主キー(またはNOT NULLのUNIQUEインデックス)があるテーブルではどのインデックスにも現れません。
DB_TRX_IDに入るトランザクションIDは、InnoDBが内部で管理するmax_trx_idという1つのグローバルなカウンタから発行されます。新しい読み書きトランザクションが始まるたびにこのカウンタが増えていくので、DB_TRX_IDは常に昇順です。6バイトで表現できる範囲は0から2^48-1(約281兆)まであり、1秒間に数万件のトランザクションをさばき続けても使い切るまでに数百年かかる大きさです。上限に達すると0へ戻ってしまい、古いトランザクションIDと新しいトランザクションIDを区別できなくなるおそれがありますが、通常運用の時間スケールでは実質的に心配しなくて構いません。
各操作での動き
INSERTでは、その時点のトランザクションIDがDB_TRX_IDに入ります。DB_ROLL_PTRは、この挿入を取り消すためのundoレコードを指し、将来ROLLBACKするときに使います。
UPDATEは、クラスタ化インデックスでは通常in-place(同じ位置での書き換え)です。更新前の値をundoログへ書き出したうえで、同じレコード位置のデータを書き換え、DB_TRX_IDを更新トランザクションのIDに、DB_ROLL_PTRをそのundoレコードへ付け替えます。読み取り側は、必要ならDB_ROLL_PTRからundoをたどって更新前のバージョンを復元します。ただし、主キー(クラスタキーの順序を決めるフィールド)が変わる場合は、旧レコードに削除マークを付けて新レコードを挿入する形になります。一方、更新後のサイズが増えて同じページに収まらない場合は、削除マーク+挿入ではなく、ページの再編成や分割を伴う更新として、同じレコードを別の位置へ移しつつ書き換えます。
DELETEでは物理削除ではなく削除マークが付きます。削除マーク付きレコードのDB_TRX_IDには削除したトランザクションのIDが入り、DB_ROLL_PTRは削除前の内容を保持するundoレコードを指します。
セカンダリインデックスからの遡及
セカンダリインデックスのレコードには、DB_TRX_IDもDB_ROLL_PTRもありません。undoチェーンはクラスタ化インデックスにしか存在しないのです。代わりに、セカンダリの各ページには、そのページを最後に更新したトランザクションIDとしてPAGE_MAX_TRX_IDがページヘッダに保持されます。
セカンダリインデックス経由で検索したときの可視性判定は、おおむね次の流れです。
- セカンダリのレコードに削除マークが付いている場合は、セカンダリだけでは可視バージョンを決められないため、対応するクラスタ化インデックスのレコードをlookupする。
- 削除マークがなく、
PAGE_MAX_TRX_IDがRead Viewのm_up_limit_id未満なら、そのページへの最後の変更はビュー作成より前に確定でコミット済みなので、ページ上のレコードをそのまま使える。 PAGE_MAX_TRX_IDがm_up_limit_id以上なら、そのページにはビュー作成時点でまだ見えないはずの変更が含まれている可能性があるため、レコードごとに対応するクラスタ化インデックスのレコードをlookupする。- クラスタ側へ行った場合は、クラスタ側の
DB_TRX_IDとDB_ROLL_PTRを使い、Read Viewから見えるバージョンをundoチェーンから構築する。
UPDATEでセカンダリインデックスのキーが変わった場合、InnoDBは「旧キーの削除マーク」と「新キーの挿入」で表現し、旧キーのレコードは削除マークが付いたまま残ります。セカンダリにはundoチェーンがないため、削除マーク付きのレコードを読むときは対応するクラスタ化インデックスをlookupし、クラスタ側のundoをたどって可視バージョンを取得します。
削除マークとpurge
DELETEやUPDATE(旧キーの削除)を実行しても、InnoDBはレコードをすぐには物理削除しません。まず削除マークを付ける論理削除を行います。まだ古いバージョンを見ているRead Viewが存在するかもしれないからです。purgeはバックグラウンドで動き、どのRead Viewからも参照されなくなった削除マーク付きレコードを物理削除します。同時に、そのレコードの古いバージョンを保持していたundoログも不要になるので、その領域を解放して他のundoに再利用できるようにします。
purgeが参照するのは、システム内で最も古いアクティブなRead Viewをクローンしたpurge viewです。なぜ最古の1つだけで足りるのでしょうか。最古のRead Viewは、最も古いスナップショットを持つ、つまりどのRead Viewよりも多くの変更を「見えない」と判定する、最も保守的な視点だからです。purge viewで「このトランザクションの削除は見える(古いバージョンはもう不要)」と判断できるなら、それより新しいRead Viewはすべて同じかそれ以降のスナップショットを持つので、やはり古いバージョンを必要としません。逆に、purge viewで「まだ見えない」と判断されるなら、少なくとも最古のRead Viewが古いバージョンを必要とする可能性があるため、purgeしてはいけません。こうして、最古のRead View1つの判定だけで、purgeは「どのアクティブなRead Viewからも参照されない」と安全に言えるレコードだけを削除できます。
Read Viewをいつ作り、いつ解放するか
InnoDBがサポートする分離レベルは、READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLEの4つです(既定はREPEATABLE READ)。ここでいうSELECTは、FOR UPDATEやFOR SHAREを付けない通常のSELECTです(対して、これらを付けたものはロックを取って待つ読み取りになります)。この通常のSELECTがロックを取らずRead Viewに基づいて読まれるのは主にREAD COMMITTEDとREPEATABLE READです。この2つでは、可視性の判定ロジック自体は共通で、Read Viewをいつ作るか(いつ作り直すか)が違います。作り直すタイミングが変わればm_idsやm_low_limit_idの内容も変わるため、見える範囲が変わります。一方、READ UNCOMMITTEDとSERIALIZABLEは、Read Viewの使い方そのものが異なります。
REPEATABLE READ
BEGINで始めた明示的なトランザクションでは、最初に通常のSELECTを行ったときにRead Viewが作られ、COMMITまたはROLLBACKまで同じスナップショットを使い続けます。同じトランザクション内で何度SELECTしても、Read Viewを作り直しません。START TRANSACTION WITH CONSISTENT SNAPSHOTを使った場合は、最初のSELECTを待たずにトランザクション開始時点でRead Viewが作られます。INSERTやUPDATEを行う読み書きトランザクションでも、最初の読み取りでRead Viewを作り、コミットまたはロールバックまで保持します。自分の変更はm_creator_trx_idにより常に見えます。
一方、BEGINを書かずautocommit=1(既定)のままSELECTだけを実行する場合、そのSELECT1文が1つのトランザクションです。文の実行中にRead Viewが作られ、文の終了とともにトランザクションが自動コミットされてRead Viewも解放されます。次のSELECTは別トランザクションなので、原則としてまた新しいRead Viewが作られます(後述のAC-NL-RO最適化で再利用される場合を除く)。
READ COMMITTED
通常のSELECTのたびに新しいRead Viewが作られます。同じトランザクション内でも、文ごとにスナップショットが更新されます。たとえば、あるトランザクションの中でSELECTを2回実行する間に、別のトランザクションが対象の行をUPDATEしてコミットしたとします。REPEATABLE READなら1回目のRead Viewを2回目でも使い続けるため、2回のSELECTは同じ値を返します。一方READ COMMITTEDでは2回目のSELECTのときに新しいRead Viewが作られるため、コミット済みの新しい値が見えるようになり、同じトランザクション内でも1回目と2回目でSELECTの結果が変わり得ます。
READ UNCOMMITTED
FOR UPDATEなどを付けないSELECTでもRead Viewによる可視性判定を行いません。ページ上にある最新版のレコードをそのまま読むため、まだコミットされていない他トランザクションの変更が見えてしまうことがあります(ダーティリード)。ある時点の見え方に固定して読むわけではない、という点がREAD COMMITTEDとの違いです。それ以外(ロック読みやUPDATE、DELETEなどのロックの扱い)は、READ COMMITTEDと同じです。
SERIALIZABLE
基本はREPEATABLE READに近いですが、autocommitが無効なとき(明示的なトランザクション中)は、通常のSELECTをSELECT ... FOR SHARE相当に置き換え、読んだ行に共有ロックを掛けます。対象行をまだコミットしていない他トランザクションが変更中なら、その終了を待ってから最新のコミット済み値を見ます。共有ロック中、他セッションは同じ行を読めます(別セッションのFOR SHAREも共存できます)が、その行のUPDATEやDELETEは、自トランザクションがコミットまたはロールバックするまで待たされます。一方、autocommitが有効なままの単独SELECTはそれ自体が読み取り専用の1トランザクションになるので、ロックを取らずRead Viewで読め、他トランザクションを待たせずに済みます。
これらとは別に、AC-NL-RO(Auto-Commit Non-Locking Read-Only、autocommitで動く非ロックの読み取り専用SELECT)向けの最適化があります。autocommit=1のままSELECTだけを実行する場合、MySQLは1文ごとに新しいトランザクションを開始・終了するため、本来は文ごとに新しいRead Viewを用意する必要があります。しかしRead Viewを新しく作るには、その時点でアクティブな読み書きトランザクションのID一覧(m_ids)をコピーする必要があり、そのためにInnoDB全体で共有するmutex(複数のスレッドが同時に同じデータへアクセスして壊さないよう、一時的に排他ロックをかける仕組み)を取得します。読み取り専用の負荷でこの処理を毎回行うと、mutexの競合がボトルネックになりかねません。そこでAC-NL-ROの場合は、直前に使ったRead Viewが「アクティブな読み書きトランザクションを1つも見ていなかった(m_idsが空)」状態で、かつそのRead Viewを作った時点のmax_trx_id(次に割り当てられるトランザクションID)から今までのあいだに新しいmax_trx_idが発行されていなければ、新しいRead Viewを作らずそのまま使い続けます。max_trx_idが変わっていないということは、その間に新しい読み書きトランザクションが1つも始まっていないということなので、作り直しても結果は同じになるはずだからです。これはREPEATABLE READの「同じトランザクション内で同じスナップショットを保つ」という一貫性のための仕組みとは目的が異なり、あくまでmutex取得やID一覧コピーの手間を省くための実装上の最適化です。
Read Viewは、トランザクション終了時やカーソルクローズ時に解放され、再利用可能なプールへ戻されます。InnoDBは、Read View用のメモリ領域をあらかじめいくつか確保しておき、このプールから使い回すことで、Read Viewを作るたびにメモリの確保・解放が発生するコストを避けています。先に説明したAC-NL-ROの再利用は「同じ内容のRead Viewをそのまま使い続ける」最適化ですが、このプールはそれとは別の話で、「Read View用の入れ物(メモリ領域)」そのものを使い終わったら回収し、次の別のトランザクションに割り当て直すための仕組みです。