【実務・中級編】初心者向け:WP_Queryの「orderby」で「post_date」以外を指定する際のパフォーマンス低下の仕組み – WordPress 内部コア・データベース構造とパフォーマンス最適化解析バイブル

コードレビュー中、ジュニアエンジニアから提出されたPR(プルリクエスト)を見て、私は思わずため息をついた。

// レビュー対象のコード
$args = array(
‘post_type’ => ‘product’,
‘posts_per_page’ => 20,
‘meta_key’ => ‘_price’,
‘orderby’ => ‘meta_value_num’,
‘order’ => ‘ASC’,
);
$query = new WP_Query( $args );

「一見、何の問題もないように見えるだろ? だが、このクエリが数百万レコードを抱えるプロダクション環境にデプロイされた瞬間、データベースのCPU使用率が跳ね上がり、スロークエリの嵐を引き起こす。なぜかわかるか?」

もし君がこの質問に即答できないなら、WordPressのデータベース構造と、MySQLが裏側で何を行っているかの理解がまだ浅いと言わざるを得ない。

今回は、`WP_Query`の`orderby`指定が引き起こすパフォーマンス劣化のメカニズム、MySQLの内部動作である 「Using filesort」 の正体、そして大規模サイトでも破綻しない堅牢なクエリ設計について、コアの内部構造を踏まえて徹底的に解説しよう。

—

1. なぜ `post_date` 以外でのソートは重いのか?

WordPressの心臓部であるMySQLデータベースにおいて、投稿データを格納するメインテーブルは `wp_posts` である。

デフォルトの状態では、`wp_posts` テーブルの主キー(Primary Key)は `ID` であり、インデックス(B-Tree)は `ID` や `post_date`、`post_type` などに対して適切に貼られている。

ここで `orderby => ‘post_date’`(デフォルト)を指定した場合、クエリの実行計画(EXPLAIN)を見ると以下のようになる。

  • インデックススキャン: すでに日付順にソートされたインデックスツリーを上から(または下から)順番に参照するため、MySQLは追加のソート処理を行う必要がない。

しかし、これが `meta_value_num` や `title`、あるいはカスタムフィールドの値によるソートに変わった瞬間、データベースの挙動は一変する。

悪名高き「Using filesort」の発生メカニズム

インデックスが存在しない(あるいはクエリの条件とインデックスの順序が一致しない)カラムでソートを行おうとすると、MySQLは以下の重い処理を強制される。

1. 全行の取得(またはインデックスによる絞り込み): 条件に合致するレコードをディスク(またはメモリ)から取得する。
2. 一時バッファへの書き込み(Sort Buffer): 取得したデータセットを `sort_buffer_size` で定められたメモリ領域に読み込ませる。
3. インメモリ/ディスクソート(Filesort): メモリ上で収まらない場合は一時ファイル(テンポラリファイル)をディスク上に作成し、クイックソートなどのアルゴリズムで並び替えを実行する。

これが MySQL の実行計画で `Extra: Using filesort` と表示される現象の正体だ。
ディスクI/Oが発生するファイルソートは、データベースのパフォーマンスを劇的に低下させる最大の要因の一つである。メタデータ(`wp_postmeta`)を絡めた結合(JOIN)とソートが発生しようものなら、O(N log N) のオーダーの計算量がシステム全体に重い負荷としてのしかかる。

—

2. 現場で使える!パフォーマンスを犠牲にしない設計パターン

では、価格順やカスタムフィールド順でのソートが必要なプロダクション環境では、どうコードを書くべきか?
「動けばいい」ではなく、データベースのインデックスを効率的に利用する(= `Using filesort` を回避する)ための設計パターンを授けよう。

アンチパターン:愚直な `meta_value_num` 指定

先ほど挙げたコードは、`wp_postmeta` との内部結合を引き起こし、かつインデックスの効かない動的ソートを行うため最悪だ。投稿数が10万件を超えたあたりからレスポンスが数秒に悪化する。

改善アプローチ 1: Transient API(オブジェクトキャッシュ)による結果のキャッシュ

もしソート結果がリアルタイム性をそこまで求められない場合(例:ECサイトの人気順、おすすめ順など)、クエリそのものをキャッシュするのが最も確実でローコストだ。

/

  • 負荷の高いカスタムソートクエリをTransientでキャッシュする堅牢な実装

/
function get_optimized_sorted_products( int $paged = 1 ): WP_Query {
$cache_key = ‘hp_sorted_products_page_’ . $paged;
$cached_query_vars = get_transient( $cache_key );

if ( false !== $cached_query_vars ) {
// キャッシュからID配列を取得してWP_Queryを構築(メタデータやfilesortをバイパス)
return new WP_Query( array(
‘post_type’ => ‘product’,
‘post__in’ => $cached_query_vars,
‘orderby’ => ‘post__in’, // 取得順序を維持
‘posts_per_page’ => 20,
) );
}

// 重いクエリはキャッシュヒットしない初回のみ実行
$query = new WP_Query( array(
‘post_type’ => ‘product’,
‘posts_per_page’ => 20,
‘paged’ => $paged,
‘meta_key’ => ‘_price’,
‘orderby’ => ‘meta_value_num’,
‘order’ => ‘ASC’,
‘fields’ => ‘ids’, // オブジェクト全体ではなくIDのみを取得してメモリ消費を抑制
) );

// 取得したIDの配列をキャッシュ(有効期限1時間)
set_transient( $cache_key, $query->posts, HOUR_IN_SECONDS );

return new WP_Query( array(
‘post_type’ => ‘product’,
‘post__in’ => $query->posts,
‘orderby’ => ‘post__in’,
‘posts_per_page’ => 20,
));
}

