【実務・中級編】実務中級者向け:WP_Queryの「tax_query」における「relation => OR」が引き起こすクエリの複雑化と対策 – WordPress 内部コア・データベース構造とパフォーマンス最適化解析バイブル

はじめに:コードレビューの現場から

「なぜ、この記事一覧ページの表示に1.5秒もかかっているんだ?」

あなたがテクニカルリードを務めるプロジェクトのステージング環境。慢性的で重いクエリの犯人を特定すべく、Query Monitorを叩いた瞬間、見慣れた悪夢が目に飛び込んでくる。

`WP_Query` の引数に渡された、たった一つの配列。その中に潜む `relation => ‘OR’`。
生成されたSQLは、数枚のテーブルを不格好に `LEFT JOIN` し、`DISTINCT` を乱れ撃ちし、MySQLのオプティマイザを完全に沈黙させていた。

中級者へのステップアップの過程で、誰もが一度はハマる罠——それが `tax_query`(および `meta_query`)における `OR` 条件の爆発的複雑化だ。

今回は、WordPressコアの内部データベース構造とクエリ生成メカニズムを解剖し、なぜ `relation => ‘OR’` がパフォーマンスの殺人鬼となるのか、そしてそれをどうやってエレガントに、かつ堅牢に回避すべきかをロジカルに伝授する。

—

1. 内部解剖:なぜ `tax_query` の `OR` は悪なのか

まずは、WordPressが内部でどのようにクエリを組み立てているかを知る必要がある。
`WP_Query` は柔軟性の代償として、複雑な条件をSQLに翻訳する際、幾重もの `JOIN` とサブクエリを生み出す。

WordPressデフォルトのデータ構造(要所)

  • `$wpdb->posts`:投稿本体
  • `$wpdb->term_relationships`:投稿とターム(カテゴリやタグ、カスタムタクソノミー)の紐付け(中間テーブル)
  • `$wpdb->term_taxonomy`:タクソノミーとタームIDの紐付け

ここで、以下のような `tax_query` を発行したとする。

$query = new WP_Query([
‘post_type’ => ‘post’,
‘tax_query’ => [
‘relation’ => ‘OR’,
[
‘taxonomy’ => ‘genre’,
‘field’ => ‘slug’,
‘terms’ => [‘action’, ‘adventure’],
],
[
‘taxonomy’ => ‘platform’,
‘field’ => ‘slug’,
‘terms’ => [‘ps5’],
],
],
]);

SQLの実行計画(EXPLAIN)における破綻

このリクエストに対し、WordPressコア(`WP_Tax_Query` クラス)は、次のような(概念的な)SQLを生成しようと試みる。

SELECT SQL_CALC_FOUND_ROWS wp_posts.
FROM wp_posts
LEFT JOIN wp_term_relationships ON (wp_posts.ID = wp_term_relationships.object_id)
LEFT JOIN wp_term_relationships AS tr2 ON (wp_posts.ID = tr2.object_id) — 別条件のために別名JOINが追加される!
WHERE 1=1
AND (
(wp_term_relationships.term_taxonomy_id IN (12, 15))
OR
(tr2.term_taxonomy_id IN (22))
)
AND wp_posts.post_type = ‘post’
AND wp_posts.post_status = ‘publish’
GROUP BY wp_posts.ID;

このクエリの何が致命的か?

1. マルチプルJOINと行数の肥大化: 条件が増えるたびに `wp_term_relationships` が別名(Alias)で `JOIN` され、デカルト積(直積)に近い状態を作り出す。
2. `GROUP BY` と `DISTINCT` の強制: 重複行を排除するためにMySQLは一時テーブル(Temporary Table)とファイルソート(Filesort)をメモリ上(場合によってはディスク上)に強制される。
3. インデックスの不効率な利用: `OR` 条件を跨いだ複合的な条件は、MySQLのクエリオプティマイザにとってインデックスの効率的な利用を極めて困難にする(Index Mergeがうまく働かないことが多い)。

結果として、データ量が数万件を超えたあたりからクエリ応答時間は数百ミリ秒から数秒へと跳ね上がり、CPU使用率は天井を突く。

—

2. 対策:`UNION` アプローチによるクエリの垂直分割

では、どうすべきか?
答えの一つは、「一つの複雑なクエリで解決しようとせず、シンプルなクエリを複数発行して `UNION` する」 もしくは 「PHP側/DB側でIDの集合(Set)として統合する」 ことだ。

特に、検索や絞り込み画面において `OR` 条件で異なるタクソノミーやメタデータを結合する場合、`WP_Query` を直列に実行してIDをマージする方が、MySQLの結合地獄を回避できるため圧倒的に高速なケースが多い。

プロダクションコード例:IDマージによる高速化パターン

