【テクニカル・上級編】wp_term_relationshipsの結合負荷を軽減する:タクソノミー階層のフラット化と非正規化設計 – WordPress 内部コア・データベース構造とパフォーマンス最適化解析バイブル

なぜ標準のタクソノミー設計はスケール限界を迎えるのか:InnoDBの物理制約

WordPressのタクソノミーシステムは、第3正規形(3NF)をベースにした極めて汎用性の高い設計となっている。しかし、データセットが数百万投稿・数万タームの規模に達し、かつ階層構造を持つタクソノミーにおいて「子孫タームを含む投稿の抽出」を実行した瞬間、RDBMS(InnoDB)の物理層では致命的なI/Oストームが発生する。

本稿では、`wp_term_relationships`、`wp_term_taxonomy`、`wp_terms`に依存するWordPress標準のEAV/正規化モデルが抱える物理的ボトルネックをInnoDBのストレージエンジンレベルで解剖し、非正規化マテリアライズド・ルックアップテーブルの導入によって結合コストを極限まで排除($O(1)$〜$O(\log N)$)するアーキテクチャを解説する。

—

1. 物理ボトルネックの解剖:B+Tree走査とNested Loop Join

WordPress標準の階層クエリ(例:親カテゴリを指定して全子孫カテゴリの投稿を取得する`tax_query`)は、内部的に以下の2段階で処理される。

1. メモリ上での子孫タームIDの再帰的解決(`get_term_children`)
2. 展開された全タームIDに対する `wp_term_relationships.term_taxonomy_id IN (…)` 条件を用いた結合クエリの実行

— 標準的な tax_query が生成する SQL 構造
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
LEFT JOIN wp_term_relationships
ON (wp_posts.ID = wp_term_relationships.object_id)
WHERE 1=1
AND wp_term_relationships.term_taxonomy_id IN (12, 13, 14, 15, …, 108)
AND wp_posts.post_type = ‘product’
AND (wp_posts.post_status = ‘publish’)
GROUP BY wp_posts.ID
ORDER BY wp_posts.post_date DESC
LIMIT 0, 20;

InnoDBの内部挙動とI/Oコスト

このクエリがストレージエンジンへ到達した際、以下の物理的制約が顕在化する。

[クライアント]
│
▼
┌────────────────────────────────────────────────────────┐
│ 1. get_term_children() │
│ PHPランタイムで再帰的にTerm ID配列を構築 │
│ (IN句の引数が数千件に膨張) │
└────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ 2. InnoDB: PRIMARY B+Tree (wp_term_relationships) │
│ 複合PK: (object_id, term_taxonomy_id) │
│ ※ 先頭カラムが object_id であるため、 │
│ term_taxonomy_id 単体での走査にはセカンダリ │
│ インデックス `term_taxonomy_id` を使用。 │
└────────────────────────────────────────────────────────┘
│
├─► ランダムI/O: セカンダリインデックス走査
│ term_taxonomy_id が一致する行をLeafノードから探索
│
├─► ランダムI/O: クラスタドインデックス(PK)への逆引き(Bookmark Lookup)
│ object_id を取得
│
▼
┌────────────────────────────────────────────────────────┐
│ 3. InnoDB: wp_posts との Nested Loop Join / Temporary │
│ GROUP BY wp_posts.ID を解決するために、 │
│ Using index condition; Using temporary; Using filesort│
│ が発生し、バッファプールを激しく浪費 │
└────────────────────────────────────────────────────────┘

1. インデックスの局所性破壊: `wp_term_relationships` のPKは `(object_id, term_taxonomy_id)` である。セカンダリインデックス `term_taxonomy_id` を走査して得たポインタ(`object_id`)からクラスタドインデックスへ逆引き(Bookmark Lookup)する際、メモリページが連続しておらず、バッファプール(InnoDB Buffer Pool)内でランダムアクセスが多発する。
2. 一時テーブルとFilesort: `GROUP BY wp_posts.ID` の重複排除のために、オプティマイザはディスクベースの一時テーブル(TempTableストレージエンジン / InnoDB内部一時テーブル)を作成し、ソートバッファ(`sort_buffer_size`)を超過して `Using temporary; Using filesort` を引き起こす。

—

2. アーキテクチャ設計:階層のフラット化と非正規化テーブル

