WP_Queryの闇を剥ぎ取る:`tax_query`の内部挙動とサブクエリ地獄からの脱却
コードレビューの場で、次のようなコードを見かけて冷や汗をかいたことはないだろうか。
// 最悪なパターン:一見きれいに見えるが、内部で悲惨なことが起きている
$args = array(
‘post_type’ => ‘product’,
‘posts_per_page’ => 10,
‘tax_query’ => array(
‘relation’ => ‘AND’,
array(
‘taxonomy’ => ‘product_cat’,
‘field’ => ‘slug’,
‘terms’ => array( ‘gadgets’, ‘iot’ ),
‘operator’ => ‘IN’,
),
array(
‘taxonomy’ => ‘product_tag’,
‘field’ => ‘slug’,
‘terms’ => array( ‘sale’ ),
‘operator’ => ‘IN’,
),
),
);
$query = new WP_Query( $args );
中級者へのステップアップの過程で、誰もが一度は使う`tax_query`。しかし、この抽象化された便利なAPIの裏側で何が起きているのかを理解しているエンジニアは少ない。
今回は、プロダクション環境で数百万件規模のレコードを扱うデータベースにおいて、`tax_query`がなぜパフォーマンスキラーになり得るのか、その内部挙動を解剖し、サブクエリや多重JOINを回避してインデックスを完全に効かせた極限のクエリ設計術を伝授する。
—
1. `tax_query` の内部挙動と「JOIN地獄」の可視化
まず、WordPressが背後で発行しているSQLの現実を見よう。`WP_Query`は、`tax_query`を受け取ると、内部クラス `WP_Tax_Query` を経由してSQLの `JOIN` 句と `WHERE` 句を動的に組み立てる。
前述のコードが発行するSQLの構造を抽象化すると、以下のようになる。
SELECT SQL_CALC_FOUND_ROWS wp_posts.
FROM wp_posts
INNER JOIN wp_term_relationships
ON (wp_posts.ID = wp_term_relationships.object_id)
INNER 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) )
AND ( tr2.term_taxonomy_id IN (22) )
AND wp_posts.post_type = ‘product’
AND wp_posts.post_status = ‘publish’
GROUP BY wp_posts.ID
ORDER BY wp_posts.date DESC
LIMIT 0, 10;
ここに潜む3つの致命的ボトルネック
1. 自己結合(Self-Join)の爆発:
`tax_query`の条件(配列の要素)が増えるたびに、`wp_term_relationships` テーブルの `JOIN` が追加される。条件が3つ、4つと増えれば、オプティマイザが悲鳴を上げる多重JOINが完成する。
2. `GROUP BY` と `SQL_CALC_FOUND_ROWS` の悪夢:
重複する投稿IDをまとめるために `GROUP BY wp_posts.ID` が強制される。さらに、ページネーションのためにデフォルトで付与される `SQL_CALC_FOUND_ROWS` は、MySQLに対してインデックスのスキャンを無視した全件カウントを強要する。データ量が10万件を超えたあたりから、クエリの実行時間は数百ミリ秒から数秒へと跳ね上がる。
3. 複合インデックスの不整合:
`wp_term_relationships` のプライマリーキーは `(object_id, term_taxonomy_id)` だが、動的なJOINと条件分岐により、MySQLのクエリプランナーが最適なインデックスを選択できず、ファイルソートや一時テーブルの生成(Using temporary; Using filesort)が発生する。
—
2. サブクエリ(`NOT IN` や複雑な否定条件)の罠
さらに事態を悪化させるのが、`operator` に `NOT IN` や `NOT EXISTS` を指定したケース、あるいは複数のタクソノミーを複雑に組み合わせた場合だ。WordPressはこれらを解決するために、内部で相関サブクエリを生成する傾向がある。
サブクエリは可読性が高い(ように見える)が、MySQLのオプティマイザ(特にMySQL 5.6以前や、MariaDBの一部バージョン、さらには古いMySQL 8.0の設定)において、外側クエリの各行に対してサブクエリが実行される(Dependent Subquery)挙動を引き起こしやすく、計算量が $O(N^2)$ にスケールアップする。
「数万件の投稿データがあるサイトで、特定タグを除外したアーカイブページが突然重くなった」という障害の多くは、これが原因だ。
—
3. 解決策:カスタムルックアップテーブルとダイレクトSQL設計
では、数百万件のレコードを持つエンタープライズ環境で、どのようにこの負荷を回避すべきか。
答えの一つは、「`tax_query` の動的JOINに頼らず、最適化されたカスタムメタ/リレーション構造、またはビット演算、あるいはプリコンピュート(事前計算)されたデータ構造を持つこと」だ。
しかし、既存のWordPressのスキーマ(`wp_posts`, `wp_term_relationships`)の制約内で最大限パフォーマンスを引き出すための、実務で即採用できる「クエリ最適化の設計パターン」を提示しよう。
設計パターン:`WP_Query` を捨て、IDを直接キャッシュ・取得する
もしあなたがパフォーマンスクリティカルなウィジェットやAPIエンドポイントを開発しているなら、`new WP_Query` を使うのをやめ、必要な投稿IDをダイレクトな最適化SQL(またはTransientキャッシュを組み合わせたプリペアドステートメント)で取得し、`update_post_cache()` に流し込むのが最も確実で高速だ。
以下に、プロダクションコードとして耐えうる、安全かつ高速なカスタムクエリ実行クラスの設計例を示す。
namespace MyApp\Performance;
class OptimizedProductQuery {
/
- tax_queryのJOIN地獄を回避し、交差検索(AND条件)を高速に実行する
- @param array $term_taxonomy_ids 対象の term_taxonomy_id の配列
- @param int $limit
- @param int $offset
- @return int[] 投稿IDの配列
/
public static function get_post_ids_by_taxonomies_and( array $term_taxonomy_ids, int $limit = 10, int $offset = 0 ): array {
global $wpdb;
// 入-力のサニタイズとバリデーション
$term_taxonomy_ids = array_map( ‘absint’, $term_taxonomy_ids );
if ( empty( $term_taxonomy_ids ) ) {
return array();
}
$count = count( $term_taxonomy_ids );
// プレースホルダーの動的生成
$placeholders = implode( ‘,’, array_fill( 0, $count, ‘%d’ ) );
// 【極意】INTERSECT または HAVING COUNT(DISTINCT) を用いた最適化クエリ
// 多重JOINを避け、指定されたすべてのタームを「同時に持つ(AND条件)」投稿IDを高速に抽出する
$sql = ”
SELECT tr.object_id
FROM {$wpdb->term_relationships} AS tr
INNER JOIN {$wpdb->posts} AS p ON p.ID = tr.object_id
WHERE tr.term_taxonomy_id IN ({$placeholders})
AND p.post_type = ‘product’
AND p.post_status = ‘publish’
GROUP BY tr.object_id
HAVING COUNT(DISTINCT tr.term_taxonomy_id) = %d
ORDER BY p.post_date DESC
LIMIT %d OFFSET %d
“;
// パラメータの結合
$params = $term_taxonomy_ids;
$params[] = $count; // HAVING句のカウント用
$params[] = $limit;
$params[] = $offset;
// クエリの実行($wpdb->prepareによるSQLインジェクション対策)
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$post_ids = $wpdb->get_col( $wpdb->prepare( $sql, $params ) );
return array_map( ‘absint’, $post_ids );
}
/
- 最適化したIDリストからWP_Queryオブジェクト、または標準の投稿オブジェクトを構築する
/
public static function get_products( array $term_taxonomy_ids, int $limit = 10, int $offset = 0 ): array {
$post_ids = self::get_post_ids_by_taxonomies_and( $term_taxonomy_ids, $limit, $offset );
if ( empty( $post_ids ) ) {
return array();
}
// キャッシュの事前ロード(N+1問題の完全な根絶)
// データベースへの無駄な個別クエリを防ぐため、オブジェクトキャッシュに一括ロードする
_prime_post_caches( $post_ids, true, true );
// 順序を維持したまま投稿オブジェクトの配列を返す
$posts = array();
foreach ( $post_ids as $id ) {
$post = get_post( $id );
if ( $post ) {
$posts[] = $post;
}
}
return $posts;
}
}
—
4. この設計がプロダクション環境で圧倒的に強い理由
1. JOINの数を常に「1」に固定:
何個のタクソノミーやタームで絞り込もうとも、`wp_term_relationships` に対する `JOIN` は1回で完結する。インデックスの効き目が一定になり、クエリプランナーが迷うことがない。
2. `HAVING COUNT(DISTINCT …)` によるエレガントなAND条件:
複数のカテゴリーやタグに「すべて所属する」という条件を、複雑なサブクエリや自己結合なしに、単一のグルーピングとカウント比較で安全かつ高速に処理できる。
3. オブジェクトキャッシュの強制プライミング(`_prime_post_caches`):
WordPress標準の `WP_Query` は、メタデータやタームデータの取得で隠れたクエリ(N+1問題)を発生させやすい。しかし、IDをピンポイントで取得した後に `_prime_post_caches()` を叩くアプローチでは、メタデータやターム情報も含めて最小限のクエリで一括キャッシュに乗せることが可能になる。
—
5. テクニカルリードからの最終提言
「便利なAPIだから」という理由で、巨大なデータベースに対して思考停止で `tax_query` をネストさせる実装は、将来のシステム障害を予約しているようなものだ。
- レコード数が数万件を超え、パフォーマンスチューニングが必要な局面では、`WP_Query` の抽象化層をあえて一段バイパスし、生のSQLの挙動とインデックスをコントロールする勇気を持つこと。
- データの取得(IDの抽出)と、オブジェクトの構築(キャッシュのロード)を分離する関心の分離(SoC)を徹底すること。
この2点を守るだけで、あなたの書くWordPressアプリケーションは見違えるほど堅牢になり、トラフィックの急増にもビクともしないスケーラビリティを手に入れる。
コードレビューで非効率な `tax_query` を見つけたら、この記事の設計思想をベースに、美しくスケーラブルなコードへ導いてほしい。