WP_Queryの闇を暴く:実行計画をリアルタイムで監視する「Slow Query Logger」の自作
コードレビューをしていて、次のような `WP_Query` に遭遇したことはないだろうか。
$args = array(
‘post_type’ => ‘post’,
‘s’ => ‘WordPress’,
‘meta_query’ => array(
‘relation’ => ‘OR’,
array(
‘key’ => ‘_view_count’,
‘value’ => 1000,
‘compare’ => ‘>’,
‘type’ => ‘NUMERIC’,
),
array(
‘key’ => ‘_is_featured’,
‘value’ => ‘1’,
‘compare’ => ‘=’,
),
),
‘orderby’ => ‘meta_value_num’,
‘meta_key’ => ‘_view_count’,
);
$query = new WP_Query( $args );
エンジニアなら直感的に冷や汗をかくはずだ。
`s`(全文検索)による `LIKE` 地獄、`meta_query` の `OR` 条件による一時テーブルの生成、そして `meta_value_num` による filesort。これらはMySQLのオプティマイザを完全に沈黙させ、高トラフィック下ではデータベースCPU使用率を100%に張り付かせる最高の爆弾となる。
「なぜこのクエリが遅いのか?」を推測で語るフェーズはもう終わりだ。
今回は、本番環境でしきい値を超えた `WP_Query` を自動検知し、MySQLの `EXPLAIN` 解析結果を添えてSlackへリアルタイム通知する「Slow Query Logger」のプロダクションコードを解説する。
—
1. アーキテクチャ設計:なぜ `SAVEQUERIES` では不十分なのか?
WordPress標準の `SAVEQUERIES` 定数を利用したデバッグ手法は、すべてのクエリ配列をメモリ上に保持するため、本番環境での常時有効化はメモリリーク(Fatal Error)直結の自殺行為となる。また、発行されたSQL文は記録できても、そのSQLがMySQL内部でどのような実行計画(Execution Plan)で処理されているかまでは追えない。
我々が実装すべき要件は以下の通りだ。
1. メモリ効率の最大化: 全クエリの蓄積ではなく、実行時間が指定ミリ秒を超えた(Slow Query)ものだけをピンポイントでキャッチする。
2. データベースへの負荷最小化: `EXPLAIN` の実行は、該当の遅いクエリに対してのみ非同期(または処理の終端)で実行する。
3. オブザーバビリティ(可観測性): どのPHPコード(呼び出し元スタックトレース)からそのクエリが発行されたのかを特定できること。
—
2. プロダクションコード:Slow Query Logger
以下のコードは、単なるスニペットではない。依存性注入(DI)やプレフィックス考慮、エラーハンドリングを考慮した、そのまま実務に投入できるクラス設計だ。
/
namespace Enterprise\Performance;
if ( ! defined( ‘ABSPATH’ ) ) {
exit;
}
class Slow_Query_Logger {
/
- 遅延とみなす閾値(ミリ秒)
- @var float
/
private $threshold_ms;
/
- Slack Webhook URL
- @var string
/
private $slack_webhook_url;
/
- 計測開始時間
- @var float
/
private $start_time = 0.0;
/
- コンストラクタ
- @urator float $threshold_ms
- @param string $slack_webhook_url
/
public function __construct( float $threshold_ms = 200.0, string $slack_webhook_url = ” ) {
$this->threshold_ms = $threshold_ms;
$this->slack_webhook_url = $slack_webhook_url;
$this->init_hooks();
}
/
- フックの登録
/
private function init_hooks(): void {
// WP_Queryの構築開始時にタイマーをセット
add_action( ‘parse_query’, array( $this, ‘start_timer’ ), 10, 1 );
// SQLが発行され、結果が取得される直前のフィルターを利用して計測
add_filter( ‘posts_request’, array( $this, ‘measure_query’ ), 10, 2 );
}
/
- タイマーの開始
- @param \WP_Query $query
/
public function start_timer( \WP_Query $query ): void {
// 管理画面やAjax、Cronを除外する場合はここでガードを入れる
if ( is_admin() && ! wp_doing_ajax() ) {
return;
}
$this->start_time = microtime( true );
}
/
- クエリの実行時間を計測し、遅延していれば解析へ回す
- @param string $sql
- @param \WP_Query $query
- @return string
/
public function measure_query( string $sql, \WP_Query $query ): string {
if ( 0.0 === $this->start_time ) {
return $sql;
}
$duration = ( microtime( true ) – $this->start_time ) 1000;
$this->start_time = 0.0; // リセット
if ( $duration >= $this->threshold_ms ) {
$this->handle_slow_query( $sql, $duration, $query );
}
return $sql;
}
/
- スロークエリのハンドリングと解析
- @param string $sql
- @param float $duration
- @param \WP_Query $query
/
private function handle_slow_query( string $sql, float $duration, \WP_Query $query ): void {
global $wpdb;
// EXPLAINの取得(セキュリティのためプリペアドステートメントの代わりに安全な構文構築)
// ※$sqlにはすでにWP_Queryによってサニタイズされた値が入っている
$explain_results = $wpdb->get_results( “EXPLAIN {$sql}”, ARRAY_A );
// コールスタック(どこから呼ばれたか)の取得
$stack_trace = $this->get_filtered_backtrace();
// 通知データの構築
$payload = array(
‘duration’ => round( $duration, 2 ),
‘sql’ => $sql,
‘query_vars’ => $query->query_vars,
‘explain’ => $explain_results,
‘stack_trace’ => $stack_trace,
‘request_uri’ => $_SERVER[‘REQUEST_URI’] ?? ‘CLI/Unknown’,
);
// 非同期(あるいはシャットダウン時)に送信するのが望ましいが、簡易的にログ出力&Slack送信
$this->dispatch_alert( $payload );
}
/
- ノイズを除外したバックトレースを取得
- @return array
/
private function get_filtered_backtrace(): array {
$trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 10 );
$filtered = array();
$core_classes = array( ‘WP_Query’, ‘Enterprise\\Performance\\Slow_Query_Logger’ );
foreach ( $trace as $t ) {
if ( isset( $t[‘class’] ) && in_array( $t[‘class’], $core_classes, true ) ) {
continue;
}
$filtered[] = sprintf(
‘%s:%d (%s%s%s)’,
$t[‘file’] ?? ‘unknown’,
$t[‘line’] ?? 0,
$t[‘class’] ?? ”,
$t[‘type’] ?? ”,
$t[‘function’] ?? ”
);
}
return array_slice( $filtered, 0, 5 );
}
/
- Slackへアラートを送信
- @param array $payload
/
private function dispatch_alert( array $payload ): void {
error_log( sprintf( ‘[SLOW WP_QUERY] Execution time: %s ms’, $payload[‘duration’] ) );
if ( empty( $this->slack_webhook_url ) ) {
return;
}
$message = sprintf(
“:rotating_light: WordPress Slow WP_Query Detected!\n” .
“• Time: `%s ms` (Threshold: %s ms)\n” .
“• URI: `%s`\n” .
“• SQL: %s\n” .
“• Caller: %s”,
$payload[‘duration’],
$this->threshold_ms,
esc_html( $payload[‘request_uri’] ),
esc_html( $payload[‘sql’] ),
esc_html( implode( “\n”, $payload[‘stack_trace’] ) )
);
// WP HTTP APIを利用したノンブロッキングな送信(timeoutを短く設定)
wp_remote_post(
$this->slack_webhook_url,
array(
‘body’ => json_encode( array( ‘text’ => $message ) ),
‘headers’ => array( ‘Content-Type’ => ‘application/json’ ),
‘timeout’ => 2,
‘blocking’ => false, // 非同期で処理をブロックしない
)
);
}
}
// 初期化(環境変数や定数から設定を読み込む設計がベストプラクティス)
if ( defined( ‘WP_DEBUG’ ) && WP_DEBUG ) {
new Slow_Query_Logger( 150.0, ‘https://hooks.slack.com/services/YOUR/WEBHOOK/URL’ );
}
—
3. コードレビューの視点:なぜこの実装が堅牢なのか?
シニアエンジニアの視点から、このコードの設計思想を解説する。
① `parse_query` と `posts_request` のフック分離
タイマーの計測開始に `parse_query` を使い、SQL構築完了の検知に `posts_request` を採用している点に注目してほしい。
これにより、`WP_Query` クラスがパラメータを解釈し、実際にMySQLへ投げるSQL文字列を生成するまでの正確なコスト(PHP側のパース時間含む)を計測できる。
② ノンブロッキングなSlack通知 (`’blocking’ => false`)
本番環境において、外部API(Slack)への通信がタイムアウトを起こしたせいで、Webサイト全体のレスポンスが遅延するのは最悪のアンチパターンだ。`wp_remote_post` の `’blocking’ => false` により、ログ送信を完全に非同期化し、ユーザー体験への影響をゼロに抑えている。
③ バックトレースのフィルタリング
スタックトレースをすべて出力するとログが肥大化するだけでなく、WordPressコアの内部処理(`WP_Query->get_posts()` など)ばかりが並び、肝心の「どのテーマ/プラグインのコードがこのクエリを呼んだのか」が見えなくなる。
ここでは `WP_Query` 自体のクラスやロガー自身のメソッドを再帰的に除外(ブラックリスト方式)することで、原因箇所の特定速度を劇的に向上させている。
—
4. 現場ですぐ使えるインデックスチューニングの極意
このロガーによってSlackに飛んできたクエリの `EXPLAIN` 結果をどう読み解くべきか。実務で頻出するボトルネックと対策をまとめる。
| EXPLAINの兆候 (Type / Extra) | 原因 | 対策(インデックスチューニング) |
| :— | :— | :— |
| `type: ALL`
`Extra: Using where` | テーブルフイスキャン(全件走査)。インデックスが全く効いていない。 | 検索条件に含まれる `meta_key` や `post_date` に複合インデックスを貼る。 |
| `Extra: Using filesort` | `ORDER BY` の順序を解決するために一時ファイルソートが発生している。 | ソートキーとWHERE句の条件カラムで適切なマルチカラムインデックスを構築する。 |
| `type: index`
`Extra: Using index` | インデックスツリーの全件走査。データ行は読まないがコストが高い。 | クエリの絞り込み条件(WHERE)を見直し、カーディナリティ(値の分散度)の高いカラムを条件に加える。 |
WordPress特有の罠:`meta_query` の最適化
WordPressの `wp_postmeta` テーブルは汎用的な設計(EAVパターン)であるため、複数のメタデータを `meta_query` で結合(JOIN)させると、MySQLのオプティマイザは容易に迷子になる。
もし特定のカスタムフィールドによる絞り込みとソートが頻発するのであれば、`WP_Query` に頼るのではなく、カスタムテーブル(Custom Table)を別途切り、そこに適切なスキーマとインデックスを定義した上で `$wpdb->get_results()` で直接クエリを叩く設計判断こそが、真のフルスタックエンジニアの選択である。
—
結び
パフォーマンスチューニングは「勘と経験」で行うものではない。「計測し、可視化し、論理的に潰す」というエンジニアリングの基本の積み重ねだ。
今回紹介した `Slow_Query_Logger` をコードベースに組み込むことで、あなたのWordPressアプリケーションは、高負荷なトラフィックに対しても揺るぎない堅牢性を手に入れるだろう。