コンテンツへスキップ
戻る

Buffer Pool と MySQL の Query Cache

公開日:  at  08:00 午前

はじめに

前回は Hibernate の二次キャッシュについて話しました。あれは「アプリケーション層/ORM」の視点からキャッシュを考えるものでした。今回はもう一段掘り下げて、データベース自身にもキャッシュの仕組みがあることを見ていきます。

「データベースにもキャッシュがある」と初めて聞くと、多くの人は少し戸惑います。そもそもデータベースを叩かないためにキャッシュするのでは? なのになぜデータベースの中にまたキャッシュがあるのか? 実はこれはまったく矛盾しません。データベースが相手にしているのはディスク I/O という本質的に遅いものであり、当然ながらよく使うデータをメモリに留めておき、実際にディスクを読み書きする回数を減らそうとするのです。

この記事では、ちょうど対照をなす 2 つを取り上げます。

  1. Buffer Pool:InnoDB の中核キャッシュ。今でも性能の生命線であり、意識せずとも毎日のように使っています。
  2. Query Cache:かつて MySQL に組み込まれていた「クエリ結果キャッシュ」。しかし MySQL 8.0 で正式に削除されました。

この 2 つのキャッシュ機構を並べて見ると、「どんなキャッシュ設計が長続きし、どんなものが時代に取り残されるのか」がちょうどよく理解できます。

Buffer Pool

Buffer Pool とは?

Buffer Pool は、InnoDB ストレージエンジンがメモリ上に確保する大きな領域で、データページ(page)とインデックスページをキャッシュするためのものです。

理解するには、まず InnoDB がデータにアクセスする基本単位が「1 件の row」ではなく**ページ(page)**であること、そしてデフォルトで 1 ページ 16KB であることを知る必要があります。1 件のデータを検索するとき、InnoDB はディスクからその 1 件だけを取ってくるのではなく、その行が含まれる 16KB のページ全体をメモリに読み込みます。

そしてディスク I/O は、メモリアクセスに比べて何桁も遅いものです。ですから InnoDB の戦略は直感的です。

読んだページ・変更したページをできるだけメモリ(Buffer Pool)に留めておき、後で同じページにアクセスするときはディスクに触れなくて済むようにする。

これは読み書きどちらにも効きます

これが、この後で話す Query Cache との最も本質的な違いです。Buffer Pool がキャッシュするのは「データページ」という低レベルの構造であって、「ある SQL の結果」ではありません。 レベルがこれほど低いからこそ、ほぼすべての操作を高速化できるのです。

Buffer Pool は何を残すかどう決める? 改良版 LRU

メモリは有限なので、Buffer Pool がいっぱいになったら「どのページを追い出すか」を決めて空きを作らねばなりません。InnoDB が使うのは改良版の **LRU(Least Recently Used、最近最も使われていないもの)**です。

素の LRU には有名な問題があります。フルテーブルスキャン(full table scan)による汚染です。ある帳票クエリが大きなテーブルを一気にスキャンし、「実際にはこの一回しか使わない」ページを大量に LRU の先頭へ押し込む様子を想像してください。本当にホットなデータが押し出されてしまい、キャッシュヒット率が一気に崩れます。

InnoDB の解決策は、LRU リストを 2 つのセグメントに分けることです。

肝心なのは、**新しく読み込まれたページは先頭ではなく、「旧世代の先頭」に挿入される(midpoint insertion、中点挿入)**という点です。あるページが新世代に昇格するのは、「旧世代に入った後、一定の時間内に再びアクセスされた」場合だけです。こうすることで、フルテーブルスキャンが持ち込む使い捨てのページは旧世代に留まってすぐ追い出され、本当にホットなデータを汚染しません。

実務で出会う設定と観察点いくつか

innodb_buffer_pool_size —— 最も重要なパラメータで、Buffer Pool の大きさを決めます。専用のデータベースサーバーでは、物理メモリの 50%〜75% に設定するのがよくある推奨です。小さすぎるとヒット率が下がり、ディスクを読み続けることになります。大きすぎると OS や他のプロセスを圧迫しかねません。

ヒット率(hit rate) —— SHOW ENGINE INNODB STATUSBUFFER POOL AND MEMORY セクションで確認でき、次のような行があります。

Buffer pool hit rate 1000 / 1000

