WP_Queryの内部監査:`tax_query`における`relation => OR`の呪縛と、RDBの物理限界を突破するデータ構造の再設計
WordPressのコアアーキテクチャにおいて、`WP_Query`は最も強力であり、同時に最も誤用されやすいブラックボックスだ。
特に、メタデータ(`meta_query`)やタクソノミー(`tax_query`)を複雑に組み合わせたクエリ発行は、背後で生成されるSQLの複雑性を隠蔽する。
今回は、実務中級者からシニアエンジニアへとステップアップする過程で必ず直面する、`tax_query`における`relation => OR`条件が引き起こすSQLオプティマイザの破綻と、その根本的な解決策について、MySQLの内部挙動(ストレージエンジン、インデックススキャン、実行計画)の観点から深掘りする。
—
1. 内部メカニズムの解剖:なぜ `relation => OR` はスロークエリの温床となるのか
まずは、WordPressがデフォルトで採用しているデータベーススキーマ、いわゆる E/W(Entity-Attribute-Value)ライクなタクソノミー構造を思い出してほしい。
タクソノミーの紐付けには、以下の3つのテーブルが関与する。
1. `wp_posts` (投稿本体)
2. `wp_term_relationships` (投稿IDとタームIDのリレーション)
3. `wp_term_taxonomy` (タームIDとタクソノミー種別のマッピング)
ここに `WP_Query` で `relation => OR` を指定したクエリを投げてみる。
$query = new WP_Query([
‘post_type’ => ‘post’,
‘tax_query’ => [
‘relation’ => ‘OR’,
[
‘taxonomy’ => ‘genre’,
‘field’ => ‘slug’,
‘terms’ => [‘action’, ‘sci-fi’],
],
[
‘taxonomy’ => ‘audience’,
‘field’ => ‘slug’,
‘terms’ => [‘adult’],
],
],
]);
このPHPコードが生成するSQLの抽象構造は、おおむね以下のようになる(簡略化)。
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
INNER JOIN wp_term_relationships
ON (wp_posts.ID = wp_term_relationships.object_id)
WHERE (
wp_term_relationships.term_taxonomy_id IN (12, 15)
OR
wp_term_relationships.term_taxonomy_id IN (22)
)
AND wp_posts.post_type = ‘post’
AND wp_posts.post_status = ‘publish’
GROUP BY wp_posts.ID;
一見して何の問題もないように見えるかもしれない。しかし、MySQL(InnoDB)のオプティマイザの挙動、そしてB-Treeインデックスの構造を知る者にとっては、これは悪夢の始まりに過ぎない。
インデックスマージ(Index Merge)の限界とフルテーブルスキャン
`wp_term_relationships` テーブルの主キーは `(object_id, term_taxonomy_id)` である。
ここで `OR` 条件が使用されると、MySQLのオプティマイザは以下の2つの選択肢の間で迷うことになる。
1. Index Merge (OR) の使用: それぞれの条件でインデックスを逆引きし、結果の合집합(Union)を取る。
2. 全件スキャン (Full Table Scan): 条件に合致する行が全体の何割を占めるか不明確な場合、インデックスを使うよりシーケンシャルリードの方が早いと判断し、テーブル全体をスキャンする。
データ量が数万件規模であれば問題は表面化しない。しかし、投稿数が数百万件に達し、`wp_term_relationships` が数千万行を超える大規模サイトにおいて、この `OR` 条件と `GROUP BY`、さらには `SQL_CALC_FOUND_ROWS`(ページネーション用の総件数計算)が同時に走った瞬間、クエリの実行時間は数百ミリ秒から数秒へと跳ね上がり、データベースのコネクションプールを枯渇させる。
—
2. アンチパターン:サブクエリによる解決の幻想
この問題を解決しようとして、開発者が陥りがちな最初の罠が「サブクエリや動的なプレフィックス結合の多用」だ。しかし、MySQL 5.7および一部の8.0初期バージョンにおいて、サブクエリ内の `IN` や `OR` は、オプティマイザによる最適化(Materializationなど)が効きにくく、一時テーブル(Temporary Table)をディスク上に生成する原因になる。
ディスクI/Oが発生した時点で、Webアプリケーションのレイテンシは致命的な打撃を受ける。我々が求めるべきは、オプティマイザに頼らない「確定的な高速化パス」の構築だ。
—
3. 解決アプローチ 1:`UNION` へのクエリの分解とアプリケーション層でのマージ
データベースのオプティマイザが複雑な `OR` 条件の処理に苦戦するならば、人間側でクエリを分割し、それぞれの結果を結合(Union)してやればいい。
幸い、`WP_Query` 自体を二回走らせてPHPのメモリ上でマージする方法もあるが、ページネーション(`paged`, `posts_per_page`)を正確に動作させる必要がある場合、これは破綻する。
したがって、直接SQLを構築するか、あるいはID配列を抽出して `post__in` で再取得するアプローチが現実的だ。
global $wpdb;
// 各条件に一致する投稿IDを個別に取得し、インデックスを確実にヒットさせる
$sql_genre = ”
SELECT object_id
FROM {$wpdb->term_relationships} tr
INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.taxonomy = ‘genre’ AND tt.term_id IN (12, 15)
“;
$sql_audience = ”
SELECT object_id
FROM {$wpdb->term_relationships} tr
INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.taxonomy = ‘audience’ AND tt.term_id IN (22)
“;
// UNION で結合し、重複を排除(UNION DISTINCT)
$union_sql = “($sql_genre) UNION ($sql_audience)”;
$post_ids = $wpdb->get_col($union_sql);
if (empty($post_ids)) {
// 該当なしの場合の処理
$query = new WP_Query([‘post__in’ => [0]]);
} else {
// 抽出したID群を用いて最終的な WP_Query を実行
// パフォーマンス劣化の原因となる complex tax_query を完全に排除する
$query = new WP_Query([
‘post_type’ => ‘post’,
‘post_status’ => ‘publish’,
‘post__in’ => $post_ids,
‘orderby’ => ‘post__in’, // 取得順序を維持
‘posts_per_page’ => 10,
‘paged’ => get_query_var(‘paged’, 1),
]);
}
このアプローチの利点は、それぞれの単独クエリが極めてシンプルになるため、MySQLが確実にインデックス(`term_taxonomy_id` や複合インデックス)を利用できる点にある。
—
4. 解決アプローチ 2:データ構造のパラダイムシフト(非正規化とカスタムテーブル設計)
シニアエンジニアとして、数千万レコードを扱うシステムを設計する場合、WordPress標準のE/Wモデルの限界を受け入れ、ストレージ層でのデータ構造の非正規化を検討すべきだ。
タクソノミーの検索がボトルネックになるのであれば、検索対象の状態をフラットなカラム、あるいはJSON型、あるいはビット演算(Bitmask)として投稿テーブル、あるいは専用のカスタムストレージテーブルに保持する。
例:カスタムフラットテーブルによる検索の高速化
`wp_posts` に依存せず、検索専用のカスタムテーブル `wp_optimized_post_index` を定義する。
CREATE TABLE wp_optimized_post_index (
post_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
genre_bits INT UNSIGNED NOT NULL DEFAULT 0,
audience_bits INT UNSIGNED NOT NULL DEFAULT 0,
KEY idx_genre (genre_bits),
KEY idx_audience (audience_bits)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ここで、各タームをビットフラグに割り当てる(例: `action` = 1, `sci-fi` = 2, `adult` = 4)。
`genre` が `action` (1) または `sci-fi` (2) かつ `audience` が `adult` (4) のような複雑な条件も、ビット演算子を使用することで、インデックスを完全に効かせた高速なクエリに変換できる。
SELECT post_id
FROM wp_optimized_post_index
WHERE (genre_bits & (1 | 2)) > 0
OR (audience_bits & 4) > 0;
このクエリは、複雑なJOINや複数のテーブル走査を一切必要とせず、極めて高いスループットを叩き出す。WordPressのフック(`save_post` など)を用いて、投稿の保存時にこのインデックステーブルを非同期(あるいはトランザクション内)で同期する仕組みを構築すれば、コアの設計思想を破壊することなく、パフォーマンスのみを極限まで引き上げることが可能だ。
—
結び:アーキテクトとしての選択
`WP_Query` の `tax_query` は非常に便利だが、それは「小〜中規模なデータセット」においてのみ成り立つ利便性に過ぎない。
システムがスケールし、データベースの物理限界に直面したとき、フレームワークが隠蔽する背後のSQLとインデックスの挙動を脳内で完全にトレースし、必要であればクエリの分割やストレージ層の再設計(データモデリングの変更)を断行する勇気こそが、真のエンジニアリングである。
小手先のパラメータチューニングに逃げるな。レイヤを掘り下げ、真のボトルネックを断て。