メインコンテンツまでスキップ

InnoDB の BufferPool 徹底解説

· 約18分

InnoDB ストレージエンジンでは、ディスク IO の回数を減らしてサービス性能を高めるために、すべての DML 操作はメモリ上の BufferPool 内で完結し、変更されたデータは非同期のフラッシュ戦略によってディスクへ書き込まれます。本記事では BufferPool のデータ構造、ページの管理方式、そして淘汰・フラッシュ戦略を詳しく整理していきます。

BufferPool の公式ドキュメント:

MySQL :: MySQL 8.0 Reference Manual :: 15.5.1 Buffer Pool

なぜ BufferPool を設計する必要があるのでしょうか。コンピュータの基礎知識があれば、メモリとディスクの効率には天と地ほどの差があることをご存じでしょう。CPU から見ると、ディスクの転送速度や応答時間はあまりにも遅すぎます。そこで MySQL は、ディスク上の実データをマッピングする BufferPool を設計し、メモリ上でデータの管理と操作を完結させることで、DML 文の実行効率を飛躍的に向上させました。BufferPool 内のデータをクラッシュや電源断の場面で失わないための仕組み(物理的な破損はさすがにどうしようもありませんが)については、以前の記事でも解説しています:

MySQL 実行フロー

Buffer Pool の概要

InnoDB におけるデータアクセスは Page 単位で行われ、各 Page のデフォルトサイズは 16KB です。Buffer Pool はこれらの Page を管理・キャッシュするためのものです。InnoDB は連続したメモリ領域を Buffer Pool に割り当て、それを複数の Buffer Pool Instance に分割してこのメモリをより効率的に管理します。各 Instance のサイズは等しく、アルゴリズムによって 1 つの Page は必ず特定の Instance に配置されることが保証されます。このマルチ Instance 方式によって Buffer Pool の並行処理性能が向上します。

各 Buffer Pool Instance の内部には、それぞれ独自の管理構造が維持されています。InnoDB は 16KB の Page 単位でディスクファイルからメモリへデータを読み込み、LRU List を使ってこれらの Page をキャッシュします。頻繁にアクセスされる Page は LRU List の前方に、あまりアクセスされない Page は後方に置かれます。ある Page にアクセスするときは、まず Buffer Pool 内を検索し、ヒットしなければディスクのデータファイルを読み、取得した Page を LRU List に格納します。Instance 内に利用可能な空き Page がなくなると、LRU List 内の Page が淘汰されます。

Buffer Pool には Page 圧縮のロジックも含まれています。つまり実際の 16KB Page を 8KB、4KB、2KB、1KB に圧縮する仕組みですが、この部分は本記事では割愛し、デフォルトの 16KB Page を前提に主要なロジックを整理します。

現時点で見ると、BufferPool は階層化された構造になっています:

  • BufferPool は複数の Buffer Pool Instance から構成される。
  • Buffer Pool Instance は複数の Buffer Chunk から構成される(デフォルトでは各 Buffer Chunk は 128MB)。Buffer Chunk は連続したメモリ領域を管理し、これを Buffer Chunk Memory と呼ぶ。
  • Buffer Chunk は複数の Buffer Page(略して Page、デフォルト 16KB)から構成される。

この階層構造は、高い並行性のもとでの mutex 競合を減らすためのものです。

本記事では主に BufferPool のデータ構造、管理方式、そして淘汰・フラッシュ戦略を解説します。まず公式が示している LRU データキャッシュの構造図を見てみましょう:

Buffer Pool のデフォルトの総サイズは 128M で、innodb-buffer-pool-size = xxxxxxx によって必要に応じて変更できます(公式は、専用のデータベースサーバーであれば物理メモリの大部分を割り当ててよいと推奨しています)。Buffer Pool は New Sublist と Old Sublist に分かれています。JVM の新生代と老年代を思い出しませんか?考え方は確かに似ています。New Sublist には最も頻繁にアクティブなデータが、Old Sublist にはあまりアクセスされないデータが格納され、Old Sublist はデフォルトで BufferPool の 3/8 を占めます。