直近 1000 回のページアクセスがすべてメモリにヒットし、ディスクを一度も読まなかったことを意味します。健全な OLTP システムでは、この値は通常ほぼ満点に近くなります。

Change Buffer(ついでに) —— InnoDB には Change Buffer という関連する仕組みもあります。「一意でない二次インデックス」への書き込みで、対応するページがそのとき Buffer Pool になければ、変更をいったん退避しておき後でマージすることで、インデックス更新のためだけに頻繁なランダムディスク読み取りが発生するのを避けます。Buffer Pool と連携して動くもので、ここではそういうものがあると知っておけば十分です。

Buffer Pool を一言でまとめると、データベース性能の土台であり、「使うかどうかを決める」必要はありません。ずっと使っているからです。できることは、十分な大きさに設定し、ヒット率を観察することです。

Query Cache

Query Cache とは?

元気に生きている Buffer Pool の話が済んだので、取り残されたほうを見てみましょう。

Query Cache は MySQL(8.0 より前)に組み込まれていた機能で、そのキャッシュ対象は Buffer Pool とはまったく別のレベルにあります。それがキャッシュするのは、1 つの SELECT 文まるごとの「完全な結果セット」です。

その仕組みは一見すると非常に魅力的です。

  1. SELECT を受け取ると、まずその SQL のテキストを使って Query Cache にキャッシュ済みかを問い合わせる。
  2. あれば(キャッシュヒット)、かつその SQL が使うすべてのテーブルが前回キャッシュしてから一切変更されていなければ、前回の結果をそのまま返す。解析も、最適化も、実行もまったく不要。
  3. なければ通常どおり実行し、その結果を Query Cache に格納して次回に備える。

関連する設定はだいたい次のような形でした(これも 8.0 より前)。

# 0=OFF, 1=ON, 2=DEMAND(SQL_CACHE ヒント付きのクエリだけキャッシュ)
query_cache_type = 1

# キャッシュ領域のサイズ
query_cache_size = 64M

素晴らしそうに聞こえませんか? クエリを実行すらせずに結果を直接返す。これこそ最速では? 問題は、理論は美しくても、実務で持ち込む面倒がしばしば利益を上回ることにあります。

Query Cache はなぜ廃止されたのか?

Query Cache は MySQL 5.7.20 で deprecated(非推奨)とされMySQL 8.0 で正式に削除されました。公式がこれほど重い判断を下したのは、解決の難しい構造的な問題をいくつも抱えていたからです。

問題その 1:無効化(invalidation)の粒度が粗すぎる

これが最も致命的な問題です。Query Cache の無効化ルールはこうです。

あるテーブルに「何らかの」書き込み(INSERT/UPDATE/DELETE)が起きた瞬間、そのテーブルを使うキャッシュ済みクエリはすべて一度に無効化され、消し飛ぶ。

注意してください。「変更された行に関係するクエリ」が無効化されるのではなく、そのテーブルに関係するすべてのクエリがまとめて無効化されます。あなたが変更したのが、それらが検索している行かどうかに関係なくです。

「読み多く書き少ない」テーブルならまだよいのですが、そのテーブルへの書き込みが少し頻繁になるだけで惨事になります。苦労してキャッシュした大量のクエリ結果が、書き込みが一つ入った瞬間に全部無効になり、次回はまた実行してキャッシュし直し、そしてまた次の書き込みで消される……キャッシュはほとんど効かないのに、「入れては消す」という無駄な仕事だけをし続けます。

問題その 2:グローバルロックによる並行性のボトルネック

この共有キャッシュを維持するため、Query Cache は内部で保護用のロックを必要とします。問題は、このロックの粒度が非常に粗く、ほぼグローバル級だということです。

結果として、高並行・マルチコアの環境では、大量のスレッドが「キャッシュを調べる/書く/消す」ために同じ 1 つのロックを奪い合い、深刻な競合(contention)を生みます。皮肉なことに、本来「高速化」するはずの機能が、高並行下ではシステム全体の直列化ボトルネックとなり、マルチコアの利点を打ち消してしまいます。コアが多く、並行性が高いほど、この問題は顕著になります。

問題その 3:「一字一句同じ」でないとヒットしない

Query Cache の照合は SQL の生の文字列で行われるため、ヒット条件が極めて厳しくなります。

つまり、現実のアプリで ORM や異なるコード片から組み立てられる、少しずつ形の違う SQL の多くは、このキャッシュの恩恵をまったく受けられないのです。

