WordPressを掌握する極限の知見:`SQL_CALC_FOUND_ROWS`の呪縛から解放される`WP_Query`高速化の極意
テックリードの私たちがコードレビューで最も戦慄する瞬間の一つは、無限スクロールや非同期ローディング、あるいは「単に最新の数件を取得したいだけ」のエンドポイントにおいて、平然とデフォルトの`WP_Query`が発行されているのを見たときだ。
画面には「たった5件」の投稿しか表示されていない。しかし、発行されたSQLの内部では、インデックスをフル活用できなくする悪名高い修飾子が静かに息を潜めている。
それが `SQL_CALC_FOUND_ROWS` だ。
本稿では、このデータベースのパフォーマンスキラーを無効化し、大規模サイトのデータ構造においてミリ秒単位のクエリ高速化をもぎ取るための、実務的な設計パターンと実装コードを解説する。
—
1. なぜ `SQL_CALC_FOUND_ROWS` は悪なのか?
WordPressの歴史的背景を振り返ろう。バージョン4.0以前から、WordPressは「ページネーション(総ページ数)」を簡単に実装するため、`WP_Query` の内部でMySQLに対して次のようなクエリを発行してきた。
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
WHERE …
LIMIT 0, 10;
この `SQL_CALC_FOUND_ROWS` が付与されると、MySQL(InnoDB)は `LIMIT` 句を完全に無視して、条件に合致するすべての行をスキャンし、総件数を強制的にカウントする。
つまり、以下のような致命的な非効率が発生する。
- 10件しか画面に出さないのに、数百万件のレコードをテーブルスキャン(あるいはインデックスフルスキャン)する。
- クエリキャッシュが効きにくくなる。
- データ量が増大するにつれて、クエリの実行時間が線形ではなく、幾何級数的に悪化する。
「全件数(Found Rows)が必要なのは、ユーザーがページ番号をクリックしてジャンプする必要がある、従来の静的なページネーション画面だけだ」
APIレスポンス、無限スクロール、ダッシュボードのウィジェットなど、現代のWebアプリケーションの9割において、総件数の厳密なカウントなど不要である。不要な計算にリソースをドブに捨てるのは今すぐやめよう。
—
2. コアの挙動:どのように無効化するか?
WordPress 4.6以降、`WP_Query` には `no_found_rows` という強力なパラメータが用意されている。これを `true` に設定することで、あの忌々しい `SQL_CALC_FOUND_ROWS` をSQLから完全に排除できる。
しかし、実務の現場では、リクエストの文脈に応じて動的に `posts_per_page` やページネーションの有無を切り替える必要がある。ここでは、動的制御を行いつつ、確実にこの最適化を適用する堅牢な実装パターンを見ていこう。
—
3. プロダクションコード例:堅牢なカスタムクエリ設計
以下のコードは、REST APIや非同期ローディング、またはカスタムテンプレートパーツにおいて、パフォーマンスを極限まで高めた `WP_Query` のラッパー関数(またはクラスメソッド)の実装例である。
/
function my_project_get_optimized_posts( array $args = [] ) {
// 1. デフォルト値の安全な定義
$defaults = [
‘post_type’ => ‘post’,
‘post_status’ => ‘publish’,
‘posts_per_page’ => 10,
‘paged’ => 1,
‘orderby’ => ‘date’,
‘order’ => ‘DESC’,
];
$parsed_args = wp_parse_args( $args, $defaults );
// 2. 動的な posts_per_page の検証とサニタイズ
$posts_per_page = absint( $parsed_args[‘posts_per_page’] );
// 異常値や過大なリクエストを防ぐキャップ(上限設定)
$posts_per_page = ( $posts_per_page > 0 && $posts_per_page <= 100 ) ? $posts_per_page : 10;
$paged = absint( $parsed_args['paged'] );
$paged = max( 1, $paged );
// 3. WP_Query の基本引数構築
$query_args = [
'post_type' => sanitize_text_field( $parsed_args[‘post_type’] ),
‘post_status’ => ‘publish’,
‘posts_per_page’ => $posts_per_page,
‘paged’ => $paged,
‘orderby’ => $parsed_args[‘orderby’],
‘order’ => $parsed_args[‘order’],
// ==========================================
// 【核心】SQL_CALC_FOUND_ROWS の無効化
// ==========================================
‘no_found_rows’ => true,
];
// 必要に応じてメタクエリやタクソノミークエリを安全にマージ
if ( ! empty( $parsed_args[‘tax_query’] ) ) {
$query_args[‘tax_query’] = $parsed_args[‘tax_query’];
}
// 4. クエリの実行
$query = new WP_Query( $query_args );
// 5. 返り値の構築(総件数がないため、次ページが存在するかどうかを軽量に判定する)
// ※ 次ページの存在確認には、設定値 + 1 件を取得してチェックするテクニックもあるが、
// 今回はシンプルにオブジェクト配列を返す。
$response = [
‘success’ => true,
‘posts’ => $query->posts,
‘count’ => count( $query->posts ),
// ‘total’ や ‘max_num_pages’ は `no_found_rows => true` のため信頼できない(または 0 になる)
‘has_more’ => ( count( $query->posts ) === $posts_per_page ),
];
// メモリリーク防止の文脈として念のためリセット
wp_reset_postdata();
return $response;
}
—
4. 総件数(Total)がどうしても必要な場合の「代替案」
「無限スクロールであっても、全体の総件数を画面のどこかに表示しなければならない仕様なんだ」というビジネス要件に直面することがある。エンジニアとしては頭を抱えたくなる瞬間だが、ここで元の `SQL_CALC_FOUND_ROWS` に逃げてはならない。
MySQLのコストベースオプティマイザを騙さず、かつ高速に総件数を取得するための 実務的な代替案 は以下の2つだ。
代替案 A: 一時的なプレフィックスキャッシュ(Transients API)の活用
総件数はリアルタイムに1件の狂いなく一致している必要があるだろうか? 多くのメディアサイトやアーカイブにおいて、総件数は数分〜数時間のキャッシュであっても実用上全く問題ない。
function my_project_get_total_posts_cached( $post_type = ‘post’ ) {
$cache_key = ‘my_project_total_posts_’ . $post_type;
$total = get_transient( $cache_key );
if ( false === $total ) {
// wp_count_posts は内部でキャッシュを持つが、カスタムクエリの条件がある場合は wpdb で直接 COUNT を叩く
global $wpdb;
$total = (int) $wpdb->get_var( $wpdb->prepare(
“SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = ‘publish'”,
$post_type
) );
// 1時間キャッシュする
set_transient( $cache_key, $total, HOUR_IN_SECONDS );
}
return $total;
}
`SELECT COUNT(ID)` は、インデックス(通常は `post_status` や `post_type` の複合インデックス、あるいはプライマリキー)が適切に効いていれば、`SQL_CALC_FOUND_ROWS` を伴う全件スキャンよりも圧倒的に高速に動作する。
代替案 B: 「次へ進めるか」の軽量判定(1件多めに取得するハック)
無限スクロール等で「まだ続きのデータがあるか」を知りたいだけの場合、わざわざ `COUNT` すら取る必要はない。
`posts_per_page` を 「指定された枚数 + 1」 でクエリを発行するのだ。
1. ユーザーが10件要求したら、裏では `11件` 取得する。
2. 実際に取得できた件数が11件であれば、`has_more = true` と判断し、画面には先頭の10件だけをスライスして渡す。
3. 10件以下であれば、それが最後のページ(`has_more = false`)である。
この手法により、データベースへの負荷を最小限に抑えつつ、ページネーションのUXを完全に担保できる。
—
5. テックリードからの総括
WordPressのパフォーマンスチューニングにおいて、フレームワークが「良かれと思って」裏側で行っている親切機能は、しばしばスケール時のボトルネックになる。
- `SQL_CALC_FOUND_ROWS` は、ページネーションの総件数を手抜きして実装するための諸刃の剣である。
- 現代の非同期インターフェースやAPIファーストの設計においては、`’no_found_rows’ => true` をデフォルトの標準とせよ。
- どうしても総件数が必要な場合は、`Transients API` によるキャッシュ、あるいは `COUNT` クエリの分離、もしくは「+1件取得法」による遅延判定を設計に組み込むこと。
システムの本質を理解し、クエリの裏側で何が起きているかを脳内でトレースできるエンジニアだけが、高負荷に耐えうる真にスケーラブルなWordPressシステムを構築できる。次のコードレビューでは、無駄なクエリを発行しているコードを見つけ出し、この知見をもとに容赦なくリファクタリングを要求してほしい。