メモリ資源は貴重なので、ディスク上のすべてのテーブルデータをメモリに載せることは不可能であり、一定の淘汰戦略が必要になります。BufferPool は LRU アルゴリズムに MySQL 独自の変種ロジックを加えてメモリデータの淘汰を実現しており、ホット・コールドデータ処理とも呼ばれます。頻繁にアクセスされるデータは New Sublist 領域へ移動し、あまりアクセスされないデータは Old Sublist へ移動します。メモリ使用量が高くなりすぎたときは、Old Sublist のデータが優先的に淘汰されます。新しく読み込まれたページは Old Sublist の Head 位置に置かれ、そのページが頻繁にアクセスされれば New Sublist へ移動し、そうでなければ Old Sublist に留まり続けます。

BufferPool の内部には ChangeBuffer と呼ばれる領域もあります。図のとおりです:

ChangeBuffer は、対象ページが BufferPool 内に存在しない DML 変更をキャッシュするためのものです。変更のたびにページをディスクから読み込むことを避け、対応するページが後でメモリに読み込まれたときにこれらの変更をマージすることで、ランダム IO を削減します。デフォルトでは ChangeBuffer は BufferPool の 25% を占め、最大 50% まで調整できます。

比較的完全な BufferPool の構造図(圧縮リストなど、描かれていないリストもいくつかあります):

整理を始める前に、いくつかの前提概念を押さえておきましょう:

  • データページ:すでにディスクのデータが読み込まれたページ。
  • ダーティページ:変更操作が行われたページで、ディスクへ書き戻す必要があるもの。
  • 空きページ:データが読み込まれていないページ。
  • 制御ブロック:各管理リストのノードに格納される BufferPool ページへのポインタ。
  • バッファページ:BufferPool 内のページの総称(ページサイズはすべて 16KB)。

複数の Buffer Pool Instance の設定

MySQL サーバーの起動時に、OS へ BufferPool のメモリ領域を要求します。マルチスレッドの場面では、各リストにロック処理が必要になるため、BufferPool が非常に大きく、かつ並行アクセス量が非常に多いとき、単一の BufferPool は処理速度に悪影響を与えます。そこで BufferPool を複数の小さな BufferPool、すなわち Buffer Pool Instance に分割します。各 Instance はそれぞれ独立してメモリ領域を確保し、独立してリストを管理するため、マルチスレッドでアクセスしても互いに影響しません。インスタンス数はパラメータで変更できます:

# buffer pool インスタンスを 2 つ作成する
innodb_buffer_pool_instances = 2

# 各インスタンスが占有するメモリ = 総サイズをインスタンス数で割った値
# innodb_buffer_pool_size / innodb_buffer_pool_instances

複数のインスタンスの作成・管理自体にもオーバーヘッドがあるため、MySQL 公式では、innodb_buffer_pool_size が 1G 未満の場合はデフォルトでインスタンスは 1 つだけとなり、複数設定しても無効になると規定されています。1G を超える場合にのみ、複数インスタンスの設定が推奨されます。

Buffer Pool によるページ管理

データページは BufferPool の中に無秩序に置かれているわけではなく、ハッシュテーブルといくつかのリストによって組織化され、前述の各種操作をサポートできるようになっています。リストのノードの中身は Page Descriptor です:

  1. page hash(高速アクセス):すべてのページは page hash と呼ばれる 1 つのハッシュテーブルで組織化されており、buffer pool 内のページへ高速にアクセスするために使われます。page hash のキーは page id です。
  2. free list(ページの割り当て):ページを buffer pool に読み込むときは、page descriptor として buf_block_t を割り当てる必要があります。空いていて未使用の buf_block_t をどうやって見つけるか?それらはすべて free list に繋がれています。
  3. LRU list(ページの淘汰):buffer pool の容量には限りがあるため、ディスクから読み込んだページを格納する空きがなくなったら、既存のページをいくつか淘汰する必要があります。ここで使われるのが LRU アルゴリズムです。
  4. flush list(ダーティページの書き戻し):ページがダーティページになったら、page cleaner スレッドが定期的にディスクへ書き戻す必要があります。これらのダーティページはすべて flush list 上にあります。

Free List によるリスト管理

MySQL サーバーの初回起動時には、BufferPool の初期化を行う必要があります。まず OS へ BufferPool のメモリ領域を要求し、それを複数の制御ブロックとバッファページのペアに分割します。この時点では実際のディスクページはまだ BufferPool にキャッシュされていません(まだ使われていないため)。その後プログラムの実行に伴って、ディスク上のページが次々とキャッシュされていきます。ここで問題になるのが、ディスクからページを 1 つ BufferPool に読み込むとき、どのバッファページの位置に置けばよいのか?言い換えれば、BufferPool 内のどのバッファページが空きで、どれがすでに使用中かをどう区別するのか?ということです。