問題その 4:総合すると、しばしば「負の最適化」になる

以上を合わせると、Query Cache は多くの現実のワークロードで利より害が大きいことが分かります。

こうして、しばしば気まずい状況に陥ります。有効にするとかえって遅くなるのです。これこそ、まだ存在していた時代に、多くのベテラン DBA の最初のチューニング推奨が「Query Cache を無効にする」だった理由です。「デフォルトで無効にしておくのが最善」な機能が削除されるのも、無理からぬことでしょう。

ではその役割は誰が引き継いだのか?

Query Cache が消えた後、それが解決しようとしていた「同一クエリを繰り返し実行しない」という課題は、より適したレイヤーに振り分けられました。

  1. アプリケーション層キャッシュ(最も主流)Redis/Memcached のような外部キャッシュを使い、クエリ結果や計算結果をキャッシュします。Query Cache と比べ、無効化戦略(TTL、能動的な evict)を自分で正確に制御でき、マシンをまたいで共有でき、意味論も明確です。これは前回の「二次キャッシュが Redis に置き換わった」のとまったく同じ流れです。人々は、低レベルに埋もれて粒度も粗い暗黙的なキャッシュより、「明示的で制御可能な」キャッシュを好むのです。
// 典型的なやり方:アプリ層で Spring Cache 抽象 + Redis
@Cacheable(value = "userProfile", key = "#id")
public UserProfile getUserProfile(Long id) {
    return userRepository.findProfile(id);
}
  1. Buffer Pool + よいインデックスに任せる:多くの場合、そもそも「結果まるごとをキャッシュする」必要はありません。データページとインデックスページが Buffer Pool にあり、インデックスもよく張られていれば、クエリ自体がすでに十分速いのです。結果をキャッシュするより、クエリ自体を速く・安くするほうが、より根本的で安定します。

  2. 中間層(proxy)キャッシュ:ProxySQL のようなデータベースプロキシは、MySQL の外側でずっと制御しやすいクエリキャッシュを提供でき、必要になったら導入します。

Buffer Pool vs Query Cache

両者を並べて対照すると、なぜ一方が残り、一方が取り残されたのかがより明確になります。

比較項目Buffer PoolQuery Cache(削除済み)
キャッシュ対象データページ/インデックスページ(16KB の page)SELECT 文まるごとの完全な結果セット
レベルストレージエンジンの低レベルクエリ層(実行前に横取り)
高速化の範囲ほぼすべての読み書き「完全同一かつテーブル未変更」の SELECT のみ
無効化の粒度ページ単位、細かいテーブル全体単位、極めて粗い
並行性の挙動高度に最適化、複数 instance に分割可グローバルロック、高並行下でボトルネック
現状中核機構、今なお不可欠MySQL 5.7 で非推奨、8.0 で削除

違いを一言で言えば、Buffer Pool は「クエリを実行すること自体を速くする」もの、Query Cache は「いっそクエリを実行しないで済ませよう」とするものです。 前者は堅実で有効、すべての操作に役立ちます。後者の美しさは理想的な状況でのみ成り立ち、書き込みが頻繁で並行性が高くなった途端に崩壊します。

まとめ

この記事では、データベース層の 2 つのキャッシュ機構を対照させて見てきました。

前回の二次キャッシュが教えてくれたのが「キャッシュの前提が成り立たなくなった瞬間、そのリスクは利益を上回る」だとすれば、Query Cache の物語はもう一つの教訓を加えてくれます。キャッシュ設計が長続きするかどうかは、しばしばヒット時にどれだけ速いかではなく、無効化時にどれだけ痛いか、そして維持のコストがどれだけ高いかにかかっているのです。Buffer Pool が長く生き残っているのは、まさに無効化が精密でコストが制御可能だからであり、Query Cache が取り残されたのも、まさにこの 2 点で敗れたからです。

このレイヤーを理解すれば、自分でキャッシュを設計するときにもう一つ問いを立てるようになるでしょう。それはいつ無効化されるのか? 無効化のコストは何か? そしてそれは、「ヒット時にどれだけ速いか」よりも先に考え抜く価値があることが多いのです。


修正を提案する
この記事をシェアする:

前の記事
バックエンド屋の性能最適化 実戦(1):問題を観測する
次の記事
Hibernate の二次キャッシュとは?