WP_Queryの限界を超えろ:`meta_query`を完全排除し、カスタムテーブルとGenerated Columnで爆速検索基盤を構築する方法
コードレビューをしていて、次のようなクエリに出くわしたことはないだろうか。
// 絶対にやってはいけないアンチパターン
$args = array(
‘post_type’ => ‘real_estate’,
‘meta_query’ => array(
‘relation’ => ‘AND’,
array(
‘key’ => ‘price’,
‘value’ => 50000000,
‘type’ => ‘NUMERIC’,
‘compare’ => ‘<=',
),
array(
'key' => ‘area’,
‘value’ => 80,
‘type’ => ‘NUMERIC’,
‘compare’ => ‘>=’,
),
),
);
$query = new WP_Query( $args );
このコードが実行された瞬間、MySQLサーバーは何を行っているか。
WordPressのデフォルト設計である `wp_postmeta` テーブルに対し、`CAST(meta_value AS SIGNED)` のような暗黙の型変換を伴う 全表スキャン(Full Table Scan) が発生し、インデックスは完全に効かなくなる。データ量が数十万件を超えた途端にCPU使用率は跳ね上がり、スロークエリの温床となる。
「カスタム投稿タイプだから」「メタデータだから」という理由で `wp_postmeta` に構造化データを詰め込む時代は終わった。
本稿では、MySQL 5.7以降で利用可能な 生成列(Generated Column) とカスタムテーブルを組み合わせ、WordPressのクエリパフォーマンスを極限まで引き上げる次世代の設計パターンを解説する。
—
1. なぜ `wp_postmeta` はスケールしないのか?(内部構造の理解)
WordPressの `wp_postmeta` は、汎用性を極限まで高めた EAV(Entity-Attribute-Value)モデルを採用している。
+————+———+—————-+—————-+
| meta_id | post_id | meta_key | meta_value |
+————+———+—————-+—————-+
| 1 | 101 | price | 48000000 |
| 2 | 101 | area | 85 |
+————+———+—————-+—————-+
この構造の最大の弱点は、「1つのエンティティ(投稿)の属性が複数行に垂直分割されて格納されている点」だ。
価格と面積の条件に一致する投稿を1つのSQLで取得しようとすると、テーブルの自己結合(Self-Join)や複雑なサブクエリ、あるいは `EXISTS` 句が必要になり、オプティマイザのコスト見積もりが破綻する。
さらに、`meta_value` のデータ型が `LONGTEXT` であるため、数値としての大小比較であってもMySQLは実行時に型変換コストを支払わされる。
—
2. 解決策:カスタムテーブル + 仮想カラム(Generated Column)
この問題を根本から解決するアプローチが、「検索・ソート対象のデータを独立したカスタムテーブルに垂直同期し、MySQLのGenerated Columnでインデックス化する」という設計だ。
不動産情報を例に、専用のカスタムテーブル `wp_real_estate_index` を定義する。
CREATE TABLE {$wpdb->prefix}real_estate_index (
post_id BIGINT(20) UNSIGNED NOT NULL,
price DECIMAL(12,2) NOT NULL,
area DECIMAL(8,2) NOT NULL,
— 【核心】検索条件となるJSONカラム
attributes JSON NOT NULL,
— 【核心】JSONから値を抽出して永続化(Stored)するGenerated Column
city_code VARCHAR(10) GENERATED ALWAYS AS (attributes->>’$.city_code’) STORED,
PRIMARY KEY (post_id),
KEY idx_price_area (price, area),
KEY idx_city (city_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Generated Columnの真価:「仮想(Virtual)」と「格納(Stored)」
- VIRTUAL: ディスク容量を消費しないが、読み取り時に毎回計算される(検索にはインデックスを貼れない)。
- STORED: データの書き込み時に計算され、ディスク上に値が保持される。このタイプであれば、通常のカラムと同様にB-Treeインデックスを付与できる。
つまり、JSONで柔軟なスキーマレスのデータを保持しつつ、頻繁に検索・ソートするキーだけを静的に抽出してインデックスの恩恵を受けることが可能になるのだ。
—
3. プロダクションコード:堅牢なデータ同期とカスタムクエリの実装
ここからは、実際のWordPressプロジェクトに組み込めるプロダクションクオリティのコードを示す。
データの同期・保存ロジック
投稿の保存・更新時に、`wp_postmeta` だけでなく、最適化されたカスタムテーブルへトランザクション安全にデータを同期する。
/
public static function sync_index( int $post_id, \WP_Post $post, bool $update ): void {
// 自動保存やリビジョン、ゴミ箱行きはスキップ
if ( defined( ‘DOING_AUTOSAVE’ ) && DOING_AUTOSAVE ) {
return;
}
if ( wp_is_post_revision( $post_id ) || ‘real_estate’ !== $post->post_type ) {
return;
}
global $wpdb;
$table_name = $wpdb->prefix . ‘real_estate_index’;
// メタデータから値を取得
$price = (float) get_post_meta( $post_id, ‘price’, true );
$area = (float) get_post_meta( $post_id, ‘area’, true );
$city_code = sanitize_text_field( get_post_meta( $post_id, ‘city_code’, true ) );
// JSON構造体の構築
$attributes = wp_json_encode( array(
‘city_code’ => $city_code,
‘updated_at’ => current_time( ‘mysql’ ),
) );
// Upsert ( INSERT … ON DUPLICATE KEY UPDATE ) による高速かつ安全な書き込み
$sql = $wpdb->prepare(
“INSERT INTO {$table_name} (post_id, price, area, attributes)
VALUES (%d, %f, %f, %s)
ON DUPLICATE KEY UPDATE
price = VALUES(price),
area = VALUES(area),
attributes = VALUES(attributes)”,
$post_id,
$price,
$area,
$attributes
);
// クエリ実行(エラー時はログに記録)
$result = $wpdb->query( $sql );
if ( false === $result ) {
error_log( sprintf( ‘Failed to index real_estate post_id: %d’, $post_id ) );
}
}
public static function delete_index( int $post_id ): void {
global $wpdb;
if ( ‘real_estate’ !== get_post_type( $post_id ) ) {
return;
}
$wpdb->delete(
$wpdb->prefix . ‘real_estate_index’,
array( ‘post_id’ => $post_id ),
array( ‘%d’ )
);
}
}
RealEstateIndexer::init();
—
4. `WP_Query` を乗っ取り、爆速カスタムクエリを実行する
メタデータを一切使わず、カスタムテーブルのインデックスを直接叩く検索リポジトリクラスを実装する。
ここでは `posts_pre_query` フィルターフックを使用し、標準の `WP_Query` の重いSQL生成プロセスを完全にバイパスして、高速に投稿オブジェクトの配列を返す。
/
public static function search( array $criteria, int $paged = 1, int $per_page = 10 ): array {
global $wpdb;
$table_name = $wpdb->prefix . ‘real_estate_index’;
$posts_table = $wpdb->posts;
$offset = ( max( 1, $paged ) – 1 ) $per_page;
// 動的プレースホルダーとWHERE句の構築
$where = array( “p.post_type = ‘real_estate'”, “p.post_status = ‘publish'” );
$values = array();
if ( ! empty( $criteria[‘max_price’] ) ) {
$where[] = “idx.price <= %f";
$values[] = (float) $criteria['max_price'];
}
if ( ! empty( $criteria['min_area'] ) ) {
$where[] = "idx.area >= %f”;
$values[] = (float) $criteria[‘min_area’];
}
if ( ! empty( $criteria[‘city_code’] ) ) {
// Generated Column を直接ヒットさせるためインデックスが有効に機能する
$where[] = “idx.city_code = %s”;
$values[] = sanitize_text_field( $criteria[‘city_code’] );
}
$where_sql = implode( ‘ AND ‘, $where );
// 総件数の取得(SQL_CALC_FOUND_ROWSは使わず、高速なCOUNTクエリを分離)
$count_sql = $wpdb->prepare(
“SELECT COUNT(p.ID) FROM {$posts_table} p
INNER JOIN {$table_name} idx ON p.ID = idx.post_id
WHERE {$where_sql}”,
$values
);
$total = (int) $wpdb->get_var( $count_sql );
$max_num_pages = (int) ceil( $total / $per_page );
// 本文データの取得(IDのみを先取得してWPオブジェクトのキャッシュを利用するのが定石だが、今回はJOINで一括取得)
$query_values = $values;
$query_values[] = $per_page;
$query_values[] = $offset;
$sql = $wpdb->prepare(
“SELECT p. FROM {$posts_table} p
INNER JOIN {$table_name} idx ON p.ID = idx.post_id
WHERE {$where_sql}
ORDER BY idx.price ASC
LIMIT %d OFFSET %d”,
$query_values
);
$results = $wpdb->get_results( $sql );
// WordPressのオブジェクトキャッシュにポストデータを流し込む(重要)
$posts = array_map( function( $post_row ) {
return new \WP_Post( $post_row );
}, $results );
update_post_cache( $posts );
return array(
‘posts’ => $posts,
‘max_num_pages’ => $max_num_pages,
‘total’ => $total,
);
}
}
—
5. テクニカルリードからの総括:このアーキテクチャがもたらす優位性
コードレビューの現場で「なぜここまでやるのか」と問われたら、私はこう答える。
1. O(1)に近いスケーラビリティ:
`wp_postmeta` のようなEAVモデルはデータ量に比例してクエリコストが線形(またはそれ以上)に悪化する。しかし、B-Treeインデックスが張られたカスタムテーブルと Generated Column による設計であれば、数百万件のレコードが存在しようとも検索パフォーマンスは数ミリ秒単位で安定する。
2. 関心の分離(Separation of Concerns):
WordPressコアの汎用データ構造に依存せず、ドメインモデル(今回の場合は不動産検索)に最適化されたストレージ層を持つことで、将来的な外部検索エンジン(ElasticsearchやAlgoliaなど)への移行も極めてスムーズに行える。
3. トランザクションの整合性:
外部の検索サーバーと同期する際の「ネットワーク遅延による不整合」のリスクがなく、MySQLのトランザクション境界内でデータとインデックスの整合性を完全に保証できる。
「動けばいいコード」から「スケールするアーキテクチャ」へ。
大規模なWordPress開発において、データベースの制約を理解し、それを逆手に取った設計こそが、プロフェッショナルエンジニアの仕事である。