どのバッファページが利用可能かをどこかに記録しておくのが最善です。ここでバッファページに対応する制御ブロックが大いに役立ちます。すべての空きバッファページに対応する制御ブロックをノードとして 1 つのリストに繋げます。このリストを Free リスト(空きリスト)と呼びます。初期化が完了したばかりの BufferPool ではすべてのバッファページが空きなので、各バッファページに対応する制御ブロックはすべて Free リストに追加されます。

Free リストをより適切に管理するために、ここでは特別にベースノードが定義されています。リストの先頭ノードのアドレス、末尾ノードのアドレス、現在のリスト内のノード数などの情報を含みます。注意すべき点として、ベースノードが占有するメモリは BufferPool 用に確保された大きな連続メモリの中には含まれず、別途確保されたメモリ領域です。

Free リストがあれば話は簡単です。ディスクからページを 1 つ BufferPool に読み込む必要が生じるたびに、Free リストから空きバッファページを 1 つ取り出し、そのバッファページに対応する制御ブロックの情報(そのページが属するテーブルスペースやページ番号などの情報)を書き込み、その制御ブロックを Free リストから外して、そのバッファページが使用中であることを表します。ここではっきりさせておきたいのは、リストから実際に取得するのは制御ブロックであり、制御ブロックを通じて初めて本当のページにアクセスできるということです。同様に、「BufferPool 内のバッファページを走査する」というのは実際には「各バッファページに対応する制御ブロックを走査する」ことなのです。

まとめ:Free リストさえきちんと管理していれば、BufferPool 内のどのページが空きかが分かります。

Page Hash によるバッファページのハッシュ処理

あるページのデータにアクセスする必要があるとき、そのページはディスクから BufferPool に読み込まれます。すでに BufferPool にあれば、そのまま使えばよいのです。ここで問題になるのが、そのページが BufferPool にあるかどうかをどうやって知るのか?まさかバッファページを順番に走査するのでしょうか?BufferPool にはバッファページが大量にあるのに、全部走査していたら大変です。

振り返って考えてみると、私たちは実はテーブルスペース番号 + ページ番号でページを特定しています。つまりテーブルスペース番号 + ページ番号が Key で、バッファページの制御ブロックが対応する Value です。Key から Value を高速に見つけるには、当然ハッシュテーブルを使います。

そこで、テーブルスペース番号 + ページ番号を Key、バッファページ制御ブロックのアドレスを Value としてハッシュテーブルを作成できます。あるページのデータにアクセスする必要があるときは、まずテーブルスペース番号 + ページ番号でハッシュテーブルを検索します。対応するバッファページがあればそのまま使い、なければ Free リストから空きバッファページを 1 つ選び、ディスク上の対応するページをそのバッファページの位置に読み込みます。

LRU List によるリスト管理

LRU リストは BufferPool バッファ領域に対する管理方法の一つです。考えてみてください。バッファ領域にデータページを読み込み続ければ、いつかは満杯になります。そのときには不要なデータページを解放する仕組みが必要になります。その仕組みが LRU アルゴリズム(Least Recently Used、最近最も使われていないものから淘汰するアルゴリズム)です。

このアルゴリズムは 1 本のリストとして捉えることができ、このリストは 2 つの部分に分かれています(MySQL の変種です)。一方は使用頻度が非常に高いバッファページ、つまりホットデータを格納する部分で、Young 領域(New Sublist)と呼ばれます。もう一方は使用頻度の低いバッファページ、つまりコールドデータを格納する部分で、Old 領域(Old Sublist)と呼ばれます。InnoDB は LRU リストを比率で 2 つに分割しており、パラメータ innodb_old_blocks_pct で Old 領域の占める比率を確認できます。