この結合負荷を根本から断ち切るため、「すべての投稿は、直接の所属タームだけでなく、その上位の全先祖タームにも同時に所属している」という状態を物理テーブル上にフラットに展開(マテリアライズド)する非正規化テーブルを導入する。

最適化テーブルの物理DDL設計

CREATE TABLE `wp_post_term_flat` (
`post_id` bigint(20) unsigned NOT NULL,
`ancestor_term_id` bigint(20) unsigned NOT NULL,
`direct_term_id` bigint(20) unsigned NOT NULL,
`taxonomy` varchar(32) NOT NULL,
`depth` tinyint(3) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`ancestor_term_id`, `post_id`),
KEY `idx_post_taxonomy` (`post_id`, `taxonomy`),
KEY `idx_taxonomy_depth` (`taxonomy`, `depth`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8;

インデックス設計の物理的根拠

  • PRIMARY KEY (`ancestor_term_id`, `post_id`):

クエリ実行時、検索条件は常に `WHERE ancestor_term_id = ?` となる。クラスタドインデックスの先頭に `ancestor_term_id` を配置することで、該当ターム(およびその全子孫)に属する `post_id` がInnoDBの同一データページ内に物理的に連続して配置される。これにより、B+Treeの走査は単一のリーフノード検索+シーケンシャルリードとなり、ディスクI/Oおよびキャッシュミスを最小化する。

  • カバリングインデックスの成立:

`post_id` をPKに含めることで、`SELECT post_id FROM wp_post_term_flat WHERE ancestor_term_id = ?` はクラスタドインデックスのみで完結し、テーブルデータ本体へのアクセスが不要となる。

—

3. データ整合性同期エンジンの実装

WordPressコアのフックチェーン(`set_object_terms`, `delete_term`, `clean_term_cache`)に割り込み、データの整合性をトランザクション内でアトミックに担保する。

  • オブジェクトにタームが割り当てられた際の同期処理
  • /
    public static function onSetObjectTerms(
    int $objectId,
    array $terms,
    array $ttIds,
    string $taxonomy,
    bool $append,
    array $oldTtIds
    ): void {
    global $wpdb;

    // システム内部の不要なPostType同期をスキップ
    if (wp_is_post_revision($objectId) || wp_is_post_autosave($objectId)) {
    return;
    }

    $flatTable = $wpdb->prefix . ‘post_term_flat’;

    // 追記モードでなければ、一旦既存の当該タクソノミー割り当てを物理削除
    if (!$append) {
    $wpdb->delete($flatTable, [
    ‘post_id’ => $objectId,
    ‘taxonomy’ => $taxonomy,
    ], [‘%d’, ‘%s’]);
    }

    if (empty($ttIds)) {
    return;
    }

    $recordsToInsert = [];

    foreach ($ttIds as $ttId) {
    $termTaxonomy = get_term_by(‘term_taxonomy_id’, $ttId);
    if (!$termTaxonomy) {
    continue;
    }

    $directTermId = (int) $termTaxonomy->term_id;

    // 自身の登録 (depth = 0)
    $recordsToInsert[] = [
    ‘post_id’ => $objectId,
    ‘ancestor_term_id’ => $directTermId,
    ‘direct_term_id’ => $directTermId,
    ‘taxonomy’ => $taxonomy,
    ‘depth’ => 0,
    ];

    // 先祖(Ancestors)を取得して展開
    $ancestors = get_ancestors($directTermId, $taxonomy, ‘taxonomy’);
    $depth = 1;
    foreach ($ancestors as $ancestorId) {
    $recordsToInsert[] = [
    ‘post_id’ => $objectId,
    ‘ancestor_term_id’ => (int) $ancestorId,
    ‘direct_term_id’ => $directTermId,
    ‘taxonomy’ => $taxonomy,
    ‘depth’ => $depth++,
    ];
    }
    }

    if (empty($recordsToInsert)) {
    return;
    }

    // バルクインサートの組み立て(重複キーは無視)
    $query = “INSERT IGNORE INTO `{$flatTable}`
    (`post_id`, `ancestor_term_id`, `direct_term_id`, `taxonomy`, `depth`) VALUES “;

    $values = [];
    $placeholders = [];

    foreach ($recordsToInsert as $row) {
    $placeholders[] = “(%d, %d, %d, %s, %d)”;
    $values[] = $row[‘post_id’];
    $values[] = $row[‘ancestor_term_id’];
    $values[] = $row[‘direct_term_id’];
    $values[] = $row[‘taxonomy’];
    $values[] = $row[‘depth’];
    }

    $query .= implode(‘, ‘, $placeholders);
    $wpdb->query($wpdb->prepare($query, $values));
    }

    /

    • 投稿削除時の整合性維持

    /
    public static function onDeletedPost(int $postId, \WP_Post $post): void
    {
    global $wpdb;
    $flatTable = $wpdb->prefix . ‘post_term_flat’;
    $wpdb->delete($flatTable, [‘post_id’ => $postId], [‘%d’]);
    }

    /

    • ターム削除時のカスケードクリーンアップ

    /
    public static function onDeleteTerm(
    int $termId,
    int $ttId,
    string $taxonomy,
    \WP_Term $deletedTerm
    ): void {
    global $wpdb;
    $flatTable = $wpdb->prefix . ‘post_term_flat’;

    $wpdb->query($wpdb->prepare(
    “DELETE FROM `{$flatTable}` WHERE ancestor_term_id = %d OR direct_term_id = %d”,
    $termId,
    $termId
    ));
    }
    }

    —

    4. クエリレイヤのオーバーライド:O(1) ルックアップへの置換

    `WP_Query` の内部フックである `posts_clauses` を捕捉し、`wp_tax_query` が生成する結合SQLを非正規化テーブルに対する直接結合へと書き換える。

    これにより、先祖タームを指定した際にも子孫タームのID展開(巨大な `IN (…)` 句)が完全に消滅し、単一の等価結合(`ancestor_term_id = ?`)に変換される。

    is_main_query()) {
    return $clauses;
    }

    $taxQuery = $query->get(‘tax_query’);
    if (empty($taxQuery) || !is_array($taxQuery)) {
    return $clauses;
    }

    global $wpdb;
    $flatTable = $wpdb->prefix . ‘post_term_flat’;

    // tax_query の構造を解析し、特定の階層クエリをインターセプト
    foreach ($taxQuery as $index => $clause) {
    if (!is_array($clause) || !isset($clause[‘taxonomy’], $clause[‘terms’])) {
    continue;
    }

    $taxonomy = $clause[‘taxonomy’];

    // 階層を持つタクソノミーで、かつ include_children が true の場合
    $includeChildren = $clause[‘include_children’] ?? true;
    $field = $clause[‘field’] ?? ‘term_id’;

    if ($includeChildren && is_taxonomy_hierarchical($taxonomy) && $field === ‘term_id’) {
    $terms = (array) $clause[‘terms’];

    // 元の遅い JOIN と WHERE 句を置換するためのエイリアスを定義
    $alias = ‘ptf_’ . md5($taxonomy . serialize($terms));

    // 結合の注入: wp_post_term_flat のクラスタドインデックスを直接叩く
    $joinCondition = “INNER JOIN `{$flatTable}` AS {$alias} ON (“;
    $joinCondition .= “{$alias}.post_id = {$wpdb->posts}.ID “;

    if (count($terms) === 1) {
    $joinCondition .= $wpdb->prepare(“AND {$alias}.ancestor_term_id = %d”, reset($terms));
    } else {
    $termsList = implode(‘,’, array_map(‘intval’, $terms));
    $joinCondition .= “AND {$alias}.ancestor_term_id IN ({$termsList})”;
    }

    $joinCondition .= $wpdb->prepare(” AND {$alias}.taxonomy = %s)”, $taxonomy);

    // 標準の tax_query によって追加された遅い JOIN/WHERE を無力化
    $clauses[‘join’] = self::stripStandardTaxonomyJoins($clauses[‘join’]);
    $clauses[‘where’] = self::stripStandardTaxonomyWheres($clauses[‘where’]);

    // 高速な非正規化結合を追加
    $clauses[‘join’] .= ” {$joinCondition} “;

    // 重複排除のための DISTINCT が不要になる(ancestor_term_id × post_id はユニーク)
    $clauses[‘distinct’] = ”;
    }
    }

    return $clauses;
    }

    private static function stripStandardTaxonomyJoins(string $join): string
    {
    // wp_term_relationships への標準 JOIN 句を除去
    return preg_replace(
    ‘/INNER JOIN\s+[^\s]wp_term_relationships\s+ON\s+\([^\)]+\)/i’,
    ”,
    $join
    ) ?? $join;
    }

    private static function stripStandardTaxonomyWheres(string $where): string
    {
    // wp_term_relationships への標準 WHERE 条件を除去
    return preg_replace(
    ‘/AND\s+[^\s]wp_term_relationships\.term_taxonomy_id\s+IN\s+\([^\)]+\)/i’,
    ”,
    $where
    ) ?? $where;
    }
    }

    —

    5. EXPLAIN による物理実行計画の比較検証

    数万件の投稿と5階層のカテゴリツリーを持つ本番想定データベースにおいて、カテゴリ走査の実行計画を比較する。

    最適化前(WordPress標準)

    EXPLAIN SELECT wp_posts.ID
    FROM wp_posts
    LEFT JOIN wp_term_relationships ON (wp_posts.ID = wp_term_relationships.object_id)
    WHERE wp_term_relationships.term_taxonomy_id IN (101, 102, 103, …, 150)
    AND wp_posts.post_status = ‘publish’
    GROUP BY wp_posts.ID
    ORDER BY wp_posts.post_date DESC
    LIMIT 20;

    +—-+————-+————————+——-+——————–+——————–+———+———————————–+——+———————————————————–+
    | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
    +—-+————-+————————+——-+——————–+——————–+———+———————————–+——+———————————————————–+
    | 1 | SIMPLE | wp_term_relationships | range | PRIMARY,term_taxid | term_taxonomy_id | 8 | NULL | 4812 | Using where; Using index; Using temporary; Using filesort |
    | 1 | SIMPLE | wp_posts | eq_ref| PRIMARY | PRIMARY | 8 | wp_term_relationships.object_id | 1 | Using where |
    +—-+————-+————————+——-+——————–+——————–+———+———————————–+——+———————————————————–+

    • ボトルネック: `Using temporary; Using filesort` が発生。4,812行のセカンダリインデックス走査後、一時テーブルを作成してファイルソートを実行している。

    —

    最適化後(マテリアライズド・フラットテーブル)

    EXPLAIN SELECT wp_posts.ID
    FROM wp_posts
    INNER JOIN wp_post_term_flat AS ptf ON (ptf.post_id = wp_posts.ID AND ptf.ancestor_term_id = 101)
    WHERE wp_posts.post_status = ‘publish’
    ORDER BY wp_posts.post_date DESC
    LIMIT 20;

    +—-+————-+———-+——–+———————–+———+———+——————–+——+—————————–+
    | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
    +—-+————-+———-+——–+———————–+———+———+——————–+——+—————————–+
    | 1 | SIMPLE | ptf | ref | PRIMARY | PRIMARY | 8 | const | 320 | Using index |
    | 1 | SIMPLE | wp_posts | eq_ref | PRIMARY | PRIMARY | 8 | ptf.post_id | 1 | Using where |
    +—-+————-+———-+——–+———————–+———+———+——————–+——+—————————–+

    • 改善点:

    1. `ptf` はクラスタドインデックス(PRIMARY)の先頭カラムのみを利用して`Using index`(カバリングインデックス)で完全解決。
    2. `Using temporary` および `Using filesort` が完全に消失。
    3. 走査行数が `4,812` から `320`(約93%削減)に圧縮。

    —

    6. まとめ

    WordPressのコアスキーマが持つ正規化モデルは、柔軟性と後方互換性の点では優れているが、エンタープライズスケールの大規模データセットや複雑な階層構造下ではInnoDBの物理I/O特性と真っ向から衝突する。

    本手法のように「書き込み時のフックで階層を解決し、読み込み専用の物理最適化テーブルに非正規化してマテリアライズドする」アプローチは、CQRS(コマンドクエリ責務分離)の概念をWordPressの単一DBインスタンス内で実現する極めて実戦的な最適化パターンである。

    データベース層の物理構造(B+Tree、ページアライメント、インデックスカバリング)を正しく理解し、WordPressのクエリ生成パイプラインを掌握することで、システム全体の応答速度を次元の異なるレベルへと押し上げることが可能となる。

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