限界を超えろ:`wp_term_relationships` の肥大化が引き起こすデータベース破綻と、極限のインデックス戦略
大規模なメディアサイトや、数百万件のカスタム投稿を抱えるECサイトの運用において、エンジニアが最初に直面する性能の壁は、多くの場合 `WP_Query` の背後でうごめくリレーショナルデータベースの悲鳴である。
特に、投稿(`wp_posts`)とターム(`wp_terms`)を結びつける中間テーブル、`wp_term_relationships` の肥大化は、インデックスの不整合やMySQLのオプティマイザの誤認を誘発し、サイト全体のスループットを致命的に低下させる。
本稿では、このテーブルが抱える構造的欠陥と、それをねじ伏せるための高度なインデックスチューニング、そしてタクソノミー設計の極意を、データベースの内部挙動(レイヤ)から徹底的に解剖する。
—
1. なぜ `wp_term_relationships` はボトルネックになるのか?
複合主キーの罠とカーディナリティ
`wp_term_relationships` テーブルの構造を改めて確認する。
CREATE TABLE wp_term_relationships (
object_id bigint(20) unsigned NOT NULL default 0,
term_taxonomy_id bigint(20) unsigned NOT NULL default 0,
term_order int(11) NOT NULL default 0,
PRIMARY KEY (object_id, term_taxonomy_id),
KEY term_taxonomy_id (term_taxonomy_id)
) ENGINE=InnoDB;
一見して非常にシンプルな構造だが、ここに大規模運用における罠が潜んでいる。
1. プライマリキーの順序 (`object_id`, `term_taxonomy_id`):
「ある投稿に紐づくタームを取得する」クエリ(例: `get_the_terms()`)に対しては極めて高速に動作する。しかし、逆の操作――「特定のタームに属する投稿IDを効率的に取得する」場合、プライマリキーのプレフィックス(最左列)が `object_id` であるため、このプライマリキーは機能せず、セカンダリインデックスである `term_taxonomy_id` が強制される。
2. カーディナリティの偏り:
例えば「未分類」や「おすすめ」のような汎用タームに数百万件の `object_id` が集中した場合、`term_taxonomy_id` インデックスの選択性(Selectivity)は著しく低下する。MySQLのオプティマイザ(Cost-Based Optimizer)は、インデックススキャンよりもフルテーブルスキャン(あるいは全インデックススキャン)の方がコストが低いと誤認し、ディスクI/Oがスパイクする。
`WP_Query` が発行する暗黒の JOIN
タクソノミーを指定した `WP_Query`(例: `tax_query` を使用)を実行した際、WordPressは内部で以下のような重いSQLを生成する。
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
INNER JOIN wp_term_relationships
ON (wp_posts.ID = wp_term_relationships.object_id)
WHERE 1=1
AND (wp_term_relationships.term_taxonomy_id IN (12345))
AND wp_posts.post_type = ‘post’
AND wp_posts.post_status = ‘publish’
GROUP BY wp_posts.ID
ORDER BY wp_posts.post_date DESC
LIMIT 0, 10;
レコード数が数千万規模に達したとき、この `INNER JOIN` と `GROUP BY`、そして何よりページネーションのために付与される `SQL_CALC_FOUND_ROWS` が、InnoDBのバッファプールを揺さぶり、ランタイムの実行時間を数百ミリ秒から数秒へと劣化させる。
—
2. データベース層でのアプローチ:インデックスの再構築と最適化
WordPressコアの仕様をそのままに、データベースの物理層でこの負荷をいなすための実践的アプローチを解説する。
カバリングインデックス(Covering Index)の導入
通常のセカンダリインデックス(`term_taxonomy_id`)だけでは、インデックスツリーの葉ノードから実際のデータ行(あるいはクラスタ化インデックス)へアクセスする「ブックマークルックアップ(Random I/O)」が発生する。
これを防ぐため、InnoDBの仕様を利用したカバリングインデックスを追加する。
— object_id を含めた複合インデックスを張ることで、インデックス内だけでクエリを完結させる
ALTER TABLE wp_term_relationships
ADD INDEX idx_taxonomy_object (term_taxonomy_id, object_id);
このインデックスにより、`term_taxonomy_id` で絞り込んだ `object_id` のリストをメモリ上のインデックスツリー走査だけで完了できるようになり、ランダムI/Oを劇的に削減できる。
`SQL_CALC_FOUND_ROWS` の殺害
WordPress 4.9以降、議論の的になり続けている `SQL_CALC_FOUND_ROWS` は、全件数を強制的にカウントするため、インデックスが効いていても最後にテーブル全体をスキャンするようなコストを支払わされる。
これをコードレベルで無効化し、パフォーマンスを強制的に引き上げる。
/
- WP_Query からパフォーマンスキラーである SQL_CALC_FOUND_ROWS を排除する
/
add_filter( ‘found_posts_query’, function( $sql, \WP_Query $query ) {
// 管理画面や、正確な総数が必要な特殊なクエリ以外ではカウントを捨てる
if ( ! is_admin() && $query->is_main_query() ) {
return ‘SELECT FOUND_ROWS()’; // 実際にはカウントクエリ自体を無効化する処理へ差し替える
}
return $sql;
}, 10, 2 );
// 代替として、FOUND_ROWS を使わない軽量なカウントロジックを担保する
add_filter( ‘pre_handle_404’, function( $pre, \WP_Query $query ) {
if ( ! $query->get( ‘no_found_rows’ ) ) {
$query->set( ‘no_found_rows’, true );
}
return $pre;
}, 10, 2 );
—
3. アプリケーション層でのアプローチ:タクソノミー設計のアンチパターン回避
データベースをいじる前に、「そもそもなぜ `wp_term_relationships` が肥大化したのか」という設計上の欠陥を疑うべきである。
アンチパターン:何でもかんでもタクソノミー化する病
「カスタムフィールド的にタクソノミーを使う」「ユーザーのログや履歴、状態(既読/未読など)をタームとして保存する」といった設計は、データベースの自殺行為である。
- 悪例: 1人のユーザーの既読状態を、投稿ごとのカスタムタクソノミーとして保存し、`wp_term_relationships` に数千万件のレコードを生成する。
- 正解: 状態管理やトランザクショナルなデータは、`wp_postmeta` の個別メタデータ、あるいはカスタムテーブル(`wp_user_reading_history` など)へ分離する。タクソノミーは本来の「階層的・非階層的な分類・構造化」のためにのみ使うべきである。
キャッシュ戦略の極限:オブジェクトキャッシュとTransient APIの限界
`wp_term_relationships` へのクエリ頻度を減らす最も確実な方法は、「データベースにクエリを到達させないこと」だ。RedisやMemcachedを用いたExternal Object Cacheの導入は必須条件だが、それだけでは不十分な場合がある。
複雑なターム階層や、大量の投稿IDを返すカスタムクエリに対しては、Transient APIを用いた独自のクエリ結果キャッシュを実装する。
/
- 高負荷なタクソノミー・ターム紐付けクエリの結果をキャッシュする堅牢なラッパー関数
- @param int $term_id
- @return array 投稿IDの配列
/
function get_optimized_posts_by_term( int $term_id ): array {
$cache_key = ‘opt_posts_term_’ . $term_id;
$cached_ids = wp_cache_get( $cache_key, ‘taxonomy_optimization’ );
if ( false !== $cached_ids ) {
return $cached_ids;
}
// データベースから直接、必要なカラム(object_id)のみを最小限のコストで取得
global $wpdb;
$query = $wpdb->prepare(
“SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id = %d ORDER BY object_id DESC LIMIT 100”,
$term_id
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$object_ids = $wpdb->get_col( $query );
// 永続オブジェクトキャッシュへ保存 (TTL: 1時間)
wp_cache_set( $cache_key, $object_ids, ‘taxonomy_optimization’, HOUR_IN_SECONDS );
return $object_ids;
}
/
- タームの割り当てが変更されたら、該当するキャッシュを即座にパージする
/
add_action( ‘edited_term_relationships’, function( $object_id, $term_taxonomy_id ) {
// 関連するキャッシュグループまたは特定キーをクリア
wp_cache_delete( ‘opt_posts_term_’ . $term_taxonomy_id, ‘taxonomy_optimization’ );
}, 10, 2 );
—
4. チーフアーキテクトからの提言
WordPressは「ブログエンジン」として生まれ、その柔軟性ゆえにあらゆる規模のシステムへと拡張されてきた。しかし、リレーショナルデータベースの物理的制約を無視した拡張はいずれ破綻を迎える。
数百万件を超える `wp_term_relationships` を前にしたとき、プラグインの機能追加や安易なコード修正は何の解決にもならない。必要なのは以下の3点に集約される。
1. データモデリングの再考: タクソノミーとして保存すべきデータと、カスタムテーブルに逃がすべきデータの厳格な分離。
2. ストレージエンジンの特性を捉えたインデックスチューニング: カバリングインデックスを活用したランダムI/Oの撲滅。
3. データベースへのアクセス頻度をゼロにするキャッシュアーキテクチャの構築。
システムの本質を見極め、コードとクエリの背後で何が起きているのかを脳内で完全にトレースできる者だけが、WordPressという巨大なランタイムを完全に掌握し、極限のパフォーマンスを実現できるのである。