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

WordPressというブラックボックスを解剖し、その「抽象化の罠」からシステムを救い出す。

私はこれまで、数え切れないほどのWordPressサイトのパフォーマンスチューニングに携わってきた。そこで目にする最も典型的な、そして最も致命的な設計ミスの一つが、`WP_Query`における`tax_query`の「安易なOR結合」だ。

「やりたいことが複雑だから、クエリも複雑になるのは仕方ない」——もし君がそう考えているなら、エンジニアとしての視点をアップデートする必要がある。RDBMS(MySQL/MariaDB)の挙動を無視した抽象化レイヤーへの依存は、データ量が増加した瞬間にシステムを沈没させる。

今日は、中級者が陥りやすい`tax_query`の`relation => OR`が引き起こすパフォーマンス劣化の正体と、それを回避して「定数時間」に近いレスポンスを維持するための設計パターンを伝授しよう。

—

1. `relation => OR` が発行する「最悪のSQL」を解剖する

まず、私たちが安易に書く以下のコードが、内部でどのような怪物を生み出しているかを見てみよう。

// カテゴリA または タグB に属する記事を取得したい
$args = [
‘post_type’ => ‘post’,
‘tax_query’ => [
‘relation’ => ‘OR’,
[
‘taxonomy’ => ‘category’,
‘field’ => ‘slug’,
‘terms’ => ‘category-a’,
],
[
‘taxonomy’ => ‘post_tag’,
‘field’ => ‘slug’,
‘terms’ => ‘tag-b’,
],
],
];
$query = new WP_Query($args);

WordPressはこのコードを受け取ると、`WP_Tax_Query`クラスを通じて以下のようなSQLを生成する(簡略化している)。

SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
LEFT JOIN wp_term_relationships AS tt1 ON (wp_posts.ID = tt1.object_id)
LEFT JOIN wp_term_relationships AS tt2 ON (wp_posts.ID = tt2.object_id)
WHERE 1=1
AND (
tt1.term_taxonomy_id IN (10) — category-aのID
OR
tt2.term_taxonomy_id IN (25) — tag-bのID
)
AND wp_posts.post_type = ‘post’
GROUP BY wp_posts.ID
ORDER BY wp_posts.post_date DESC
LIMIT 0, 10;

なぜこれが遅いのか?

1. Multiple JOINs: `OR`条件を満たすために、WordPressは`wp_term_relationships`テーブルを何度も(条件の数だけ)JOINする。
2. LEFT JOIN の強制: `AND`結合であれば`INNER JOIN`で済むが、`OR`の場合は「どちらか一方が存在しない」可能性を考慮し、オプティマイザが非効率な実行計画を立てやすくなる。
3. Temporary Table & Filesort: 重複を排除するための`GROUP BY`や、巨大な結合結果に対する`ORDER BY`が発生し、MySQLはメモリ上に一時テーブルを作成、最悪の場合はディスクI/Oが発生する。

データ件数が数万件を超えた時、このクエリの実行時間は指数関数的に増大する。

—

2. 解決策:アプリケーションレイヤーでの「クエリ分割」と「IDマージ」

プロの現場では、複雑な一つのSQLで解決しようとせず、「単純で高速なクエリを複数投げ、PHP側で結果をマージする」手法を取る。これが最も確実で、キャッシュの恩恵も受けやすい。

実装パターン:IDマージ・ストラテジー

/

  • 高速なOR検索を実現するためのマージクエリ

