はじめに
前回は Hibernate の二次キャッシュについて話しました。あれは「アプリケーション層/ORM」の視点からキャッシュを考えるものでした。今回はもう一段掘り下げて、データベース自身にもキャッシュの仕組みがあることを見ていきます。
「データベースにもキャッシュがある」と初めて聞くと、多くの人は少し戸惑います。そもそもデータベースを叩かないためにキャッシュするのでは? なのになぜデータベースの中にまたキャッシュがあるのか? 実はこれはまったく矛盾しません。データベースが相手にしているのはディスク I/O という本質的に遅いものであり、当然ながらよく使うデータをメモリに留めておき、実際にディスクを読み書きする回数を減らそうとするのです。
この記事では、ちょうど対照をなす 2 つを取り上げます。
- Buffer Pool:InnoDB の中核キャッシュ。今でも性能の生命線であり、意識せずとも毎日のように使っています。
- 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)に留めておき、後で同じページにアクセスするときはディスクに触れなくて済むようにする。
これは読み書きどちらにも効きます。
- 読み取り:必要なページがすでに Buffer Pool にあれば(キャッシュヒット)、メモリから直接返され、ディスクにはまったく触れません。
- 書き込み:変更はまずメモリ上のページに書き込まれ(この時点でそのページは「ダーティページ / dirty page」になります)、すぐにはディスクへ書き戻されません。後でバックグラウンドスレッドがまとめてフラッシュ(flush)します。これにより書き込みが毎回ディスクを待たずに済みます。
これが、この後で話す Query Cache との最も本質的な違いです。Buffer Pool がキャッシュするのは「データページ」という低レベルの構造であって、「ある SQL の結果」ではありません。 レベルがこれほど低いからこそ、ほぼすべての操作を高速化できるのです。
Buffer Pool は何を残すかどう決める? 改良版 LRU
メモリは有限なので、Buffer Pool がいっぱいになったら「どのページを追い出すか」を決めて空きを作らねばなりません。InnoDB が使うのは改良版の **LRU(Least Recently Used、最近最も使われていないもの)**です。
素の LRU には有名な問題があります。フルテーブルスキャン(full table scan)による汚染です。ある帳票クエリが大きなテーブルを一気にスキャンし、「実際にはこの一回しか使わない」ページを大量に LRU の先頭へ押し込む様子を想像してください。本当にホットなデータが押し出されてしまい、キャッシュヒット率が一気に崩れます。
InnoDB の解決策は、LRU リストを 2 つのセグメントに分けることです。
- New / Young sublist(新世代):本当にホットなページを置く。
- Old sublist(旧世代):読み込まれたばかりで、まだホットだと証明されていないページを置く。デフォルトでリスト全体の約 **3/8(37%)**を占め、
innodb_old_blocks_pctで調整できます。
肝心なのは、**新しく読み込まれたページは先頭ではなく、「旧世代の先頭」に挿入される(midpoint insertion、中点挿入)**という点です。あるページが新世代に昇格するのは、「旧世代に入った後、一定の時間内に再びアクセスされた」場合だけです。こうすることで、フルテーブルスキャンが持ち込む使い捨てのページは旧世代に留まってすぐ追い出され、本当にホットなデータを汚染しません。
実務で出会う設定と観察点いくつか
innodb_buffer_pool_size —— 最も重要なパラメータで、Buffer Pool の大きさを決めます。専用のデータベースサーバーでは、物理メモリの 50%〜75% に設定するのがよくある推奨です。小さすぎるとヒット率が下がり、ディスクを読み続けることになります。大きすぎると OS や他のプロセスを圧迫しかねません。
ヒット率(hit rate) —— SHOW ENGINE INNODB STATUS の BUFFER 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 文まるごとの「完全な結果セット」です。
その仕組みは一見すると非常に魅力的です。
- SELECT を受け取ると、まずその SQL のテキストを使って Query Cache にキャッシュ済みかを問い合わせる。
- あれば(キャッシュヒット)、かつその SQL が使うすべてのテーブルが前回キャッシュしてから一切変更されていなければ、前回の結果をそのまま返す。解析も、最適化も、実行もまったく不要。
- なければ通常どおり実行し、その結果を 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 の生の文字列で行われるため、ヒット条件が極めて厳しくなります。
- 大文字小文字の違い、空白 1 つ、改行 1 つ、コメント 1 つの違い → 別のクエリとみなされ、ヒットしない。
NOW()、RAND()、CURRENT_TIMESTAMPのような非決定的関数を使用 → 結果がそもそも変わるので、まったくキャッシュされない。- 異なるデータベース(schema)、異なるプロトコルバージョン、異なる文字セットの接続 → これらも別物とみなされうる。
つまり、現実のアプリで ORM や異なるコード片から組み立てられる、少しずつ形の違う SQL の多くは、このキャッシュの恩恵をまったく受けられないのです。
問題その 4:総合すると、しばしば「負の最適化」になる
以上を合わせると、Query Cache は多くの現実のワークロードで利より害が大きいことが分かります。
- キャッシュを維持するために、ロック競合、メモリ管理、無効化判定のコストを払う。
- しかし無効化が粗すぎ、ヒット条件が厳しすぎるため、実際に節約できる実行回数はごく限られる。
こうして、しばしば気まずい状況に陥ります。有効にするとかえって遅くなるのです。これこそ、まだ存在していた時代に、多くのベテラン DBA の最初のチューニング推奨が「Query Cache を無効にする」だった理由です。「デフォルトで無効にしておくのが最善」な機能が削除されるのも、無理からぬことでしょう。
ではその役割は誰が引き継いだのか?
Query Cache が消えた後、それが解決しようとしていた「同一クエリを繰り返し実行しない」という課題は、より適したレイヤーに振り分けられました。
- アプリケーション層キャッシュ(最も主流):Redis/Memcached のような外部キャッシュを使い、クエリ結果や計算結果をキャッシュします。Query Cache と比べ、無効化戦略(TTL、能動的な evict)を自分で正確に制御でき、マシンをまたいで共有でき、意味論も明確です。これは前回の「二次キャッシュが Redis に置き換わった」のとまったく同じ流れです。人々は、低レベルに埋もれて粒度も粗い暗黙的なキャッシュより、「明示的で制御可能な」キャッシュを好むのです。
// 典型的なやり方:アプリ層で Spring Cache 抽象 + Redis
@Cacheable(value = "userProfile", key = "#id")
public UserProfile getUserProfile(Long id) {
return userRepository.findProfile(id);
}
-
Buffer Pool + よいインデックスに任せる:多くの場合、そもそも「結果まるごとをキャッシュする」必要はありません。データページとインデックスページが Buffer Pool にあり、インデックスもよく張られていれば、クエリ自体がすでに十分速いのです。結果をキャッシュするより、クエリ自体を速く・安くするほうが、より根本的で安定します。
-
中間層(proxy)キャッシュ:ProxySQL のようなデータベースプロキシは、MySQL の外側でずっと制御しやすいクエリキャッシュを提供でき、必要になったら導入します。
Buffer Pool vs Query Cache
両者を並べて対照すると、なぜ一方が残り、一方が取り残されたのかがより明確になります。
| 比較項目 | Buffer Pool | Query Cache(削除済み) |
|---|---|---|
| キャッシュ対象 | データページ/インデックスページ(16KB の page) | SELECT 文まるごとの完全な結果セット |
| レベル | ストレージエンジンの低レベル | クエリ層(実行前に横取り) |
| 高速化の範囲 | ほぼすべての読み書き | 「完全同一かつテーブル未変更」の SELECT のみ |
| 無効化の粒度 | ページ単位、細かい | テーブル全体単位、極めて粗い |
| 並行性の挙動 | 高度に最適化、複数 instance に分割可 | グローバルロック、高並行下でボトルネック |
| 現状 | 中核機構、今なお不可欠 | MySQL 5.7 で非推奨、8.0 で削除 |
違いを一言で言えば、Buffer Pool は「クエリを実行すること自体を速くする」もの、Query Cache は「いっそクエリを実行しないで済ませよう」とするものです。 前者は堅実で有効、すべての操作に役立ちます。後者の美しさは理想的な状況でのみ成り立ち、書き込みが頻繁で並行性が高くなった途端に崩壊します。
まとめ
この記事では、データベース層の 2 つのキャッシュ機構を対照させて見てきました。
- ✅ Buffer Pool は、InnoDB がメモリ上でデータ/インデックスページをキャッシュする中核機構で、改良版 LRU(midpoint insertion でフルテーブルスキャン汚染に抵抗)で管理し、読み書きどちらにも効く、性能の土台です。やるべきは
innodb_buffer_pool_sizeを十分な大きさに設定し、ヒット率を観察することです。 - ✅ Query Cache は「SELECT 文まるごとの結果」をキャッシュします。理論は美しいのですが、無効化の粒度が粗すぎ、グローバルロックが並行性ボトルネックになり、一字一句同じでないとヒットしないため、現実の負荷ではしばしば利より害が大きく、最終的に MySQL 8.0 で削除されました。
- ✅ その役割は、アプリケーション層キャッシュ(Redis)+ クエリ自体を速くするよいインデックスが引き継ぎました。これは二次キャッシュが Redis に置き換わったのと同じ流れで、「暗黙的・粗い粒度・低レベルに埋もれる」から「明示的・制御可能・正しいレイヤーに置く」への移行です。
前回の二次キャッシュが教えてくれたのが「キャッシュの前提が成り立たなくなった瞬間、そのリスクは利益を上回る」だとすれば、Query Cache の物語はもう一つの教訓を加えてくれます。キャッシュ設計が長続きするかどうかは、しばしばヒット時にどれだけ速いかではなく、無効化時にどれだけ痛いか、そして維持のコストがどれだけ高いかにかかっているのです。Buffer Pool が長く生き残っているのは、まさに無効化が精密でコストが制御可能だからであり、Query Cache が取り残されたのも、まさにこの 2 点で敗れたからです。
このレイヤーを理解すれば、自分でキャッシュを設計するときにもう一つ問いを立てるようになるでしょう。それはいつ無効化されるのか? 無効化のコストは何か? そしてそれは、「ヒット時にどれだけ速いか」よりも先に考え抜く価値があることが多いのです。