SQL_CALC_FOUND_ROWSの呪縛:WP_Queryにおける巨大データセットの高速化と真のページネーション戦略
WordPressのアーキテクチャにおいて、`WP_Query` は最も濫用され、かつ最も誤解されているコンポーネントの一つだ。数百万件規模のレコードを持つデータベース上で、無限スクロールや「もっと見る」形式のUIを実装する際、無意識のうちに発行される重いクエリに頭を抱えたことはないだろうか。
今回は、`WP_Query` の背後にあるMySQLのオプティマイザの挙動、そしてシステム全体のスループットを劇的に低下させる元凶である `SQL_CALC_FOUND_ROWS` の実態と、それを無効化して限界突破するための実践的アプローチを解説する。
—
1. 内部メカニズム:なぜ `SQL_CALC_FOUND_ROWS` は悪なのか
WordPress 4.0以降、`WP_Query` はデフォルトで `SQL_CALC_FOUND_ROWS` フラグを付与したSQLクエリを発行する。
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
WHERE wp_posts.post_type = ‘post’
AND wp_posts.post_status = ‘publish’
ORDER BY wp_posts.post_date DESC
LIMIT 0, 10;
この `SQL_CALC_FOUND_ROWS` が発行された瞬間、MySQL(InnoDB)のクエリパーサとオプティマイザは、通常の実行計画を放棄し、「LIMIT句を無視して、WHERE句にマッチするすべての行をスキャンし、一時的な結果セットの総数を計算する」という重い処理を強制される。
ストレージエンジンレベルのコスト
InnoDBにおいて、これはキャッシュヒット率を低下させ、ディスクI/Oを激しくスパイクさせる原因となる。
さらに悪質なのは、ページネーションの総件数(`$wp_query->found_posts`)を取得するために、続くセカンドクエリとして必ず以下が実行される点だ。
SELECT FOUND_ROWS();
もし、あなたが `posts_per_page => 10` で最新の記事10件を取得したいだけだとしても、MySQLは裏でテーブル全体(あるいはインデックスの全範囲)を舐め回している。これが、データ量が増加するにつれてWordPressのレスポンスタイムが線形ではなく指数関数的に悪化する根本原因である。
—
2. 解決策:`no_found_rows` による計算の完全排除
全件カウント(総ページ数)がUI上不要な場合(例:無限スクロール、APIのエンドポイント、最新情報のフィードなど)、やるべきことは明確だ。`WP_Query` の引数に `no_found_rows => true` を指定する。
これにより、WordPressは `SQL_CALC_FOUND_ROWS` をクエリから除外し、不要な `FOUND_ROWS()` の呼び出しもスキップする。
実装例:最適化されたカスタムクエリ
/
- SQL_CALC_FOUND_ROWSを排除し、極限まで最適化されたWP_Queryの実行例
/
$args = array(
‘post_type’ => ‘product’,
‘posts_per_page’ => 20,
‘paged’ => get_query_var( ‘paged’ ) ? get_query_var( ‘paged’ ) : 1,
‘no_found_rows’ => true, // ★ここでFOUND_ROWSの計算を完全に殺す
‘update_post_meta_cache’ => false, // メタデータのキャッシュも不要なら切る
‘update_post_term_cache’ => false, // タームキャッシュも不要なら切る
);
$optimized_query = new WP_Query( $args );
if ( $optimized_query->have_posts() ) {
while ( $optimized_query->have_posts() ) {
$optimized_query->the_post();
// レンダリング処理
}
}
wp_reset_postdata();
この設定により、MySQLは `LIMIT 20` に到達した瞬間にスキャンを打ち切る(Short-circuit evaluation)。実行時間はミリ秒単位へと劇的に短縮される。
—
3. 代替案:真に効率的な「総件数」の取得とキャッシュ戦略
「しかし、UIの要件上、どうしても総件数が必要なんだ」というケースもあるだろう。そのためにデフォルトの `SQL_CALC_FOUND_ROWS` に頼るべきではない。それはシステム全体のリソースの無駄遣いだ。
シニアエンジニアが選択すべき代替案は、「カウント専用の軽量クエリの分離」 と 「オブジェクトキャッシュによる永続化」 である。
アプローチA:近似値または条件付きカウントクエリ
正確な総件数がビジネスロジック上で厳密に必要でない限り、あるいは頻繁に変動しないのであれば、RedisやMemcachedなどの外部オブジェクトキャッシュ層にカウント結果を退避させるべきだ。
/
- キャッシュを活用した高速なページネーション総件数取得
/
function get_optimized_found_posts( $cache_key, $args, $ttl = 3600 ) {
$found_posts = wp_cache_get( $cache_key, ‘optimized_query’ );
if ( false === $found_posts ) {
// カウント専用の軽量クエリ(メタやタームJOINを排除)
$count_query = new WP_Query( array_merge( $args, array(
‘fields’ => ‘ids’,
‘posts_per_page’ => 1,
‘no_found_rows’ => true,
) ) );
// 注意: 厳密なSQLのCOUNTが必要な場合は、ここで直接$wpdbを使用するか、
// キャッシュのTTLを短くして負荷をコントロールする。
// ここでは簡易的に全体のID数をトランジェント等で代用する設計も視野に入れる。
// 実際の総件数取得にはSQLのCOUNT(ID)を使う方が安全な場合がある
global $wpdb;
// ※実際の本番環境ではプレースホルダーとバリデーションを厳格に行うこと
$found_posts = (int) $wpdb->get_var( “SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_type = ‘product’ AND post_status = ‘publish'” );
wp_cache_set( $cache_key, $found_posts, ‘optimized_query’, $ttl );
}
return $found_posts;
}
アプローチB:無限スクロール(カーソルベースのページネーション)への移行
もしあなたが数百万件のデータベースを運用しているなら、`OFFSET` を使ったページネーション自体がアンチパターンである。MySQLは `OFFSET 100000` を処理するために、先頭から10万件を読み捨てなければならないからだ。
ここで導入すべきなのが、カーソルベース(シークメソッド) のページネーションである。
— 前回の最後の投稿ID(例: 5042)を基準に取得する
SELECT ID, post_date
FROM wp_posts
WHERE post_type = ‘post’
AND post_status = ‘publish’
AND ID < 5042
ORDER BY ID DESC
LIMIT 20;
この方式であれば、`SQL_CALC_FOUND_ROWS` も `OFFSET` も完全に不要となり、データ量がどれだけ増大しようとも、クエリの実行コストは常に一定($O(1)$に近い挙動)に保たれる。
---
4. 最後に:アーキテクトとしての心構え
WordPressは「誰でも簡単に使えるCMS」という顔の裏に、極めて伝統的なリレーショナルデータベースの制約を隠し持っている。プラグインやテーマをインストールするだけで動く手軽さに甘え、データベースの内部挙動(EXPLAINの結果やインデックスの効き具合)を無視したコードを書き続けることは、システムに対する慢心に他ならない。
`SQL_CALC_FOUND_ROWS` の無効化は、その最適化の旅のほんの第一歩に過ぎない。しかし、この小さなフラグの制御一つをとっても、シニアエンジニアとしての技量と、背後に流れるデータの重みに対する敬意が明確に現れる。
システムを極限までチューニングし、いかなる負荷状況下でも秒速で応答するインフラストラクチャを作り上げること――それこそが、真のエンジニアリングの醍醐味である。