/
function get_posts_by_tax_or_optimized($cat_slug, $tag_slug, $limit = 10) {
// 1. 各条件を「IDのみ取得」の軽量クエリで個別に実行
// これにより、WPの内部キャッシュ(Object Cache)を最大限活用できる
$base_args = [
‘post_type’ => ‘post’,
‘post_status’ => ‘publish’,
‘fields’ => ‘ids’, // ここが重要:IDのみ取得
‘no_found_rows’ => true, // ページネーション不要ならカウントをスキップ
‘update_post_meta_cache’ => false,
‘update_post_term_cache’ => false,
‘posts_per_page’ => 100, // マージ用に多めに取得
];

$query_a_args = array_merge($base_args, [
‘category_name’ => $cat_slug,
]);

$query_b_args = array_merge($base_args, [
‘tag’ => $tag_slug,
]);

$ids_a = get_posts($query_a_args);
$ids_b = get_posts($query_b_args);

// 2. PHP側でIDをマージして重複を排除
$merged_ids = array_unique(array_merge($ids_a, $ids_b));

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

// 3. 最終的なメインクエリを実行(post__in を使用)
// プライマリキーによる検索になるため、JOINが発生せず極めて高速
return new WP_Query([
‘post_type’ => ‘post’,
‘post__in’ => $merged_ids,
‘orderby’ => ‘post_date’,
‘order’ => ‘DESC’,
‘posts_per_page’ => $limit,
]);
}

この設計のメリット

  • インデックスの最適活用: `post__in`(プライマリキー検索)は、MySQLが最も得意とする処理だ。
  • キャッシュの粒度: 個別のクエリ結果を `wp_cache_get` 等で個別にキャッシュできるため、一部のデータが更新されても他のキャッシュが生き残る。
  • 実行計画の安定性: 複雑なJOINが消え、スロークエリログにこのコードが現れることはなくなるだろう。

—

3. 究極の最適化:タクソノミー・シャドウイング

もし、この「OR検索」が頻繁に発生し、かつ検索対象のタクソノミーが固定されているなら、データベース構造自体を「検索用」に正規化することを検討すべきだ。これを私は「タクソノミー・シャドウイング」と呼んでいる。

設計コンセプト

検索用の隠しタクソノミー(例:`combined_search_tax`)を用意し、保存時(`save_post`フック)に、関連するカテゴリ名やタグ名をすべてそのタクソノミーのタームとしてコピーしてしまう。

/

  • 保存時に検索用タームを同期する

/
add_action(‘save_post’, function($post_id) {
if (get_post_type($post_id) !== ‘post’) return;

$categories = wp_get_post_categories($post_id, [‘fields’ => ‘all’]);
$tags = wp_get_post_tags($post_id, [‘fields’ => ‘all’]);

$search_terms = [];
foreach ($categories as $cat) $search_terms[] = “cat_{$cat->slug}”;
foreach ($tags as $tag) $search_terms[] = “tag_{$tag->slug}”;

// 隠しタクソノミー ‘shadow_tax’ に全ての情報を集約
wp_set_object_terms($post_id, $search_terms, ‘shadow_tax’);
}, 10, 1);

こうすることで、フロントエンドでの検索は `relation => OR` を一切使わず、単一のタクソノミーに対する `IN` 演算だけで完結する。

// 最適化後のクエリ:JOINは1回、WHEREは単純なIN句
$args = [
‘tax_query’ => [
[
‘taxonomy’ => ‘shadow_tax’,
‘field’ => ‘slug’,
‘terms’ => [‘cat_category-a’, ‘tag_tag-b’],
‘operator’ => ‘IN’,
],
],
];

—

4. テクニカルリードからのアドバイス

「WordPressの関数が用意されているから」という理由は、パフォーマンスを犠牲にしていい理由にはならない。`WP_Query`は万能なツールだが、内部的には複雑なSQLビルダーに過ぎない。

実務で意識すべきは以下の3点だ。

1. SQLを見ろ: `echo $query->request;` を実行し、生成されたSQLを `EXPLAIN` にかけろ。
2. JOINを恐れろ: 結合が増えるほど、MySQLのオプティマイザが迷走する確率は上がる。
3. データ構造で解決しろ: ロジック(PHP)で解決するより、データ構造(DB設計)で解決する方が、常にスケーラビリティは高い。

君が書くその一行の `relation => OR` が、将来のバーストアクセス時にサーバーを落とす原因になるかもしれない。システムを掌握するエンジニアなら、常にその「裏側」を想像しながらコードを書いてほしい。

健闘を祈る。

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