データベース検索の限界とElasticsearch連携の本質
エンタープライズ領域のWordPress開発において、`WP_Query` や `get_posts()` は諸刃の剣だ。小規模なサイトであればシームレスに機能するこれらクエリビルダーも、数十万件を超える投稿、複雑なタクソノミー階層、そして複数のカスタムフィールド(Post Meta)が絡み合う瞬間、MySQLのクエリプランナーは崩壊する。
特に最悪なのが、`meta_query` や `tax_query` を複合的に指定した検索だ。
SELECT SQL_CALC_FOUND_ROWS wp_posts.
FROM wp_posts
INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id )
WHERE 1=1 AND ( ( wp_postmeta.meta_key = ‘price’ AND wp_postmeta.meta_value = ‘1000’ ) )
GROUP BY wp_posts.ID
ORDER BY wp_posts.post_date DESC
LIMIT 0, 10;
このクエリは、`wp_postmeta` の EAV(Entity-Attribute-Value)構造に起因する巨大な一時テーブル(Temporary Table)の生成、filesort、そしてテーブルスキャンを引き起こす。インデックスをどれほど適切に貼ろうとも、メタデータの数が増えればスケールしない。さらに `SQL_CALC_FOUND_ROWS` がページネーションの総数計算のために毎度フルスキャンを強制する。
このI/O地獄からWordPressを解放する唯一の解が、検索処理のElasticsearchへのオフロードである。
本稿では、単に「プラグインを入れる」のではなく、WordPressのクエリライフサイクルを深く理解した上で、`WP_Query` 自体をハックしてElasticsearchへクエリを完全に委譲する、プロダクション品質のアーキテクチャと実装コードを解説する。
—
アーキテクチャ設計:WP_Queryフックの介入ポイント
WordPressのコアは非常に拡張性が高く設計されている。`WP_Query` の内部動作を追うと、データベースにクエリを投げる直前に `posts_pre_query` という極めて強力なフィルターが存在する。
[WP_Query 実行]
└─> [posts_pre_query フィルター]
├─> null を返すと通常通り MySQL へクエリ発行
└─> 投稿オブジェクトの配列を返すと MySQL クエリを完全バイパス!
この `posts_pre_query` を利用し、特定の検索リクエスト(またはすべてのフロントエンド検索)をキャッチしてElasticsearchへ問い合わせ、返ってきたID群からWordPressのオブジェクトキャッシュを利用して一括で `WP_Post` を復元する。この設計により、MySQL側の負荷を理論上の限界までゼロに近づけることができる。
—
実装:堅牢なElasticsearch検索アダプターとWP_Queryオーバーライド
以下に提示するのは、実務のコードレビューでもそのままマージできるレベルの、疎結合かつ堅牢な実装だ。
1. Elasticsearchクライアントとの通信レイヤー
まずはElasticsearchへのリクエストを抽象化する。ここではWordPress標準の `wp_remote_post` を使用し、外部依存ライブラリ(ComposerのElasticsearch PHPクライアントなど)の読み込みエラーやバージョンコンフリクトを回避するミニマルな設計にする。
endpoint = rtrim( $endpoint, ‘/’ );
$this->index = $index;
}
/
- Elasticsearchへクエリを送信し、マッチしたPost IDの配列とヒット総数を返す
- @param array $query_dsl
- @param int $offset
- @param int $limit
- @return array{ids: int[], total: int}
/
public function search( array $query_dsl, int $offset = 0, int $limit = 10 ): array {
$url = sprintf( ‘%s/%s/_search’, $this->endpoint, $this->index );
$payload = [
‘from’ => $offset,
‘size’ => $limit,
‘query’ => $query_dsl,
];
$response = wp_remote_post( $url, [
‘headers’ => [ ‘Content-Type’ => ‘application/json’ ],
‘body’ => wp_json_encode( $payload ),
‘data_format’ => ‘body’,
‘timeout’ => 5, // 検索のタイムアウトは厳格に設定
] );
if ( is_wp_error( $response ) ) {
// 本番環境ではエラーをログに記録し、例外をスローしてMySQL側へフォールバックさせる設計が望ましい
throw new \RuntimeException( ‘Elasticsearch connection failed: ‘ . $response->get_error_message() );
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $status_code ) {
throw new \RuntimeException( ‘Elasticsearch returned non-200 status: ‘ . $status_code );
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$ids = [];
$total = 0;
if ( isset( $body[‘hits’][‘hits’] ) ) {
foreach ( $body[‘hits’][‘hits’] as $hit ) {
// ElasticsearchのドキュメントIDにWordPressのPost IDを格納している前提
$ids[] = (int) $hit[‘_id’];
}
}
if ( isset( $body[‘hits’][‘total’][‘value’] ) ) {
$total = (int) $body[‘hits’][‘total’][‘value’];
}
return [
‘ids’ => $ids,
‘total’ => $total,
];
}
}
2. WP_Query のインターセプトと処理委譲
次に、`posts_pre_query` フックを使い、特定の条件(例: `s` パラメータが存在する場合など)でMySQLへのクエリをバイパスし、Elasticsearchの結果を注入するクラスを実装する。
client = $client;
$this->init_hooks();
}
private function init_hooks(): void {
// クエリ発行プロセスへの割り込み
add_filter( ‘posts_pre_query’, [ $this, ‘intercept_query’ ], 10, 2 );
}
/
- WP_Queryの実行をインターセプトする
- @param null|array $posts
- @param WP_Query $query
- @return array|null
/
public function intercept_query( ?array $posts, WP_Query $query ): array {
// 管理画面やメインクエリ以外、あるいは検索キーワードがない場合はスキップ
if ( is_admin() || ! $query->is_search() || ! $query->get( ‘s’ ) ) {
return $posts; // nullを返すと通常のMySQLクエリが走る
}
// 無限ループを防ぐためのフラグチェック
if ( $query->get( ‘es_processed’ ) ) {
return $posts;
}
$search_term = $query->get( ‘s’ );
$paged = max( 1, $query->get( ‘paged’ ) );
posts_per_page = $query->get( ‘posts_per_page’ );
$posts_per_page = $posts_per_page ? (int) $posts_per_page : (int) get_option( ‘posts_per_page’ );
$offset = ( $paged – 1 ) $posts_per_page;
// Elasticsearch用の簡易的なDSL構築(実際にはより高度なマルチマッチクエリなどを構築する)
$dsl = [
‘multi_match’ => [
‘query’ => $search_term,
‘fields’ => [ ‘post_title^3’, ‘post_content’, ‘excerpt’ ],
],
];
try {
$result = $this->client->search( $dsl, $offset, $posts_per_page );
if ( empty( $result[‘ids’] ) ) {
$query->found_posts = 0;
$query->max_num_pages = 0;
return [];
}
// WP_Queryのページネーション用プロパティを明示的に書き換える
// これにより SQL_CALC_FOUND_ROWS の発行を完全に防ぐ
$query->found_posts = $result[‘total’];
$query->max_num_pages = (int) ceil( $result[‘total’] / $posts_per_page );
// 取得したID順序を保持したまま WP_Post オブジェクトの配列を構築
// 内部で object_cache が効くためパフォーマンスは極めて高い
$fetched_posts = [];
foreach ( $result[‘ids’] as $post_id ) {
$post_obj = get_post( $post_id );
if ( $post_obj instanceof \WP_Post ) {
$fetched_posts[] = $post_obj;
}
}
// 念のため投稿データをキャッシュに温めておく
update_post_caches(
$fetched_posts,
$query->get( ‘post_type’ ),
true,
true
);
return $fetched_posts;
} \Throwable $e {
// フェイルセーフ:Elasticsearchがダウンしている場合はMySQL検索へフォールバックする
error_log( ‘Elasticsearch Search Error, falling back to MySQL: ‘ . $e->getMessage() );
return null;
}
}
}
—
パフォーマンス上の注意点と実践的プラクティス
この設計をプロダクション環境に投入する際、シニアエンジニアとして必ず担保しなければならないポイントが3つある。
1. 順序性(Relevance Scoring)の維持
Elasticsearchの検索結果は、TF-IDFやBM25アルゴリズムに基づいた関連度スコア順(あるいは特定フィールド順)で返ってくる。
もし取得したIDを単に `get_posts()` で再取得すると、データベースのデフォルトソート(通常は `post_date DESC`)に書き換わってしまい、「検索ワードに最もマッチした記事が上位に来ない」という致命的なUXのバグが起きる。
上記のコード例では、Elasticsearchから返却されたIDの配列順序(`$result[‘ids’]`)をそのまま保持して返却している点に注目してほしい。
2. キャッシュ戦略の分離
Elasticsearch側の検索結果(IDのリストと総数)は、頻繁に変わるものではない。検索キーワードとページ番号をキーにして、WordPressのオブジェクトキャッシュ(Redis / Memcached)に一定時間(例: 5分間)キャッシュ層を挟むべきだ。
これにより、高負荷時のElasticsearchサーバー自体の保護にも繋がる。
3. インデキシングの非同期化(Event-Driven Sync)
今回は検索部分(Read)のオフロードに特化したが、投稿の更新(Write)時にElasticsearchのインデックスを同期させる必要がある。
この同期処理をフック(`save_post` など)の同期実行で行うと、管理画面での記事保存や一括更新のレスポンスタイムが大幅に悪化する。
プロダクションでは、`save_post` からはペイロードを Action Scheduler や外部キュー(RabbitMQ, AWS SQSなど)にプッシュし、バックグラウンドワーカーで非同期にElasticsearchへバルクインサートするアーキテクチャが絶対条件となる。
—
総括
WordPressの `WP_Query` を外部検索エンジンにオフロードすることは、単なる「検索スピードの高速化」に留まらない。それは、モノリスなRDB(MySQL)を単なるデータ永続化層へとフェードアウトさせ、アプリケーションのスケールアウト性を劇的に高めるためのモダンな脱却手法である。
フレームワークの仕様にただ従うだけのコードから脱却し、リクエストのライフサイクルとデータフローを完全に掌握したシステム設計を行ってほしい。それがプロフェッショナルエンジニアの仕事である。