WP_Queryの限界を突破せよ:`wp_posts`における複合インデックス最適化とクエリチューニングの極意
テックリードの私だ。コードレビューの際、「なぜこの`WP_Query`は遅いのか」「なぜデータが増えるとMySQLのCPU使用率が跳ね上がるのか」をロジカルに説明できないエンジニアが多すぎる。
「とりあえずキャッシュプラグインを入レバいい」という思考停止は今日で終わりにしよう。数百万レコードを抱えるエンタープライズ領域のWordPressにおいて、データベースの物理層とオプティマイザの挙動を理解していない設計は、システム全体を崩壊させる時限爆弾でしかない。
今回は、WordPressの心臓部である `wp_posts` テーブルのデフォルトインデックス構造を解剖し、特定のカスタム投稿タイプ(CPT)が肥大化した際に陥るパフォーマンス劣化のメカニズムと、それを根治するための「複合インデックス設計」について、実務直結の知見を授ける。
—
1. デフォルトのインデックス構造が抱える「致命的な欠陥」
まず、デフォルトの `wp_posts` テーブルのインデックス構成を直視しよう。`SHOW INDEX FROM wp_posts;` を叩けば一目瞭然だが、主要なカラムに対するインデックス設計は以下のようになっている。
- `PRIMARY KEY` (`ID`)
- `KEY post_name` (`post_name(191)`)
- `KEY type_status_date` (`post_type`, `post_status`, `post_date`, `ID`) ※一部バージョンや環境によるが、基本は単体または部分的な複合インデックス
ここで、以下の典型的な `WP_Query` を発行したときのMySQLの内部挙動を想像してほしい。
$query = new WP_Query([
‘post_type’ => ‘my_custom_post’,
‘post_status’ => ‘publish’,
‘posts_per_page’ => 10,
‘orderby’ : ‘date’,
‘order’ => ‘DESC’,
]);
一見、何の問題もない美しいコードに見える。しかし、`my_custom_post` のレコードが 500万件 存在し、そのうち `publish` はわずか10件、残りが `draft` やゴミデータだった場合、MySQLのクエリパーサとオプティマイザはどう動くか?
もしインデックスの選択が適切に行われない場合、またはデータ分布の偏り(カーディナリティの問題)により、MySQLはフルテーブルスキャン(全件走査)、あるいは効率の悪いインデックスマージを選択する。結果として、`filesort` が発生し、クエリの実行に数百ミリ秒〜数秒を要することになる。REST API経由でこれが走ろうものなら、フロントエンドは一瞬で沈黙する。
—
2. 複合インデックス(Composite Index)の設計哲学
この問題を解決するのが、`post_type` と `post_status`、そしてソートキーである `post_date` を網羅した複合インデックスの追加だ。
MySQLのB-Treeインデックスにおいて、複数カラムにインデックスを貼る際の順序(左プレフィックス則)は極めて重要である。
1. 等価条件(Equality) に使われるカラムを左に置く:`post_type = ‘my_custom_post’`
2. 範囲条件・状態(Range / Status) に使われるカラムを次に置く:`post_status = ‘publish’`
3. ソート(Sorting) に使われるカラムを右に置く:`post_date DESC`
この原則に従い、以下のDDLを実行することで、クエリの実行計画(EXPLAIN)を劇的に改善できる。
— 複合インデックスの追加
ALTER TABLE wp_posts ADD INDEX idx_ptype_pstatus_pdate (post_type, post_status, post_date);
なぜこれが効くのか?
MySQLは、`post_type` で絞り込んだ上で、そのサブツリーから `post_status` が一致するものをピンポイントで抽出し、すでにソート済みの `post_date` の順序でデータにアクセスできる。これにより、`Using where; Using filesort` が消え、`Using index condition` または効率的な範囲スキャンへと最適化されるのだ。
—
3. 【プロダクションコード】安全なインデックス管理とマイグレーション
現場のエンジニアとして、これを手動で本番DBに適用するのは悪手だ。デプロイメントパイプラインやテーマ・プラグインの有効化時(`activation hook`)に、安全かつ冪等性(Idempotency)を担保した上でインデックスの存在チェックと追加を行うコードを実装すべきである。
以下のコードは、まさにプロダクション環境で使用できる堅牢なマイグレーション処理の例だ。
/
if ( ! defined( ‘ABSPATH’ ) ) {
exit;
}
class WP_Posts_Index_Optimizer {
const INDEX_NAME = ‘idx_ptype_pstatus_pdate’;
/
- 初期化
/
public static function init() {
// プラグイン有効化時にインデックスを追加
register_activation_hook( __FILE__, [ __CLASS__, ‘add_composite_index’ ] );
}
/
- 複合インデックスを安全に追加する
- 既に存在する場合は重複作成を避けるため何も実行しない
/
public static function add_composite_index() {
global $wpdb;
$table_name = $wpdb->posts;
// インデックスの存在確認
if ( self::index_exists( $table_name, self::INDEX_NAME ) ) {
return;
}
// 複合インデックスの追加クエリ
// 注意: 大規模テーブルでのALTER TABLEはロックを伴うため、メンテナンス時間やpt-online-schema-change等の利用を推奨
$sql = “ALTER TABLE {$table_name} ADD INDEX ” . self::INDEX_NAME . ” (post_type, post_status, post_date)”;
$result = $wpdb->query( $sql );
if ( false === $result ) {
// エラーログの記録(実務ではMonologやカスタムロガーに流すこと)
error_log(sprintf(
‘[Database Optimization Error] Failed to create index %s on table %s. DB Error: %s’,
self::INDEX_NAME,
$table_name,
$wpdb->last_error
));
}
}
/
- 指定したインデックスがテーブルに存在するかチェックする
- @param string $table_name
- @param string $index_name
- @return bool
/
private static function index_exists( $table_name, $index_name ) {
global $wpdb;
// SHOW INDEXクエリの結果からインデックス名を走査
$indexes = $wpdb->get_results( “SHOW INDEX FROM {$table_name} WHERE Key_name = ‘{$index_name}'” );
return ! empty( $indexes );
}
}
WP_Posts_Index_Optimizer::init();
—
4. テックリードからの実務上の重要なしるし(注意点)
このコードとアプローチを適用するにあたり、シニアエンジニアとして以下のリスクヘッジを厳命する。
1. 数千万レコードを超える巨大テーブルでの `ALTER TABLE` の危険性
標準的な `ALTER TABLE` はテーブルを排他ロック(Metadata Lock)するため、実行中にサイト全体の書き込みが完全にフリーズする。数千万件規模の環境では、ペタバイト級のデータ移行ツール(`pt-online-schema-change` や MySQL 8.0以降の `ALGORITHM=INSTANT` / `INPLACE` の挙動)を慎重に選定し、オフピーク時に実行すること。
2. 過剰なインデックスの弊害
「速くなるなら何でもインデックスを貼ればいい」というのは素人の発想だ。インデックスが増えれば増えるほど、`INSERT` / `UPDATE` / `DELETE` 実行時のB-Tree再構築コスト(書き込み性能の劣化)が跳ね上がる。本当に必要なクエリパターン(スロークエリログで検出されたもの)に対してのみインデックスを設計しろ。
3. `no_found_rows => true` の併用
ページネーション(`SQL_CALC_FOUND_ROWS`)を裏で実行させるな。WordPress 4.9以降でも、明示的に指定しないと余計な全件カウントクエリが走る。複合インデックスを活かすクエリとセットで、必ず `no_found_rows => true` を指定し、不要なパフォーマンスコストを排除しろ。
—
結び
フレームワークの便利さに甘え、データベースのレイヤーに無頓着なエンジニアは、シニアとは呼ばれない。WordPressはただのブログツールではない。PHPとMySQLで構築された巨大なエンタープライズCMSだ。
内部構造をハックし、クエリの実行計画をコントロールする。その泥臭くも美しい最適化の積み重ねこそが、プロフェッショナルなWebシステムを支える唯一の技術基盤となる。
今日の知見を自身の開発環境で `EXPLAIN` を叩いて検証し、無駄なクエリを駆逐してほしい。健闘を祈る。