MySQL 8.0以降の現実とWordPress:クエリキャッシュ廃止時代におけるアプリケーション層キャッシュの極限設計
テックリードの私だ。コードレビューの場において、未だに「MySQLのクエリキャッシュが効くから大丈夫だ」といった幻想を抱いているエンジニアを見かけることがある。
目を覚ましてほしい。MySQL 8.0では、クエリキャッシュ(Query Cache)はコードベースから完全に削除された。
かつてMySQLのメモリ上にSQL文と結果セットを直接ハッシュ保存し、パースや最適化のコストをゼロにしていた黄金律は、マルチコア環境における深刻なスケーラビリティのボトルネック(特に高頻度の書き込み時におけるグローバルロックの競合)ゆえに消え去ったのだ。
この現実を前に、我々WordPressエンジニアは何をすべきか?
データベースエンジン側が担っていた「結果のキャッシュ」という責務を、WordPressアプリケーション層(オブジェクトキャッシュと独自のTransient設計)へ完全にシフトさせなければならない。
今回は、MySQL 8.0+時代において `WP_Query` の発行する重いSQLとインデックスの現実に向き合い、破綻しない高スケーラブルなキャッシュ層を構築するための設計思想と実践コードを伝授する。
—
1. なぜ `WP_Query` はスケールしないのか?(内部挙動の直視)
まずは敵を知ることから始めよう。
WordPressの `WP_Query` は非常にリッチで柔軟だが、デフォルトのままで大規模サイト(投稿数数十万件、メタデータ数百万件)に投入すると、MySQLサーバーを確実に膝をつかせる。
特に以下のクエリパターンは、インデックス設計の不備と相まってフルテーブルスキャン(あるいはそれに近いソリューション)を引き起こす。
1. `meta_query` の多用と暗黙の `JOIN`
`meta_query` でリレーション(`relation => ‘AND’`)を組むと、発行されるSQLは必要なメタキーの数だけ `wp_postmeta` をセルフ結合(`LEFT JOIN` / `INNER JOIN`)する。
2. `NOT EXISTS` や `NOT IN` の地獄
複雑な除外条件は、オプティマイザのコスト見積もりを狂わせ、一時テーブル(Temporary Table)のディスク書き込みを誘発する。
3. `SQL_CALC_FOUND_ROWS` の呪い
(※WordPress 4.6以降、`no_found_rows => true` を指定しない限りデフォルトで有効)ページネーションのために全一致行数を強制カウントするこの修飾子は、MySQL 8.0のオプティマイザに対しても容赦なくコストの高いスキャンを強要する。
現場の鉄則:DB層へのアプローチとインデックスチューニング
アプリケーションキャッシュを語る前に、大前提としてMySQL側のインデックスが最適化されていなければ話にならない。`wp_postmeta` に対しては、以下の複合インデックスが最低限の防御壁となる。
— wp_postmeta の検索効率を爆発的に高めるための複合インデックス
ALTER TABLE wp_postmeta ADD INDEX post_id_meta_key (post_id, meta_key(191));
ALTER TABLE wp_postmeta ADD INDEX meta_key_meta_value (meta_key(191), meta_value(191));
(※ `meta_value` のプレフィックス長指定は、InnoDBのインデックス長制限とパフォーマンスのバランスを取るための定石だ。)
しかし、どれほどインデックスを最適化しようとも、数千万行規模のテーブルに対する複雑なJOINとソートはCPUサイクルを確実に消費する。だからこそ、「MySQLにクエリを到達させない」ための堅牢なキャッシュ戦略が必要なのだ。
—
2. オブジェクトキャッシュの限界と「セグメント化トランジェント」の設計思想
WordPressには標準で `Transients API` が備わっているが、標準実装のままマルチサイトや高トラフィック環境で使うと、データベース(`wp_options`)への書き込み競合(Lock Contention)を引き起こす。
さらに、`WP_Query` の結果(特にポストIDの配列)をそのままキャッシュする場合、以下の2点に直面する。
- キャッシュパージの粒度問題:特定の投稿が1件更新されただけで、関連する複雑なクエリキャッシュ全破棄(Flush)するのは非効率。
- シリアライズコスト:巨大な `WP_Post` オブジェクトの配列をMemcachedやRedisに保持すると、シリアライズ/デシリアライズのオーバーヘッドが馬鹿にならない。
したがって、我々がキャッシュすべきは `WP_Post` オブジェクトそのものではなく、「条件に一致したポストIDの配列(Integer Array)」のみである。オブジェクトの実体はWordPressの内部キャッシュ(`_wp_post_type_চি` や `update_post_caches`)に任せ、IDのリストだけを最速で取得・保持する設計(ID-Caching Pattern)を採る。
—
3. 【プロダクションコード】堅牢なカスタムクエリキャッシュ実装
ここからが本題だ。
複雑なカスタムメタ条件を持つ `WP_Query` をラップし、Redis/Memcachedなどの永続的オブジェクトキャッシュ(Persistent Object Cache)と連動、さらにデータベースへのクエリ発行を極限まで抑制する実用的なクラスコンポーネントを提示する。
以下のコードは、コードレビューで「合格点」を出せる、例外処理とキャッシュ無効化のフックを備えたプロダクションコードだ。
declare(strict_types=1);
namespace App\Performance;
/
- Class Optimized_Query_Service
- WP_Query の実行結果(IDリスト)をキャッシュし、MySQL 8.0環境における
- クエリ負荷を劇的に軽減するサービスクラス。
/
final class Optimized_Query_Service {
private const CACHE_GROUP = ‘app_optimized_queries’;
private const CACHE_TTL = 3600; // 1時間
/
- キャッシュを考慮した投稿IDの取得
- @param array $args WP_Query に渡す引数
- @return int[] 投稿IDの配列
/
public static function get_cached_post_ids( array $args ): array {
// キャッシュキーの一意性を担保するため、引数をハッシュ化する
$cache_key = ‘q_’ . md5( wp_json_encode( $args ) );
// 永続オブジェクトキャッシュから取得
$post_ids = wp_cache_get( $cache_key, self::CACHE_GROUP );
if ( false !== $post_ids ) {
/
- 開発現場への注意:
- キャッシュヒット時も、WordPress内部のオブジェクトキャッシュ
- (postキャッシュ)にデータがロードされていることを保証するため、
- 必要に応じて warm_post_caches を実行する。
/
self::warm_post_caches( $post_ids );
return $post_ids;
}
// キャッシュミスの場合は強制的にIDのみを取得するよう引数を最適化
$args[‘fields’] = ‘ids’;
$args[‘no_found_rows’] = true; // SQL_CALC_FOUND_ROWS を無効化
$args[‘update_post_meta_cache’] = false;
$args[‘update_post_term_cache’] = false;
$query = new \WP_Query( $args );
$post_ids = array_map( ‘intval’, $query->posts );
// オブジェクトキャッシュにストア
wp_cache_set( $cache_key, $post_ids, self::CACHE_GROUP, self::CACHE_TTL );
// 取得したID群のポスト実体データをWordPressの内部キャッシュにロード
self::warm_post_caches( $post_ids );
return $post_ids;
}
/
- WP_Post の内部キャッシュを効率的にウォームアップする
- @param int[] $post_ids
/
private static function warm_post_caches( array $post_ids ): void {
if ( empty( $post_ids ) ) {
return;
}
// _prime_post_caches はコア関数。存在しない場合のエクスプレッション防御
if ( function_exists( ‘_prime_post_caches’ ) ) {
_prime_post_caches( $post_ids );
}
}
/
- キャッシュのパージ(無効化)
- 投稿の保存・削除時にグループ全体のキャッシュをクリアする、
- もしくはタグベースの無効化戦略と組み合わせる。
/
public static function flush_cache(): void {
// Object Cache が flush_group をサポートしている場合の処理
if ( method_exists( $GLOBALS[‘wp_object_cache’], ‘flush_group’ ) ) {
$GLOBALS[‘wp_object_cache’]->flush_group( self::CACHE_GROUP );
} else {
// フォールバック:単一キーの管理が困難な場合は、
// キャッシュグループのバージョン(Salt)をインクリメントする設計が望ましい
$version = (int) wp_cache_get( ‘cache_version’, self::CACHE_GROUP );
wp_cache_set( ‘cache_version’, $version + 1, self::CACHE_GROUP );
}
}
}
/
- フックの設定:投稿の更新・削除時にキャッシュを確実に無効化
/
add_action( ‘save_post’, function( int $post_id, \WP_Post $post, bool $update ) {
// リビジョンやオートセーブは無視
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
Optimized_Query_Service::flush_cache();
}, 10, 3 );
add_action( ‘deleted_post’, function( int $post_id ) {
Optimized_Query_Service::flush_cache();
}, 10, 1 );
—
4. テックリードからの実践的な警告とアーキテクチャの心得
上記のコードをプロダクションに投入するにあたり、シニアエンジニアとして以下の3点を厳守事項として言い残しておく。
1. `no_found_rows => true` の徹底による副作用の理解
ページネーションのトータルページ数(`max_num_pages`)が算出されなくなるため、もしフロントエンド側で「全何件中何件表示」といったUIが必要な場合は、別途COUNT用の軽量クエリをキャッシュ付きで用意するか、無限スクロール(Infinite Scroll)などのUIアーキテクチャへ転換すること。ページネーションの総数計算のために毎度重いクエリを走らせる設計は、現代のWebスケールにおいては「悪」である。
2. キャッシュスタンピード(Thundering Herd)対策
高トラフィックサイトにおいて、人気のクエリキャッシュが期限切れ(TTL切れ)を起こした瞬間に数台のWebワーカーが一斉にMySQLへ重いクエリを叩き込む現象が発生する。Redisを使用している場合は、Mutex(排他ロック)機構や、Stale-While-Revalidate戦略(期限切れ後も古いキャッシュを返しながらバックグラウンドで再生成する)をオブジェクトキャッシュプロバイダ側で有効化しておくこと。
3. データ整合性とキャッシュタグの限界
WordPressのフックベースのキャッシュクリア(`save_post`)は極めてシンプルだが、カスタムタクソノミーの紐付け変更や、別テーブルのメタデータ変更時にはトリガーされないケースがある。ドメインモデルの複雑さに応じて、キャッシュの有効期限(TTL)を短めに設定する(例: 15分〜1時間)か、更新系のアクションフックを網羅的にフックする設計の堅牢性を担保すること。
MySQL 8.0の時代、データベースは「データを安全に永続化し、ACIDを保証するストレージエンジン」に徹し、スケーラビリティとパフォーマンスの担保はアプリケーション層の知見に委ねられている。
プラグイン任せの設計を脱却し、自らの手でクエリのライフサイクルを掌握せよ。それこそが、真のプロフェッショナルエンジニアの仕事だ。