WP_Queryの限界を超える:`fields => ‘ids’` と `get_posts` のメモリ消費量ガチ比較と最適解
コードレビューをしていると、未だに数万件規模の投稿を持つ大規模サイトで、安易に `new WP_Query()` を回しているコードに遭遇する。「動いているからいいだろう」という認識は、プロダクション環境のスケール時には致命的なメモリリークやDBの負荷増大を引き起こす。
今回は、WordPressエンジニアとして知っておくべき、オブジェクト生成コストの最適化手法について徹底的に解剖する。特に、IDのみが必要なケースにおける `WP_Query( [ ‘fields’ => ‘ids’ ] )` と、ラップ関数である `get_posts()` の挙動の違いを、メモリ消費量と内部クエリの観点からロジカルに紐解く。
中級から一歩抜け出し、シニアとして「なぜその書き方を選ぶのか」を説明できるエンジニアになるための知見を授けよう。
—
1. 内部アーキテクチャの理解:なぜ `WP_Query` は重いのか?
まず前提として、WordPressの `WP_Query` が裏で何をやっているかを正確に把握する必要がある。
デフォルトの `WP_Query` は、発行されたSQLの結果セット(通常は投稿IDのリスト)を受け取った後、以下の処理をループ内で実行する。
1. DBからのデータ取得: `SELECT FROM $wpdb->posts WHERE …` により、投稿の全カラム(コンテンツ、抜粋、スラッグ等)のデータを取得。
2. オブジェクトの構築: 取得したレコードを基に、`WP_Post` クラスのインスタンスを生成。
3. オブジェクトキャッシュへの登録: 生成された `WP_Post` オブジェクトが `wp_cache_set` を通じてオブジェクトキャッシュ(Memcached / Redisなど)に保存、あるいはキャッシュからフェッチされる。
4. メタデータの事前ロード (Meta Lazy Loading): 近年のバージョンでは最適化されているが、それでも関連するメタデータ群の効率化処理が走る。
数千件単位の投稿をこのフローで処理した場合、PHPのメモリ制限(`memory_limit`)に到達するのは時間の問題だ。特に共有ホスティングやリソースが限られたクラウド環境では、OOM(Out of Memory)エラーの引き金になる。
—
2. `fields => ‘ids’` の真価と限界
このオーバーヘッドを回避するために用意されているのが、`WP_Query` の引数における `’fields’ => ‘ids’` だ。
$query = new WP_Query( [
‘post_type’ => ‘post’,
‘posts_per_page’ => 5000,
‘fields’ => ‘ids’, // ここがキモ
] );
何が起きているのか?
内部のSQL生成フェーズにおいて、WordPressは `SELECT $wpdb->posts.ID` のみを実行するようになる。これにより、以下のメリットが生まれる。
- ネットワーク転送量の激減: 巨大な `post_content` などのテキストフィールドがDBからPHPへと転送されない。
- メモリ消費の劇的抑制: `WP_Post` オブジェクトではなく、単なる整数の配列(`int[]`)が返されるため、PHPのメモリフットプリントが数分の一、あるいは数十分の一になる。
注意すべき罠
ただし、`’fields’ => ‘ids’` を使った場合でも、WordPressは内部でオブジェクトキャッシュに余計なデータを残さないためのケアを意識する必要がある。もし取得したID群に対して個別に `get_post($id)` をループで回すような愚行を犯せば、結局キャッシュヒット率が低下し、N+1問題の亜種を引き起こす。IDのみで完結する処理(例:カスタムテーブルへのバッチ処理、外部APIへのID同期など)でのみ真価を発揮する。
—
3. `get_posts()` との比較:ラッパー関数の正体
次に、多くの開発者が日常的に使用している `get_posts()` についてだ。
「`WP_Query` を直接書くのは面倒だから `get_posts()` を使おう」というノリで使っていないだろうか?
結論から言えば、`get_posts()` は内部で `new WP_Query()` をインスタンス化しているだけのラッパー関数に過ぎない。
ソースコード(`wp-includes/post.php`)を覗いてみれば一目瞭然だが、`get_posts()` の実体はこうだ。
function get_posts( $args = null ) {
// デフォルト引数のマージ処理…
$my_query = new WP_Query();
$raw_posts = $my_query->query( $args );
// …
return $raw_posts;
}
決定的な違いは、`get_posts()` のデフォルト引数が `WP_Query` と異なっている点である。
例えば、`get_posts()` はデフォルトで `suppress_filters => true` が設定されており、投稿の取得パフォーマンスに重きを置いている。
しかし、もしあなたが「IDの配列だけ欲しい」という目的で `get_posts()` を使う場合、以下のように書くことになる。
$post_ids = get_posts( [
‘post_type’ => ‘post’,
‘posts_per_page’ => 5000,
‘fields’ => ‘ids’,
] );
これ自体は `WP_Query` を直接書くのとほぼ同等のパフォーマンスを発揮する。なぜなら、内部で `new WP_Query` が生成され、同じパスを通るからだ。
—
4. プロダクションコード例:安全かつ高速なバッチ処理の実装
大規模データセットを安全に処理するための、プロダクションクオリティのコードを示す。ここでは、メモリ溢れを防ぎつつ、IDベースで効率的に処理を回す設計パターンを採用している。
/
declare( strict_types=1 );
namespace TechLead\Performance;
class BatchProcessor {
/
- 指定された投稿タイプのIDを取得し、コールバック関数で処理する
- @param string $post_type 対象の投稿タイプ
- @param int $batch_size 1回あたりの処理件数
- @param callable $callback 各IDに対して実行する処理
- @return void
/
public static function process_posts_by_id( string $post_type, int $batch_size, callable $callback ): void {
$paged = 1;
do {
// メモリ消費を最小限に抑えるため ‘fields’ => ‘ids’ を指定
$query_args = [
‘post_type’ => $post_type,
‘post_status’ => ‘publish’,
‘posts_per_page’ => $batch_size,
‘paged’ => $paged,
‘fields’ => ‘ids’,
‘orderby’ => ‘ID’,
‘order’ => ‘ASC’,
// 管理画面の負荷軽減や無駄なSQLキャッシュを防ぐ
‘no_found_rows’ => true,
‘update_post_meta_cache’ => false,
‘update_post_term_cache’ => false,
];
// get_posts は WP_Query のラッパー。明示的に意図を示すために使用。
$post_ids = get_posts( $query_args );
if ( empty( $post_ids ) ) {
break;
}
foreach ( $post_ids as $post_id ) {
// コールバック実行(例外処理は呼び出し側またはここで担保)
try {
call_user_func( $callback, (int) $post_id );
} catch ( \Throwable $e ) {
// ログ出力などのエラーハンドリング
error_log( sprintf( ‘Error processing post ID %d: %s’, $post_id, $e->getMessage() ) );
}
}
// メモリ解放の明示的促進(PHPのガベージコレクタを補助)
unset( $post_ids );
if ( function_exists( ‘gc_collect_cycles’ ) ) {
gc_collect_cycles();
}
$paged++;
} while ( true );
}
}
// — 実行例 —
// BatchProcessor::process_posts_by_id(‘post’, 500, function( int $post_id ) {
// // ここに重い処理(外部API連携やメタデータの書き換えなど)を記述
// });
コードの設計ポイント
1. `no_found_rows => true`: ページネーション用の `SQL_CALC_FOUND_ROWS` を無効化することで、MySQL側のクエリ実行コストを劇的に削減している。件数カウントが不要なバッチ処理では必須の設定。
2. キャッシュ制御フラグの徹底: `update_post_meta_cache` と `update_post_term_cache` を `false` に。IDしか取得しないため、メタやタームのプリロードは完全な無駄骨となる。これらを防ぐことでDBへの負荷を最小化する。
3. 明示的なメモリ管理: ループの終わりに `unset()` と `gc_collect_cycles()` を挟み、長時間稼働するバッチプロセスでもメモリリークが起きない堅牢な構造にしている。
—
5. まとめ:シニアエンジニアが選ぶべき最適解
- `WP_Query` 直接 vs `get_posts()`:
機能的な差はほぼない(`get_posts` は単なるエイリアス)。ただし、コードの可読性やデフォルト引数の特性(`suppress_filters` 等)を理解した上で使い分けること。IDのみが必要なケースではどちらを使っても内部の最適化恩恵は受けられる。
- `fields => ‘ids’` の活用:
「全データが必要なわけではない(IDだけ、あるいは後から必要なものだけピンポイントで取得したい)」という要件において、メモリ爆発を防ぐための最強の武器。
- 周辺オプションの同時指定:
`’fields’ => ‘ids’` を使うだけでなく、`no_found_rows` やメタ・タームキャッシュの無効化をセットで運用して初めて、真のパフォーマンスチューニングと言える。
「動くコード」から「スケールするコード」へ。WordPressの内部挙動を完全に掌握し、リソースを極限まで最適化された美しいシステムを構築しよう。