以下のコードは、実務の現場で安全に動作し、かつメンテナンス性の高いカスタムクエリ実行クラスの設計パターンである。

  • Class OptimizedPostRepository
  • 複雑な tax_query (OR) を回避し、高速なID取得とキャッシュを担保するリポジトリ
  • /
    class OptimizedPostRepository {

    /

    • 指定された複数のタクソノミー条件(OR)に合致する投稿IDを取得し、WP_Queryを実行する
    • @param array $genre_slugs
    • @param array $platform_slugs
    • @param int $per_page
    • @param int $paged
    • @return WP_Query

    /
    public function get_posts_by_tax_or(array $genre_slugs, array $platform_slugs, int $per_page = 10, int $paged = 1): WP_Query {
    // 各条件ごとに極めてシンプルでインデックスが効きやすいWP_Queryを個別に実行する
    $post_ids_1 = [];
    if (!empty($genre_slugs)) {
    $q1 = new WP_Query([
    ‘post_type’ => ‘post’,
    ‘post_status’ => ‘publish’,
    ‘fields’ => ‘ids’, // IDのみ取得することでメモリ消費を極限まで抑制
    ‘posts_per_page’ => -1,
    ‘tax_query’ => [
    [
    ‘taxonomy’ => ‘genre’,
    ‘field’ => ‘slug’,
    ‘terms’ => $genre_slugs,
    ],
    ],
    ]);
    $post_ids_1 = $q1->posts;
    }

    $post_ids_2 = [];
    if (!empty($platform_slugs)) {
    $q2 = new WP_Query([
    ‘post_type’ => ‘post’,
    ‘post_status’ => ‘publish’,
    ‘fields’ => ‘ids’,
    ‘posts_per_page’ => -1,
    ‘tax_query’ => [
    [
    ‘taxonomy’ => ‘platform’,
    ‘field’ => ‘slug’,
    ‘terms’ => $platform_slugs,
    ],
    ],
    ]);
    $post_ids_2 = $q2->posts;
    }

    // PHP側で合集合(UNION / OR相当)を計算し、重複を排除
    $merged_ids = array_unique(array_merge($post_ids_1, $post_ids_2));

    // 条件にヒットするものがゼロの場合は、空のWP_Queryを返す(無駄なクエリを走らせない)
    if (empty($merged_ids)) {
    return new WP_Query([‘post__in’ => [0]]);
    }

    // ページネーションを正しく機能させるために、統合されたID配列からスライスを切り出す
    // ※ 大量件数で memory_limit が懸念される場合はSQLの UNION を直接書くかTransientを使うこと
    $offset = ($paged – 1) $per_page;
    $paged_ids = array_slice($merged_ids, $offset, $per_page);

    // 最終的な投稿オブジェクトの取得(ここで初めてwp_postsやpostmetaのJOINが発生する)
    return new WP_Query([
    ‘post_type’ => ‘post’,
    ‘post_status’ => ‘publish’,
    ‘post__in’ => $paged_ids,
    ‘orderby’ => ‘post__in’, // IDの順序を保持
    ‘posts_per_page’ => count($paged_ids),
    ‘ignore_sticky_posts’ => true,
    // 既にIDが特定されているため、不要なメタキャッシュやタームキャッシュのロードを制御可能なら最適化
    ]);
    }
    }

    —

    3. さらに踏み込む:`post__in` のパフォーマンス特性とDBインデックスの最適化

    上記のコードで登場した `’post__in’ => $paged_ids`。
    ここで注意しなければならないのは、WordPressの `post__in` は内部で `ORDER BY FIELD(wp_posts.ID, …)` というSQL句を生成する点だ。

    もし `$paged_ids` に数千件ものIDを渡してしまうと、MySQLはフィールド値の比較と並び替えに苦しみ、やはりパフォーマンスが劣化する。そのため、上記のコード例のように 「あらかじめPHP側、あるいはDB側でページネーションに必要な最小限のID群(例: 10件〜20件)に絞り込んでから `post__in` を渡す」 という設計が極めて重要になる。

    データベース層でのインデックス確認

    WordPress標準のテーブル設計であっても、以下のインデックスが正しく貼られているかをDBA(またはインフラ担当)と共に確認すべきだ。

    1. `wp_term_relationships` テーブルの複合インデックス:

    • `(object_id, term_taxonomy_id)` はプライマリキーとして存在するが、逆引き(`term_taxonomy_id` を起点とした検索)を行う場合、`term_taxonomy_id` 単体のインデックス、または `(term_taxonomy_id, object_id)` のインデックスがないと、大規模データ時にフルスキャンが発生する。

    —

    4. テクニカルリードからの提言:設計フェーズでの判断基準

    コードレビューで `tax_query` の `relation => ‘OR’` を見かけたら、以下のフローでリファクタリングを促してほしい。

    1. 要件の再定義: その `OR` 条件は、本当に同じタクソノミー内のターム同士か?(同じタクソノミー内であれば、単に `terms` 配列に複数指定するだけで、WordPressコアが適切に `IN` 句へと変換するため、`relation => ‘OR’` は不要で高速に処理される)。
    2. 異種タクソノミー間のORか?: カテゴリA または タグB、といった異なるタクソノミーをまたぐ `OR` 条件である場合、前述のクエリ複雑化が確実に発生する。
    3. スケールの見積もり: 対象の投稿数が1万件を超えることが確実なシステムであれば、最初から `WP_Query` の複雑なネストを避け、カスタムSQLによる `UNION` クエリの直書き、もしくは Elasticsearch / Algolia などの外部検索エンジン、あるいはカスタムテーブル設計(リレーショナルな正規化テーブル)への移行をアーキテクトとして決断すべきだ。

    まとめ

    WordPressは「手軽にブログを作れるCMS」から「堅牢なエンタープライズWebアプリケーション基盤」へと進化を遂げた。しかし、その内部構造の抽象化の裏側にあるコストを理解せずにフレームワークの機能を安易に呼び出すと、データベースは容易に悲鳴を上げる。

    「なぜこのSQLが発行されるのか」を常に頭の中でダンプし、オプティマイザの立場に立ったコードを書くこと。それこそが、プロフェッショナルなWordPressエンジニアの条件である。

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