【テクニカル・上級編】上級プロフェッショナル向け:WP_Queryの実行フローにおける「SQLキャッシュ」の有効範囲と限界の深掘り – WordPress 内部コア・データベース構造とパフォーマンス最適化解析バイブル

1. RDBMS層の「幻影」とWP_Queryの宿命

大規模WordPressサイトのパフォーマンス障害を解析する際、未だに「MySQL側のクエリキャッシュ(Query Cache)が効いているから大丈夫だ」あるいは「データベースサーバーのスペックを上げれば解決する」という誤謬に囚われているエンジニアに遭遇することがある。

結論から述べよう。リレーショナルデータベース(RDBMS)レイヤにおけるクエリキャッシュは、WordPressのような高頻度で更新と読み取りが混在する動的CMSにおいては無力であるばかりか、深刻なスループット低下のボトルネックと化す。 MySQL 8.0でQuery Cacheが完全にコードベースから削除されたのは、分散・高並行処理アーキテクチャにおける必然の帰結であった。

本稿では、MySQLストレージエンジンの低レイヤマクロ挙動と、WordPressコアにおける`WP_Query`の実行パイプラインをトレースし、なぜRDBMS層のキャッシュが破綻するのか、そしてアプリケーションレイヤ(`WP_Object_Cache`)でいかにしてクエリを抽象化・局所化し、RDBMSを「単なる永続化KVS」へと昇華させるべきかを解説する。

—

2. MySQL Query Cacheの構造的破綻とWordPressワークロード

MySQL 5.7以前に存在したQuery Cache(QC)の基本構造は、入力されたSQL文字列のハッシュ値をキーとし、シリアライズされた結果セットをバッファ上に保持する極めて単純な機構であった。

[ Incoming SQL Query ]
│
▼
[ Hash Computation (MD5-like) ] ──(Exact String Match)──► [ Query Cache Memory ]
│ │
(Miss) (Hit)
│ │
▼ ▼
[ Parser -> Optimizer -> InnoDB ] [ Return Resultset ]

このアーキテクチャがWordPressのワークロード下で破滅的な結果をもたらす理由は3点に集約される。

① テーブル無効化の連鎖(Invalidation Storm)

Query Cacheはテーブル単位でキャッシュを無効化する。`wp_posts` テーブルに1行でも `UPDATE`(コメントカウントの更新、閲覧数のインクリメント、下書き保存、Cronによる状態遷移)が発生した瞬間、そのテーブルを参照しているすべてのQuery Cache領域がグローバルミューテックスを取得して即座にパージされる。

② グローバルロック競合(`query_cache_mutex` Contention)

マルチコアCPU環境において、クエリを検証・格納・破棄するたびに `query_cache_mutex` の排他ロックが要求される。同時並行リクエスト数が数百に達した際、クエリのパース処理そのものよりも、このミューテックスの獲得待ち(Lock Contention)によってCPU使用率が100%に張り付き、システム全体がスタベーションに陥る。

③ InnoDB Buffer Poolとの決定的な差異

MySQLのパフォーマンスの根幹は InnoDB Buffer Pool にある。Buffer Poolはデータページ(16KB単位)およびインデックスページをメモリ上に保持する機構であり、行の更新が発生してもダーティページとして非同期にフラッシュされるため、読み取りと書き込みのロック分離がMVCC(多版型同時実行制御)によって実現されている。

Query Cacheの破棄は、Buffer PoolのMVCCをバイパスしてグローバルな同期待ちを強制する。すなわち、RDBMSに「クエリ結果」をキャッシュさせる設計思想そのものが、ストレージエンジンのアーキテクチャと根本的に衝突していたのである。

—

3. WP_Query 内部実行パイプラインの解剖

アプリケーション層でのキャッシュ戦略を論じる前に、`WP_Query::get_posts()` が内部でどのようにSQLをビルドし、実行しているかを正しく把握しなければならない。

WP_Query::get_posts()
│
├── 1. parse_query()
│ └── クエリ引数の正規化とデフォルト値の補完
│
├── 2. do_action_ref_array( ‘pre_get_posts’, … )
│ └── 実行前フック(パラメータ改変の最終ポイント)
│
├── 3. posts_clauses / posts_where / … フィルター群
│ └── 生SQLフラグメント(JOIN, WHERE, ORDER BY)の構築
│
├── 4. apply_filters_ref_array( ‘posts_pre_query’, … )
│ └── 【超重要】RDBMS完全バイパスのインターセプトポイント
│
├── 5. $wpdb->get_results( $this->request )
│ └── RDBMSへSQL送信・生データのフェッチ
│
└── 6. Hydration & Prime Caches
├── update_post_caches()
├── update_post_meta_cache()
└── update_object_term_cache()

パフォーマンスを著しく毀損する2大アンチパターン