このコードのポイントは `’fields’ => ‘ids’` を使って軽量なIDリストだけを最初に取得し、2回目の `WP_Query` では `’post__in’` と `’orderby’ => ‘post__in’` を使って `wp_posts` の主キーインデックスをフル活用している点だ。これにより、filesortのコストを初回のリクエストのみに限定し、2回目以降は爆速のレスポンスを実現できる。

—

改善アプローチ 2: 検索・ソート専用カラムへのデータ非正規化(究極のパフォーマンス)

数百万件規模のエンタープライズ案件において、カスタムフィールドの値をメタテーブル(別テーブル)に入れたまま結合・ソートするのは設計の敗北と言っていい。

本当にパフォーマンスを追求するなら、「ソート専用のプレフィックス付きカラムを `wp_posts` 自体に生やす」 か、あるいは カスタムテーブルを切り、適切なインデックスを張る べきだ。

例えば、価格順ソートを高速化するために、メタデータを `wp_posts.post_meta_price` のような独自カラム(数値型)に同期させる設計をとる。

/

  • 投稿保存時にカスタムフィールドの値をwp_postsの専用カラムに同期させる
  • (※事前にwp_postsに DECIMAL型 の `sort_price` カラムを追加している前提)

/
function sync_product_price_column( int $post_id, WP_Post $post, bool $update ) {
// リビジョンや自動保存、異なる投稿タイプはスキップ
if ( ‘product’ !== $post->post_type || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}

$price = get_post_meta( $post_id, ‘_price’, true );
if ( ” === $price ) {
return;
}

global $wpdb;
// 直接UPDATE文を発行して高速に同期(WPの標準関数をバイパスしてオーバーヘッドを削減)
$wpdb->update(
$wpdb->posts,
array( ‘sort_price’ => floatval( $price ) ),
array( ‘ID’ => $post_id ),
array( ‘%f’ ),
array( ‘%d’ )
);
}
add_action( ‘save_post’, ‘sync_product_price_column’, 10, 3 );

この設計を行えば、`WP_Query` 側では以下のように `orderby` を安全にハンドリングできる(※カスタムカラムでのソートには `posts_clauses` フィルターを使用)。

/

  • 専用カラムを使ったインデックス効かす高速クエリの構築

/
function get_ultra_fast_sorted_products( string $order = ‘ASC’ ) {
$args = array(
‘post_type’ => ‘product’,
‘posts_per_page’ => 20,
// カスタムソート用のプレースホルダー(posts_clausesでフックする)
‘custom_orderby’ => ‘sort_price’,
‘custom_order’ => strtoupper( $order ),
);

add_filter( ‘posts_clauses’, ‘apply_custom_sort_clauses’, 10, 2 );
$query = new WP_Query( $args );
remove_filter( ‘posts_clauses’, ‘apply_custom_sort_clauses’, 10 );

return $query;
}

function apply_custom_sort_clauses( array $clauses, WP_Query $query ): array {
$orderby = $query->get( ‘custom_orderby’ );
$order = $query->get( ‘custom_order’ );

if ( ! empty( $orderby ) && in_array( $order, array( ‘ASC’, ‘DESC’ ), true ) ) {
global $wpdb;
// wp_postsに貼られたインデックスが直接ヒットするため、filesortが発生しない
$clauses[‘orderby’] = “{$wpdb->posts}.{$orderby} {$order}”;
}

return $clauses;
}

このアプローチを取ることで、MySQLのオプティマイザは `wp_posts` テーブルの `sort_price` カラムに張られたインデックス(B-Tree)を寸分違わず使用し、`Using filesort` を完全に排除した秒速のクエリ実行を実現できる。

—

テクニカルリードからの総括

WordPressは「手軽にブログを作れるCMS」という顔を持つ一方で、安易なクエリ設計を許容してしまうと、途端にスケーラビリティを失う諸刃の剣だ。

`orderby` の裏側で何が起きているのか。
MySQLがどのメモリを消費し、なぜディスクI/Oが発生するのか。

それらを頭の中でコードから逆算できるエンジニアこそが、真の意味で「WordPressを掌握している」と言える。動くコードを書くのはプログラマーの仕事だが、「負荷に耐え、スケールするコードを書く」のはエンジニアの責務だ。

次のコードレビューでは、無邪気な `meta_key` や複雑なソート条件を見つけたら、今日の話を思い出してほしい。そして、自信を持ってこう指摘してやりたまえ。

――「おい、そのクエリ、Filesortが走るぞ」と。

タイトルとURLをコピーしました