WordPressを掌握する極限の知見:WP_Queryキャッシュ汚染の完全撲滅と堅牢なトランジェント設計
テックリードの私だ。コードレビューの際、「`WP_Query`の負荷が高いから、とりあえず結果をTransients APIでキャッシュしよう」という安易なプルリクエストを見かけるたびに、私は冷や汗が出る。
君たちは本当に理解して実装しているか?
「データが更新されたのに古い情報が表示される」「条件分岐の組み合わせが爆発してキャッシュが肥大化する」「プレビューや非公開投稿がパブリックに見えてしまう」――これらはすべて、キャッシュ汚染(Cache Pollution)の典型的な症状だ。
今回は、WordPressのデータベース構造とフックの実行順序を完全にハックし、実務で絶対に破綻しない堅牢なクエリキャッシュの設計パターンを授ける。
—
1. なぜ `WP_Query` のキャッシュは汚染されるのか?
まず、敵を知ることから始めよう。`WP_Query` は発行されるSQLが非常に複雑であり、メタデータのJOINや用語(Term)のリレーションシップなど、多大なデータベースリソースを消費する。
これをTransients APIで保存する際、以下の3つのトラップが必ずエンジニアの牙をむく。
1. キャッシュキーの次元崩壊(Key Collision)
ページネーション、ソート順、カスタムフィールドのクエリなど、変数が変わるたびにキーを動的に生成し損ねると、ユーザーAの画面にユーザーBの検索結果が表示される地獄が起きる。
2. 無効化漏れ(Stale Cache)
投稿が「更新」されたとき、あるいは「コメントが承認」されたとき、関連するすべてのクエリキャッシュを正確にパージ(削除)しなければ、データは整合性を失う。
3. ステートの混入(Context Pollution)
管理画面、プレビューモード、REST API経由のアクセスなど、フロントエンドと異なるコンテキストでキャッシュが生成・保存され、一般ユーザーに非公開データが露出する。
—
2. 堅牢なキャッシュ設計の3大原則
プロダクション環境で耐えうるキャッシュレイヤーを構築するためには、以下の原則をコードに強制させなければならない。
- 原則A: キャッシュキーは「クエリ変数の正規化ハッシュ」で作れ
- 原則B: キャッシュの保存と破棄は「イベント駆動(フック)」で完全に同期させろ
- 原則C: キャッシュ対象は「公開済みのフロントエンドコンテキスト」に限定せよ
—
3. 【実装例】プロダクション品質のキャッシュラッパー・クラス
抽象論はここまでだ。ここからは、実務の現場でそのままコピペして使える、堅牢なクラス設計を示す。
このコードでは、シリアライズされた `WP_Query` の引数から一意かつ安全なハッシュを生成し、適切なフックで確実にキャッシュをパージする仕組みを実装している。
/
if ( ! defined( ‘ABSPATH’ ) ) {
exit;
}
class Advanced_Query_Cache {
private const CACHE_PREFIX = ‘aqc_q_’;
private const CACHE_GROUP = ‘advanced_query_cache’;
/
- 初期化:フックの登録
/
public static function init(): void {
// 投稿の保存・削除時にキャッシュをクリア
add_action( ‘save_post’, [ self::class, ‘purge_cache_on_post_save’ ], 10, 3 );
add_action( ‘deleted_post’, [ self::class, ‘purge_cache_on_delete’ ], 10, 1 );
// Termの更新時にも対応する場合
add_action( ‘edited_terms’, [ self::class, ‘purge_cache_global’ ], 10, 0 );
}
/
- キャッシュを考慮したWP_Queryのラッパーメソッド
- @param array $args WP_Queryの引数
- @return WP_Query
/
public static function get_query( array $args ): WP_Query {
// 原則C: 管理画面やプレビューではキャッシュバイパス
if ( is_admin() || is_preview() || ( defined( ‘REST_REQUEST’ ) && REST_REQUEST ) ) {
return new WP_Query( $args );
}
// 原則A: 引数を正規化して一意のキャッシュキーを生成
$cache_key = self::generate_cache_key( $args );
// Transients API (またはobject cache) から取得
$cached_data = get_transient( $cache_key );
if ( false !== $cached_data && is_array( $cached_data ) ) {
// キャッシュヒット時は投稿IDの配列からWP_Queryを再構築(メモリ効率の最適化)
$query = new WP_Query();
$query->posts = array_map( ‘get_post’, $cached_data[‘post_ids’] );
$query->post_count = count( $query->posts );
$query->found_posts = $cached_data[‘found_posts’];
$query->max_num_pages = $cached_data[‘max_num_pages’];
$query->queried_object = $cached_data[‘queried_object’];
$query->queried_object_id = $cached_data[‘queried_object_id’];
// クエリフラグの復元
$query->is_home = $cached_data[‘is_home’];
$query->is_archive = $cached_data[‘is_archive’];
// 必要に応じて他のプロパティも復元
return $query;
}
// キャッシュミス:通常のクエリ実行
$query = new WP_Query( $args );
// データベース負荷を軽減するため、重いオブジェクトではなく必要最小限のIDとメタデータを保存
$cache_payload = [
‘post_ids’ => wp_list_pluck( $query->posts, ‘ID’ ),
‘found_posts’ => $query->found_posts,
‘max_num_pages’ => $query->max_num_pages,
‘queried_object’ => $query->queried_object,
‘queried_object_id’ => $query->queried_object_id,
‘is_home’ => $query->is_home,
‘is_archive’ => $query->is_archive,
];
// 寿命は24時間、またはサイトの更新頻度に応じて調整
set_transient( $cache_key, $cache_payload, DAY_IN_SECONDS );
return $query;
}
/
- 引数配列からMD5ハッシュを生成し、キーの衝突を防ぐ
/
private static function generate_cache_key( array $args ): string {
// 外部からの意図しない変数混入を防ぐためソートしてシリアライズ
ksort( $args );
return self::CACHE_PREFIX . md5( serialize( $args ) );
}
/
- 原則B: 投稿保存時のキャッシュパージ
- ※トランジェント自体の完全なワイルドカード削除は標準APIではないため、
- タグやカテゴリに基づいたプレフィックス管理、またはオブジェクトキャッシュのグループフラッシュを用いる。
/
public static function purge_cache_on_post_save( int $post_id, WP_Post $post, bool $update ): void {
// リビジョンやオートセーブは無視
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
// 実際のプロダクションでは、ここでトランジェントを削除するか、
// データベースのカスタムテーブル/オプションにキャッシュバージョンのタイムスタンプを持ち、
// キー自体をインクリメントする手法(Cache Versioning)を推奨する。
self::increment_cache_version();
}
public static function purge_cache_on_delete( int $post_id ): void {
self::increment_cache_version();
}
public static function purge_cache_global(): void {
self::increment_cache_version();
}
/
- キャッシュバージョンをインクリメントすることで、
- 個別のトランジェントを削除するコストを回避し、一瞬で全キャッシュを無効化する
/
private static function increment_cache_version(): void {
$version = (int) get_option( ‘aqc_cache_version’, 1 );
update_option( ‘aqc_cache_version’, $version + 1, false );
}
/
- バージョンを考慮したキャッシュキー生成の改良版(実務推奨)
/
private static function generate_cache_key_with_version( array $args ): string {
ksort( $args );
$version = get_option( ‘aqc_cache_version’, 1 );
return self::CACHE_PREFIX . $version . ‘_’ . md5( serialize( $args ) );
}
}
// 初期化実行
Advanced_Query_Cache::init();
—
4. コードレビューの視点:なぜこの実装が優れているのか?
私のチームにこのコードが提出されたら、私は以下のポイントを高く評価する。
① `WP_Query` オブジェクトを丸ごとキャッシュしない理由
オブジェクト全体(投稿のフルデータ、メタデータキャッシュなど)をシリアライズしてTransientsに保存すると、メモリ消費量が肥大化し、MemcachedやRedisのメモリ上限を圧迫する原因になる。
このコードでは「投稿IDの配列」だけを保存し、描画時に `get_post()` で必要最低限のオブジェクトを復元する設計(Lazy Loading的なアプローチ)をとっている。これによりキャッシュサイズを劇的に小さく抑えている。
② キャッシュバージョニング(Cache Versioning)によるパージ問題の解決
WordPressのTransients APIは、特定のプレフィックスを持つキーの一括削除(ワイルドカード削除)をネイティブサポートしていない(MySQLのトランジェントストレージに依存するため)。
個別のキーをすべて追跡して削除するのはバグの温床だ。ここで導入している「キャッシュバージョンをインクリメントする手法」は、オプション値(整数)を1つ更新するだけで、実質的にすべての旧クエリキャッシュを一瞬で無効化できる。これは大規模サイトにおける鉄板の設計パターンである。
③ コンテキストの厳格な分離
管理画面(`is_admin()`)、プレビュー(`is_preview()`)、REST APIリクエストを完全にバイパスしている。これにより、「管理者が下書きをプレビューしている状態」のクエリ結果がキャッシュされ、一般ユーザーに下書きが露出するという致命的なセキュリティインシデントを構造的に防いでいる。
—
5. さらなる高みへ:Object Cache (Redis/Memcached) との併用
もし君たちのインフラ環境が Redis や Memcached を導入しているのであれば、Transients APIの背後で内部的にオブジェクトキャッシュが動くため、データベース(`wp_options` テーブル)への負荷はゼロになる。
だが、どれほどインフラが優秀であれ、「アプリケーション層でのキー設計と無効化ロジックの正確性」が欠けていれば、システムはいずれ破綻する。
コードを書くときは常に自問しろ。
「このキャッシュは、データが更新された瞬間に、誰の手を煩わせることなく消え去るか?」
「このキャッシュキーは、リクエストの文脈を完全に識別できているか?」
この問いに淀みなく答えられるエンジニアだけが、真にWordPressを掌握する者だ。実装に入れ。