WordPressを掌握する極限の知見:`wp_posts`のインデックス設計とクエリプランナーの調律
コードレビューの場において、次のようなクエリを見たことはないだろうか。
SELECT FROM wp_posts WHERE post_type = ‘product’ AND post_status = ‘publish’;
一見して何の問題もない、極めて一般的なクエリに見える。しかし、数百万レコードを超える大規模なWordPressサイトにおいて、このクエリはデータベースのCPU使用率を跳ね上げ、スロークエリの温床となる。
テックリードとして言こう。「なぜこのクエリが非効率なのか、MySQLのオプティマイザの挙動を踏まえて説明できないうちは、大規模なWordPressサイトの設計を語る資格はない」。
今回は、WordPressの根幹である `wp_posts` テーブルの物理構造、特に `post_status` と `post_type` の複合インデックス設計、そしてクエリプランナー(オプティマイザ)の挙動を極限までハックする方法を伝授する。
—
1. WordPressデフォルトのインデックス構造の限界
まず、デフォルトの `wp_posts` テーブルのインデックスを確認する。
- `PRIMARY KEY`: `ID`
- `KEY type_status_date`: `post_type`, `post_status`, `post_date`, `ID` (※環境やバージョンにより微差あり)
- `KEY post_author`: `post_author`
- `KEY post_name`: `post_name` (プレフィックス長制限あり)
- `KEY post_parent`: `post_parent`
「なんだ、`post_type` と `post_status` の複合インデックスはデフォルトで張られているじゃないか」と思ったならば早計だ。
MySQL(InnoDB)のB-Treeインデックスは、「左端プレフィックス原則(Leftmost Prefix Principle)」に従う。つまり、`type_status_date` というインデックス (`post_type`, `post_status`, `post_date`, `ID`) が存在する場合、以下の検索ではインデックスが有効に機能する。
1. `WHERE post_type = ‘…’`
2. `WHERE post_type = ‘…’ AND post_status = ‘…’`
3. `WHERE post_type = ‘…’ AND post_status = ‘…’ AND post_date = ‘…’`
しかし、もしあなたが `post_status` だけを条件にした場合、あるいはクエリの記述順序を変えた場合に、オプティマイザがどのように動くかを理解しているだろうか?
—
2. クエリプランナーの裏側:カーディナリティの罠
MySQLのコストベース・オプティマイザ(CBO)は、統計情報に基づいて「どのインデックスを使うのが最もコストが低いか」を決定する。
ここで問題になるのがカーディナリティ(データの重複度の低さ、種類の多さ)だ。
- `post_type` の種類は通常、数個〜数十個程度(`post`, `page`, `attachment`, カスタム投稿タイプなど)。つまりカーディナリティは低い。
- 一方、`post_status` も種類は少ない (`publish`, `draft`, `trash`, `auto-draft` など)。こちらもカーディナリティは低い。
カーディナリティが低いカラムに対する単独の絞り込みは、インデックスを使って行数を絞り込むよりも、テーブル全体をスキャン(フルテーブルスキャン)した方が速いとオプティマイザが判断することが多々ある。
さらに、WordPressで最も頻発するアンチパターンがこれだ。
// 最悪な例:メタデータとの結合や複雑な条件の混入
$query = new WP_Query([
‘post_type’ => ‘product’,
‘post_status’ => ‘publish’,
‘meta_key’ => ‘_stock_status’,
‘meta_value’ => ‘instock’,
]);
このクエリを発行した瞬間、WordPressは `wp_postmeta` との `INNER JOIN` を発生させ、インデックスの効かない非効率な一時テーブル(Temporary Table)とFilesort地獄を作り出す。`wp_posts` 側のインデックスが最適化されていなければ、MySQLのバッファプールは一瞬で枯渇する。
—
3. 実務で勝つための複合インデックスの再設計
もし、特定のカスタム投稿タイプ(例: `product` や `event`)に対して膨大なトラフィックがあり、`post_type` と `post_status` を軸にしたフィルタリングがボトルネックになっている場合、デフォルトのインデックスだけでは不十分なケースがある。
特に、`post_date` や `ID` を含んだソートを伴う場合、インデックスのカラム順序がパフォーマンスを大きく左右する。
最適なインデックス戦略の策定
等価比較(`=`)を行うカラムを左側に置き、範囲検索やソート(`ORDER BY`)を行うカラムを右側に配置するのがB-Treeの鉄則だ。
もし `WHERE post_type = X AND post_status = Y ORDER BY post_date DESC` というクエリが頻出するなら、以下のインデックスが理想的となる。
ALTER TABLE wp_posts ADD INDEX idx_type_status_date_rev (post_type, post_status, post_date DESC);
しかし、本番環境のデータベースに対して安易に `ALTER TABLE` を叩くのは危険だ。巨大なテーブルでのインデックス追加はテーブルロックを引き起こす。ペタバイト級、あるいは数千万レコードを抱えるエンタープライズ環境では、`pt-online-schema-change` などのツールや、MySQL 8.0以降の `ALGORITHM=INSTANT` / `INPLACE` を適切に選択する必要がある。
—
4. プロダクションコード:安全かつ高速なクエリの構築とキャッシュ戦略
データベースの物理構造を最適化したら、次はアプリケーション層(WordPress)からのアプローチだ。
無駄なクエリを発行させず、クエリプランナーに無駄な負荷をかけないためのプロダクションコードを提示する。
以下のコードは、カスタム投稿タイプとステータスの複合条件を安全に処理し、オブジェクトキャッシュ層で完全に保護された堅牢なクエリラッパーの例である。
/
namespace Enterprise\Optimization;
class PostQueryOptimizer {
/
- 最適化された投稿取得メソッド
- @param string $post_type 投稿タイプ
- @param string $post_status 投稿ステータス
- @param int $number 取得件数
- @return \WP_Post[]
/
public static function get_optimized_posts( string $post_type = ‘post’, string $post_status = ‘publish’, int $number = 10 ): array {
// キャッシュキーの生成(クエリ条件をハッシュ化)
$cache_key = ‘eq_posts_’ . md5( $post_type . ‘_’ . $post_status . ‘_’ . $number );
$cache_group = ‘enterprise_posts’;
// 1. オブジェクトキャッシュ(Redis/Memcached)からのフェッチ
$cached_posts = wp_cache_get( $cache_key, $cache_group );
if ( false !== $cached_posts ) {
/
- 開発者注:
- キャッシュヒット時はDBへのヒットが0になるため、クエリプランナーの挙動すらバイパスできる。
- これこそが究極のパフォーマンスチューニングである。
/
return $cached_posts;
}
/
- 2. WP_Queryの安全な構築
- no_found_rows を true にし、SQL_CALC_FOUND_ROWS(ページネーション用の全体件数カウント)を抑制。
- これにより、不要なテーブルスキャンコストを完全に排除する。
/
$query_args = [
‘post_type’ => $post_type,
‘post_status’ => $post_status,
‘posts_per_page’ => $number,
‘no_found_rows’ => true, // ページネーション不要なら必ずtrueにすること
‘update_post_meta_cache’ => false, // メタデータを取得しない場合はfalseでメモリを節約
‘update_post_term_cache’ => false, // タームを取得しない場合も同様
‘orderby’ => ‘date’,
‘order’ =’DESC’,
];
$query = new \WP_Query( $query_args );
$posts = $query->posts;
// 3. キャッシュの永続化(TTLは要件に応じて調整。例: 1時間)
wp_cache_set( $cache_key, $posts, $cache_group, HOUR_IN_SECONDS );
return $posts;
}
}
このコードの優れた設計ポイント(コードレビューの視点から)
1. `no_found_rows => true` の強制
WordPressのデフォルトの `WP_Query` は、ページネーションのために必ず `SELECT FOUND_ROWS()` またはそれに類するカウントクエリを発行する。これが大規模テーブルにおいて致命的なパフォーマンス低下を招く。件数カウントが不要なウィジェットやAPIエンドポイントでは、このオプションを立てるのが鉄則。
2. メタ・タームキャッシュの無効化
純粋に `wp_posts` のテーブル構造(`post_type`, `post_status`)のみで完結するデータ取得において、`wp_postmeta` や `wp_term_relationships` を巻き込むキャッシュ処理はメモリの無駄遣いだ。不要なJOINやサブクエリが発生しないよう、明示的にオフにしている。
3. オブジェクトキャッシュファースト
MySQLのインデックスがどれほど優れていても、ディスク(あるいはメモリ上のバッファプール)を読むより、Redisなどのインメモリキャッシュからミリ秒未満でデータを引き抜く方が圧倒的に速い。インデックス設計とキャッシュ戦略は両輪である。
—
結びにかえて
「動けばいい」という甘えたコードは、トラフィックが急増した瞬間にシステムを崩壊させる。
データベースの物理構造(B-Treeの特性、インデックスの左端プレフィックス原則)を理解し、クエリプランナーが迷わないSQLを発行すること。そして、WordPress特有のオーバヘッドを殺すための適切なパラメータチューニングを行うこと。
これらをやりきって初めて、「WordPressを掌握している」と胸を張ることができる。次のコードレビューでは、同僚の書いた `WP_Query` の背後にある実行計画(EXPLAIN)まで突き詰めて指導してやってほしい。