この 2 つの領域があることで、InnoDB の設計者は BufferPool のヒット率の状況に応じた最適化ができるようになりました:

  • 先読み(プリフェッチ)されたページがその後アクセスされない可能性に対する最適化。設計者は、ディスク上のあるページが初めて BufferPool のバッファページに読み込まれたとき、そのバッファページに対応する制御ブロックを Old 領域の先頭に置くと定めました。こうすることで、BufferPool に先読みされたものの後続アクセスがないページは徐々に Old 領域から追い出され、Young 領域内の使用頻度の高いバッファページに影響を与えません。
  • フルテーブルスキャン時に短時間で大量の低頻度ページにアクセスすることに対する最適化。フルテーブルスキャンでは、初回読み込みのページは Old 領域の先頭に置かれますが、その後すぐにアクセスされ、アクセスのたびにそのページが Young 領域の先頭に置かれるため、依然として使用頻度の高いページを押し出してしまいます。設計者は、フルテーブルスキャンの過程では、あるページに多くのレコードがあり、1 レコード読むごとに 1 回ページアクセスと数えたとしても、この過程にかかる時間は非常に短いと考えました。そこで次のように定めました。Old 領域にあるバッファページへの最初のアクセス時に、対応する制御ブロックにアクセス時刻を記録します。その後のアクセス時刻が最初のアクセス時刻から一定の間隔以内であれば、そのページは Old 領域から Young 領域の先頭へ移動せず、間隔を超えた場合にのみ移動します。この間隔時間はシステム変数 innodb_old_blocks_time で制御されます。

Flush List によるリスト管理

BufferPool 内のあるバッファページのデータを変更すると、ディスク上のページと不一致になります。このようなバッファページをダーティページと呼びます。もちろん、変更のたびに即座にディスク上の対応するページへフラッシュすることもできますが、頻繁なディスク書き込みは性能を著しく悪化させます。そのため、バッファページを変更するたびに急いでフラッシュするのではなく、将来のある時点でフラッシュします。

しかし即座にフラッシュしないなら、後でフラッシュするときに BufferPool 内のどのページがダーティページで、どのページが一度も変更されていないページなのかをどうやって知るのでしょうか?そこで、ダーティページを格納するリストをもう 1 本作らざるを得ません。変更されたバッファページに対応する制御ブロックは、すべてノードとしてこのリストに追加されます。これらのノードに対応するバッファページはすべてディスクへフラッシュする必要があるため、Flush リストと呼ばれます。Flush リストの構造は Free リストとほぼ同じです。また、あるバッファページが空きであれば、それは決してダーティページではありません。ダーティページであれば、決して空きではありません。つまり、あるバッファページに対応する制御ブロックが Free リストのノードであると同時に Flush リストのノードであることはあり得ず、どちらか一方のリストにしか存在できません。

まとめ:Free リストはバッファ領域内のすべての空きページからなるリスト、Flush リストは変更されたすべてのページ(ダーティページ)からなるリストと簡単に理解できます。

ダーティページのフラッシュ機構

ダーティページは以下のような状況でディスクへフラッシュされます:

1)Redo Log に書き込む余地がなくなったとき。Redo Log は永続化の保障として、更新操作のたびに必ず記録されます。Redo Log に書き込めなくなったら、ページをフラッシュしてデータをディスクへ同期し、Redo Log の CheckPoint ポインタを移動して新しい領域を空ける必要があります。このときメモリに余裕があれば、フラッシュしたページをメモリから淘汰する必要はありません。

2)メモリにデータページを格納する余地がなくなったとき。検索操作はディスク上のデータページをメモリへ読み込んでから返します。メモリに余地がなくなったら、最も長く使われていないデータページを淘汰しなければなりません。淘汰されるのがダーティページであれば、まずディスクへフラッシュしてから淘汰する必要があります。

3)データベースがアイドル状態のとき。データベースが現在アイドル状態にあると判断した場合、ダーティページのフラッシュを行います。このときメモリに余裕があれば、フラッシュ後に淘汰は行いません。

4)データベースの正常終了時。シャットダウン時にもページのフラッシュが行われ、それまでの変更がディスクへ永続化されます。

まとめ

BufferPool は、InnoDB がメモリで IO を代替する中核的な設計です。データは 16KB の Page 単位でメモリにキャッシュされ、DML 操作はまずメモリを変更し、その後非同期でディスクへ書き込まれます。並行処理時のロック競合を減らすために複数の Instance に分割され、内部では Page Hash によって高速な位置特定を実現し、Free・LRU・Flush の 3 本のリストがそれぞれ空きページの割り当て、ホット・コールドデータの淘汰、ダーティページの書き戻しを担います。LRU の New/Old 分割と innodb_old_blocks_time の時間ウィンドウは、先読みとフルテーブルスキャンによるホットデータの押し流し問題を解決しています。これらのリストの連携の仕方を理解すれば、BufferPool 全体の動作メカニズムもはっきり見えてくるはずです。

COMMENTS