WordPressデータベースの物理限界:wp_postsの行サイズ制約とTEXT型が引き起こすパフォーマンスの崩壊
コードレビューをしていて、次のようなカスタムクエリに出くわしたことはないだろうか。
// 最悪な実装例:絶対にやってはいけないアンチパターン
$args = array(
‘post_type’ => ‘product’,
‘posts_per_page’ => 20,
‘orderby’ => ‘meta_value_num’,
‘meta_key’ => ‘_price’,
‘s’ => ‘keyword’,
);
$query = new WP_Query( $args );
一見、何の問題もない標準的なWP_Queryに見えるかもしれない。しかし、これが数百万レコードを抱えるプロダクション環境で実行された瞬間、データベースのCPU使用率は跳ね上がり、スロークエリの嵐を引き起こす。
原因は、多くのエンジニアが「WordPressの抽象化層(WP_QueryやメタAPI)」の裏側にあるMySQLの物理ストレージ層の挙動を無視しているからだ。
今回は、`wp_posts`テーブルの物理構造、特に`post_content`をはじめとする`LONGTEXT`型カラムがクエリの実行計画(EXPLAIN)とメモリ内ソートに与える致命的な影響を解剖し、インデックスのみでクエリを完結させるための堅牢な設計指針を伝授する。
—
1. 物理的背景:なぜ `post_content` はパフォーマンスの癌なのか?
InnoDBの行サイズ制限とオフページ・ストレージ
MySQLのデフォルトストレージエンジンであるInnoDBでは、1ページあたりのサイズは通常16KBに設定されている。行のデータがこのサイズを超えない場合、すべてのカラムデータはクラスタ化インデックス(B-Tree)の同じページ内に格納される(In-line)。
しかし、`wp_posts`の`post_content`(`LONGTEXT`型)や`post_excerpt`は、どれだけデータが小さくても、あるいは数メガバイトのHTMLやJSONであっても、行外(Off-page / 外部ページ)に格納される。
InnoDBは、行外に格納されたラージオブジェクト(LOB)へアクセスするために、「BLOBポインタ(20バイトのオーバーヘッド)」を経由する。
メモリ内ソート(Filesort)と一時テーブルの悲劇
ここで問題になるのが、`ORDERBY`や`GROUPBY`を含むクエリを実行した際の水面下の挙動だ。
MySQLがインデックスを利用してソートを完了できない場合、Filesort(ファイルソート)が発生する。この時、MySQLはソート対象のデータをメモリ上に一時テーブル(Temporary Table)として展開しようとする。
ここでMySQLの古いバージョンや設定不備、あるいはクエリの書き方によって、一時テーブルに`LONGTEXT`型を含む行データ全体がコピーされることがある。
1. `wp_posts`から条件に一致する行をスキャンする。
2. `post_content`のポインタを辿り、実データをメモリ上に読み込む。
3. ソートバッファ(`sort_buffer_size`)の容量を瞬時に超過し、ディスク上の一時テーブル(Temporary Table on Disk / MyISAMまたはInnoDB)への書き込みが発生する。
SSD環境であっても、ディスクI/Oを伴う一時テーブルの生成はデータベース全体のスループットを劇的に低下させる。これが、「記事が増えた途端にWordPressのページングが重くなる」根本的な物理的原因である。
—
2. 実行計画(EXPLAIN)から読み解く「最悪のシナリオ」
次のような、よくあるページングクエリを考えてみよう。
SELECT FROM wp_posts
WHERE post_type = ‘post’ AND post_status = ‘publish’
ORDER BY post_date DESC
LIMIT 0, 20;
`wp_posts`には `type_status_date` のような複合インデックスが存在しない場合、あるいはクエリがインデックスを効率的に使えない場合、MySQLは全件スキャン(Full Table Scan)を行い、ファイルをソートし、`SELECT ` によってすべてのカラム(もちろん `post_content` を含む)を取得する。
これを防ぐための鉄則は一つしかない。「必要なカラム以外は絶対にSELECTしない、そしてインデックスだけでソートを完結させる(Covering Index)」ことだ。
—
3. 実務で使える堅牢な設計指針とプロダクションコード
ここからは、抽象化されたWP_Queryの限界を突破し、データベースの物理負荷を極限まで削減するための実践的なコードパターンを解説する。
指針A: `posts_fields` フィルターで不要なTEXT型を排除する
WP_Queryはデフォルトで `SELECT wp_posts.` を発行する。これにより、不要な `post_content`, `post_content_filtered`, `to_ping` などの巨大なテキストデータがメモリにロードされる。
一覧表示やページング用のクエリでは、必要なIDのみ、あるいは軽量なカラムのみをSELECTするようにフックを挟むべきである。
以下は、パフォーマンスを極限まで高めたカスタムクエリの設計パターンだ。
/
- Class HighPerformance_Post_Repository
- wp_postsの物理制約を回避し、インデックスのみで高速なページングを実現するリポジトリクラス。
/
class HighPerformance_Post_Repository {
/
- 軽量化された投稿一覧を取得する(テキストカラムを排除)
- @param int $paged
- @param int $per_page
- @return array 投稿IDの配列とページング情報
/
public static function get_paginated_post_ids( int $paged = 1, int $per_page = 20 ): array {
global $wpdb;
$offset = ( max( 1, $paged ) – 1 ) $per_page;
// 【設計のポイント】
// 1. post_contentなどのTEXT型を一切SELECTしない。
// 2. プレフィックスをハードコードせず $wpdb->posts を使用。
// 3. インデックス(post_type, post_status, post_date)を完全にヒットさせるクエリ構造。
$query = $wpdb->prepare(
“SELECT ID FROM {$wpdb->posts}
WHERE post_type = %s
AND post_status = %s
ORDER BY post_date DESC
LIMIT %d OFFSET %d”,
‘post’,
‘publish’,
$per_page,
$offset
);
// キャッシュ戦略:オブジェクトキャッシュ層(Redis/Memcached)の活用
$cache_key = ‘hp_posts_paged_’ . $paged . ‘_’ . $per_page;
$cached_ids = wp_cache_get( $cache_key, ‘high_performance_posts’ );
if ( false !== $cached_ids ) {
return $cached_ids;
}
$post_ids = $wpdb->get_col( $query );
// キャッシュに保存(TTLはイベント駆動でパージする設計にすること)
wp_cache_set( $cache_key, $post_ids, ‘high_performance_posts’, HOUR_IN_SECONDS );
return $post_ids;
}
/
- IDリストからオブジェクトを一括取得し、WordPressの標準キャッシュにプライミングする
- @param array $post_ids
/
public static function prime_post_cache( array $post_ids ): void {
if ( empty( $post_ids ) ) {
return;
}
// _prime_post_caches はコア内部関数だが、WP_Queryの肥大化を防ぐために直接利用価値が高い
if ( function_exists( ‘_prime_post_caches’ ) ) {
_prime_post_caches( $post_ids, true, true );
}
}
}
指針B: カバリングインデックス(Covering Index)の自前定義
もし標準のWP_Queryや上記のリポジトリクエリをさらに高速化したい場合、MySQL側のインデックス設計を見直す必要がある。
デフォルトのWordPressには、複合インデックスが不足しているケースが多い。特に大規模サイトでは以下のインデックスの追加をDBAに要請、あるいはマイグレーションスクリプトで適用することを検討すべきだ。
— wp_posts に対する高パフォーマンスな複合インデックスの例
— post_type と post_status で絞り込み、post_date でソートするクエリをインデックスのみで完結させる
ALTER TABLE wp_posts ADD INDEX idx_type_status_date (post_type, post_status, post_date);
このインデックスが存在する場合、MySQLはテーブル本体(データファイル)にアクセスすることなく、インデックスツリーの走査だけで該当するレコードのIDとソート順を特定できる(これをインデックスカバリングと呼ぶ)。`post_content` のような巨大なTEXT型カラムの存在を完全に無視してクエリを処理できるため、メモリ消費量は劇的に削減される。
—
4. テクニカルリードからの総括
WordPressは「誰でも簡単に使えるCMS」であるゆえに、内部のデータベース構造を意識せずとも動くコードが書けてしまう。しかし、その抽象化の代償として、数万〜数百万規模のスケールに直面した瞬間にシステムは崩壊する。
プロダクションコードを書くエンジニアとして持つべき視点は以下の3点だ。
1. 「`SELECT ` の呪縛を断ち切る」: 不要な `LONGTEXT` 型カラムをメモリにロードさせない。
2. 「Filesortを回避するインデックス設計」: クエリのWHERE句とORDER BY句が、どのインデックスツリーを通るのかを常に脳内でトレースする。
3. 「キャッシュとオブジェクトプライミングの分離」: IDのリスト(軽量)と実データの取得(重量)を分離し、必要なタイミングで効率的にWordPressのキャッシュ層に流し込む。
「なんとなく動く」コードから、「物理層を支配した堅牢な」コードへ。今日のコードレビューから、この知見をあなたのチームにも実装してほしい。