1. `SQL_CALC_FOUND_ROWS` によるインデックススキャン無効化

ページネーションを行う際、WordPressはデフォルトで `SQL_CALC_FOUND_ROWS` をクエリに付与していた(WordPress 6.0以前、または明示的な引数指定時)。これにより、MySQLは `LIMIT` 句による早期脱出(Early Exit Optimization)を放棄し、テーブル全件またはインデックス全件のトラバースを強制される。

— 最悪の実行計画を引き起こす典型例
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
WHERE wp_posts.post_type = ‘post’
AND wp_posts.post_status = ‘publish’
ORDER BY wp_posts.post_date DESC
LIMIT 0, 10;

SELECT FOUND_ROWS();

※ MySQL 8.0.17以降、`SQL_CALC_FOUND_ROWS` は非推奨(Deprecated)となっており、オプティマイザの最適化パスを著しく阻害する。

2. 未完全なハイドレーションによるN+1クエリ病

`’fields’ => ‘ids’` を指定してクエリを軽量化したつもりになっても、その後のループ内で `get_post_meta()` や `get_the_terms()` をコールした瞬間に個別クエリが大量発行されるケースがある。これを防ぐには、後述のキャッシュプライミング戦略が必須となる。

—

4. WordPress 6.1以降のクエリキャッシング機構

WordPress 6.1において、コアの `WP_Query` に歴史的なリファクタリングが施された。それが 「クエリ結果(ID配列)自体のオブジェクトキャッシュへの自動格納」 である。

現在、`WP_Query` は外部オブジェクトキャッシュ(Redis / Memcached)が有効な環境において、以下のフローで自動的にキャッシュを探索する。

キャッシュキーの生成メカニズム

// WordPress Core (wp-includes/class-wp-query.php) の内部ロジック概念
$query_vars_hash = md5( serialize( $this->query_vars ) );
$cache_key = “wp_query:$query_vars_hash:” . wp_cache_get_last_changed( ‘posts’ );

ここで注目すべきは `wp_cache_get_last_changed( ‘posts’ )` である。
投稿が1件でも更新・追加されると、`posts` グループの `last_changed` マイクロ秒タイムスタンプが更新される。これにより、Redis上のキー全体を走査してパージ(`KEYS` や `SCAN`)することなく、キーのプレフィックスを変更することで論理的に過去のキャッシュを一括無効化している。

完全なキャッシュパイプラインの挙動

1. クエリキーの計算: 引数配列(`query_vars`)と `last_changed` から一意なハッシュを生成。
2. IDの取得: キャッシュヒット時はDBへ行かず、メモリからPost IDの配列(例: `[102, 105, 110]`)を取得。
3. オブジェクトのバルクハイドレーション:
取得したID群を元に、`_prime_post_caches()` が `post`, `postmeta`, `term` キャッシュを一括でフェッチ(Redisの `MGET` 相当)。

これにより、RDBMSへの接続とクエリ実行を O(1) のメモリアクセス に変換している。

—

5. 極限のチューニング:`posts_pre_query` による完全バイパス実装

標準の `WP_Query` キャッシュ機構は安全側に倒されているため、`last_changed` の変更によって無効化頻度が高くなりすぎる場合がある。エンタープライズ環境(例:秒間数万リクエストのメディアサイト、APIサーバー)では、特定のスロークエリをRDBMSから完全に隔離し、カスタムのRedisキャッシュ層へルーティングする必要がある。

