クラッシュしても変更を失わないための記録
redoログは、InnoDBがクラッシュリカバリを実現するために持つ変更履歴です。INSERT・UPDATE・DELETEでバッファプール内のページを更新すると、そのページをディスクへ書き出す前に、まず「どのページのどの位置に何を書いたか」という変更内容をredoログに記録します。もしサーバーがクラッシュしても、再起動時にチェックポイントLSN(LSNはLog Sequence Numberの略で、redoログに記録された変更の順序を表す通し番号)以降のredoログをLSNの若い順に適用すれば、ディスクへ反映しきれていなかった変更を復元できます。
ここで自然にわく疑問は、「更新したページをそのままディスクへ書けばいいのでは」というものです。実は、それだと性能が持ちません。コミットの時点では、更新したページの多くはまだバッファプール内にあります。コミットのたびに関係するダーティページをすべてディスクへ書き出すと、そのつど大量のランダムI/Oが発生します。一方、redoログは追記型で、順番に書いていくシーケンシャルな書き込みで済みます。コミット時にはredoログだけをディスクへフラッシュし、実際のページは後からバックグラウンドでゆっくり書き出せます。こうすればコミットのレスポンスを保ちつつ、クラッシュ時にも変更を失いません。この「先にログ、後からページ」という考え方をWAL(Write-Ahead Logging)と呼びます。
ページ10のnameをAliceからBobへ更新し、コミットした直後は次の配置になります。バッファプール上のページ10はすでにBobですが、データファイル上の同じページはまだAliceです。ディスクへ先に乗っているのは、redoファイルへ追記した「ページ10のnameをBobに」という記録です。
矢印はコミット時にディスクへ書き出される経路です。データファイル側のページ10は、あとからフラッシュされるまでAliceのままです。クラッシュしても、redoの記録からバッファプール上のBobを復元できます。
ログファイルの構成
redoログは、ログバッファ(Log Buffer)というメモリ領域を経て、ディスク上のredoログファイルへ書き出されます。MySQL 8.0.30以降・8.4では、redoログはデータディレクトリ配下の#innodb_redoディレクトリに複数のファイルとして置かれ、InnoDBは既定でこれを32個のファイルに保とうとします。各ファイルのサイズは、innodb_redo_log_capacityで指定するredoログ全体の合計容量の32分の1で、実際に使われている「使用中(ordinary)」ファイルと、次に使う予定でファイル名に_tmpが付く「予備(spare)」ファイルの2種類があります。
書き込みはLSNの若い順にファイルをまたいで進み、複数のファイル全体を1つの大きなリングとして扱います。リングといっても、データページどうしをポインタでつなぐ方式ではありません。各ファイルが「次のファイル」への参照を中に持っているわけではなく、次に使うファイルは番号順で決まります。#ib_redo0の次は#ib_redo1、最後のファイルの次はまた#ib_redo0です。あるファイルがend_lsnで終わると、次のファイルはそのend_lsnから始まります。あるファイルの内容がすでにチェックポイントを通過し、データページへ反映済みとみなせるようになると、そのファイルは上書きして再利用できます。ファイル単位で使用中・予備を入れ替えられる構造のおかげで、innodb_redo_log_capacityを変更してもサーバーを再起動せずに容量をオンラインで変えられます。
容量の指定方法はバージョンで異なります。MySQL 8.0.30以降はinnodb_redo_log_capacityで合計容量(32ファイル分の合計サイズ)を指定し、既定は100MBです。この変数は実行中にも変更できます。それ以前はinnodb_log_file_size(既定48MB)とinnodb_log_files_in_group(既定2)でファイルサイズと個数を指定していましたが、MySQL 8.4では非推奨です。
LSNとチェックポイント
LSN(Log Sequence Number)は、redoログの論理的な位置を表す番号です。ログはLSNの小さい順に生成され、リカバリでもLSNの若い順に適用します。途中を飛ばすことはできません。
チェックポイントLSNは、「このLSN以前のredoログが記述する変更は、すでにディスク上のデータページへ反映されている」という境界を表します。チェックポイントより前のログ領域は上書きして再利用できます。チェックポイントを進めるには、そのLSNに対応する変更をすべてデータページへフラッシュしておく必要があります。バッファプールのリストフラッシュが、まさにこの目的で行われます。redoログの再利用できる範囲と、ダーティページをどれだけ書き出したかは、こうして連動しています。
複数ファイルを1つのリングとして見たときの配置は、次のようになります。図の各箱が#innodb_redo内の1ファイルです。実際は既定32個ですが、ここでは3個に省略しています。矢印はファイル内のポインタではなく、番号順の「次のファイル」です。#ib_redo0の末尾LSN=4000から#ib_redo1が始まり、#ib_redo2の書き込み先端LSN=7200の次は、一周して#ib_redo0へ戻ります。#ib_redo0はチェックポイントLSN=4000より前なので上書きしてよく、#ib_redo1と#ib_redo2はリカバリに必要なので残します。
チェックポイントLSNを進めるほど、再利用できる区間が広がります。進めるには、そのLSN以前の変更を持つダーティページを先にフラッシュしておく必要があります。
ログバッファ
ログバッファは、redoログファイルへ書き出す前にredoログレコードを一時的に置くメモリ領域です。コミットやページ更新で生成されるredoログは、まずここに書かれ、その後バックグラウンドのログライタースレッドがディスクへフラッシュします。多くの行を更新・挿入・削除するトランザクションほどredoログも増えますが、ログバッファを大きくしておくと、コミットまでディスクへの書き込みを待たずにバッファへ溜められ、ディスクI/Oの回数を減らせます。
サイズと配置
ログバッファのサイズはinnodb_log_buffer_sizeで指定します。MySQL 8.4では最小値が1MB、既定が64MBです。既定はバージョンで異なり、8.0では16MBでした。ログバッファはバッファプールとは別に確保され、redoログ専用に使われます。中身は論理的なLSNの範囲に対応して並び、ログライタースレッドはバッファ内のデータを順にディスクへ書き出します。書き出し済みの範囲を超えてログライターが先へ進むことはありません。
並行書き込みと順序保証
トランザクションの変更がコミットされると、その変更を記述したredoログが生成されます。書き込みは大まかに次の手順です。
- ログバッファ内に書き込む領域(バイト数)を予約する。予約によりstart_lsnからend_lsnの範囲が割り当てられる。
- 予約した範囲へredoログデータを書き込む。
- 書き込み後、「start_lsnからend_lsnまで書き込み済み」という情報をrecent writtenバッファに登録する。
複数スレッドが同時にログバッファへ書くと、書き終わる順序がLSNの並びと一致しないことがあります。たとえばスレッドAがLSN100〜150、スレッドBが150〜200を予約したとき、Bが先に書き終わってAがまだ途中、という状態が起こり得ます。ところがログライターは、リカバリで先頭から順に適用できるように、ディスクへLSNの若い順に書かなければなりません。100〜150がまだ埋まっていないのに150〜200だけをディスクへ書くことはできず、Aの完了を待つ必要があります。
recent writtenバッファは、各スレッドが「自分の範囲を書き終えた」ことを登録する補助構造です。ログライターは、登録されたリンク(start_lsn → end_lsn)の連鎖をたどることで「このLSNより前はすべてバッファに書き込み済み」と判断でき、その境界までをディスクへフラッシュします。リンクが途切れている箇所では該当スレッドの完了を待ちます。これで、複数スレッドの並行書き込みと、ログライターの順序保証が両立します。
フラッシュの制御
ログバッファをいつディスクへフラッシュするかは、innodb_flush_log_at_trx_commitで制御します。
| 値 | 動作 |
|---|---|
| 0 | 1秒に1回程度フラッシュする。コミット時にはフラッシュしない。クラッシュ時に直近1秒分のコミットが失われ得る |
| 1 | コミットのたびにフラッシュし、OSのfsyncまで完了させる。耐久性が最も高い |
| 2 | コミットのたびにOSのキャッシュへは書くが、fsyncは毎回は行わない。OSがクラッシュするとログが失われ得る |
耐久性と性能のトレードオフを、この設定で選べます。既定の1が最も安全です。
クラッシュリカバリ
クラッシュ後の再起動時、InnoDBはリカバリ処理を行います。チェックポイントLSNから、ディスク上のredoログに記録された末尾まで、LSNの順にredoログを適用します。適用とは、各ログレコードが記述する「ページのどの位置にどのバイト列を書くか」に従い、該当ページを読み込んでバッファプール上で変更を反映することです。反映されたページはダーティになり、その後のフラッシュでディスクへ書き出されます。こうして、未フラッシュだったダーティページの内容がredoログを通じて復元されます。
リカバリでは、まずチェックポイント以降のredoログをすべて適用します。ここにはクラッシュ時点で未コミットだったトランザクションの変更も含まれます。redoの適用が終わると、MySQLサーバーはアプリケーションなどからのクライアント接続をできるだけ早く受け付け始めます。未コミットだったトランザクションのロールバックは、undoログを使ってバックグラウンドスレッドが行い、新しいクライアントからのクエリ処理と並行して進むことがあります。redoは物理的な変更の再現、undoは論理的な取り消しという、役割の異なる2つを組み合わせて、クラッシュ直前の一貫した状態に戻します。
変更を元に戻すための記録
undoログは、トランザクションの変更を「元に戻すための情報」を記録する仕組みです。InnoDBでは主に2つの用途があります。
1つはROLLBACK時の取り消しです。トランザクションがコミットされずに終わった場合、そのトランザクションが行った変更をundoログに基づいて打ち消し、データを変更前の状態へ戻します。
もう1つはMVCC(Multi-Version Concurrency Control)です。ある時点のスナップショットを見ている読み取りからは、他のトランザクションがすでにコミットした最新の行が「まだ見えてはいけない」ことがあります。そのとき、undoログに残された古いバージョンをたどって、その読み取りから見えるべきバージョンを復元します。
redoログとの役割の違い
undoログとredoログは名前が似ていますが、記録するものが正反対です。redoログは「何を変更したか」を物理的に記録し、クラッシュ後に変更を再現するために使います。undoログは「変更をどう取り消すか」を論理的に記録し、ROLLBACKや古いバージョンの復元に使います。
両者は補い合う関係でもあります。通常の(永続テーブル向けの)undoログへの書き込み自体もredoログに記録されるため、undoの永続性はredoによって保証されます。クラッシュ後のリカバリでredoを適用してundoログを復元し、それから未コミットのトランザクションをundoで巻き戻す、という流れになります。なお、ユーザー一時テーブル向けのundoはクラッシュリカバリに不要なため、redoには記録されません。
どこに保存されるか
undoログは、rollback segmentが保持します。MySQL 8.0では専用のundoテーブルスペース(初期化時に既定で2つ作られる)に置かれ、システムテーブルスペース(ibdata1)には格納されません。undoログがibdata1に置かれていたのは主にMySQL 5.7以前です。rollback segmentはINSERT用とUPDATE用のundoログを持ち、各undoログのレコードは、通常のインデックスページと同じ16KB単位のundoページに記録されます。
1つのトランザクションが複数の変更を行うと、そのトランザクションに属するundoログは連鎖状につながります。ROLLBACK時には、この連鎖を逆にたどることで、変更を順に取り消していけます。
2種類のundoログ
undoログは、操作の種類に応じて2つに大別されます。
| 種類 | 役割 |
|---|---|
| INSERTのundo | 行の挿入を取り消すための情報。ROLLBACK時に挿入したレコードを削除する |
| UPDATEのundo | 行の更新・削除を取り消すための情報。ROLLBACK時に更新前の値を復元する。並行する読み取りで古いバージョンを復元するときにも使う |
この2つは、寿命の扱いも違います。INSERTのundoはROLLBACK専用なので、トランザクションがコミットされれば破棄されるか、再利用のためにキャッシュされます。挿入した行は、コミットしてしまえば「元に戻す」必要も「古いバージョンとして見せる」必要もないからです。
一方、UPDATEのundoはhistory listに入り、まだ古いバージョンを参照しているRead Viewがある間は保持されます。不要になったあと、purgeによって回収されます。更新前の値は、まだ古いスナップショットを見ている読み取りにとって必要になり得るため、コミット後すぐには捨てられないわけです。
読み取りと書き込みを待たせ合わない
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用の入れ物(メモリ領域)」そのものを使い終わったら回収し、次の別のトランザクションに割り当て直すための仕組みです。