以下は、`posts_pre_query` フィルターを用いてSQLパースすら発生させずにクエリを解決する、プロダクションレディな最適化コードである。

  • WP_Queryの内部実行を遮断し、高速ストレージからハイドレーションする
    • @param WP_Post[]|int[]|null $posts
    • @param WP_Query $query
    • @return WP_Post[]|int[]|null

    /
    public static function intercept_query( ?array $posts, WP_Query $query ): ?array
    {
    // 管理画面、メインクエリ、プレビュー、ミューテーションクエリはDB直通とする
    if ( is_admin() || $query->is_main_query() || $query->is_preview() || ! empty( $query->query_vars[‘suppress_filters’] ) ) {
    return $posts;
    }

    // 明示的に最適化フラグが立っているカスタムクエリのみを対象化
    if ( empty( $query->query_vars[‘enterprise_cache’] ) ) {
    return $posts;
    }

    // 1. クエリの一意なシグネチャを生成(ソート順・ページ番号・Taxクエリを網羅)
    $cache_key = self::generate_cache_key( $query->query_vars );

    // 2. 高速なインメモリストアからPost ID配列を照会
    $cached_data = wp_cache_get( $cache_key, self::CACHE_GROUP );

    if ( false !== $cached_data && is_array( $cached_data ) ) {
    [ ‘ids’ => $post_ids, ‘total’ => $total_posts, ‘max_pages’ => $max_pages ] = $cached_data;

    // ページネーション用プロパティの補完
    $query->found_posts = (int) $total_posts;
    $query->max_num_pages = (int) $max_pages;

    if ( empty( $post_ids ) ) {
    return [];
    }

    // 3. Postオブジェクト、メタ、タクソノミーを一括プライミング(バルクフェッチ)
    _prime_post_caches( $post_ids, true, true );

    // 4. ‘fields’ 指定に応じた形式でリターン
    if ( ‘ids’ === $query->get( ‘fields’ ) ) {
    return array_map( ‘intval’, $post_ids );
    }

    // オブジェクト配列を復元して返却
    $post_objects = [];
    foreach ( $post_ids as $id ) {
    $post = get_post( $id );
    if ( $post instanceof WP_Post ) {
    $post_objects[] = $post;
    }
    }

    return $post_objects;
    }

    // キャッシュミス時はnullを返し、標準のWP_Queryパイプラインへフォールバックさせる
    // ただし、DBクエリ完了後にキャッシュするためのフックを登録
    add_filter( ‘posts_results’, function( array $results, WP_Query $executed_query ) use ( $cache_key, $query ) {
    // 同一インスタンスの検証
    if ( $executed_query === $query ) {
    $ids = wp_list_pluck( $results, ‘ID’ );

    $payload = [
    ‘ids’ => $ids,
    ‘total’ => $executed_query->found_posts,
    ‘max_pages’ => $executed_query->max_num_pages,
    ];

    // 算出した結果セットをアプリケーションキャッシュへ格納
    wp_cache_set( $cache_key, $payload, self::CACHE_GROUP, self::CACHE_TTL );
    }
    return $results;
    }, 10, 2 );

    return null;
    }

    /

    • クエリ引数から完全決定論的なキャッシュキーを生成する

    /
    private static function generate_cache_key( array $query_vars ): string
    {
    // 非決定的なパラメータや不要な内部変数を排除
    ksort( $query_vars );
    $serialized = serialize( $query_vars );

    return ‘ep_q_’ . hash( ‘xxh64’, $serialized );
    }
    }

    HighPerformanceQueryBypass::init();

    呼び出し側のコード例

    // アプリケーションレイヤでの呼び出し
    $query = new \WP_Query([
    ‘post_type’ => ‘product’,
    ‘post_status’ => ‘publish’,
    ‘posts_per_page’ => 20,
    ‘tax_query’ => [
    [
    ‘taxonomy’ => ‘product_cat’,
    ‘field’ => ‘slug’,
    ‘terms’ => ‘electronics’,
    ],
    ],
    ‘no_found_rows’ => true, // カウントクエリを抑止(最適化の基本)
    ‘enterprise_cache’ => true, // 自作のバイパスインターセプターをトリガー
    ]);

    if ( $query->have_posts() ) {
    while ( $query->have_posts() ) {
    $query->the_post();
    // 既に _prime_post_caches されているため、DBアクセスゼロで実行
    the_title();
    }
    wp_reset_postdata();
    }

    —

    6. キャッシュヒット時の内部レイテンシ比較

    上記のアプローチを適用した場合の各レイヤにおけるレイテンシプロファイルは以下のようになる。

    | 実行パス | 到達レイヤ | 想定レイテンシ (p99) | スループット限界要因 |
    | :— | :— | :— | :— |
    | MySQLクエリキャッシュ (Legacy) | RDBMS Parser / Lock | 15ms 〜 120ms (ロック競合時) | `query_cache_mutex` による直列化 |
    | MySQL Buffer Pool ヒット | InnoDB Engine | 2ms 〜 8ms | コネクションプール数、CPUコア数 |
    | WP_Query 標準キャッシュ (Redis) | WP Core + Redis | 0.8ms 〜 2ms | ネットワークI/O(Unix Domain Socket推奨) |
    | `posts_pre_query` 完全バイパス | Memory / Local APU | 0.1ms 〜 0.4ms | PHPランタイム(Zend VM)のメモリ帯域 |

    —

    7. アーキテクトが持つべき設計思想

    データベースは「演算器」ではなく「永続化層」として扱うべきである。

    複雑な `JOIN` や `GROUP BY`、`ORDER BY RAND()` をMySQLに丸投げし、それをRDBMSのキャッシュに期待する設計は、トラフィックの増大とともに必ず限界を迎える。

    1. クエリはIDのみを引く単純な主キー走査に帰着させる。
    2. リレーションの解決とハイドレーションはアプリケーション層(PHP/Redis)で完結させる。
    3. `WP_Query` のフックチェーンを掌握し、高負荷クエリはSQLをパースさせる前にインターセプトする。

    この3原則を徹底することこそが、月間数億PVを捌く超大規模WordPressインフラを構築するための唯一の道である。

    タイトルとURLをコピーしました