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

1. なぜ大規模WordPressにおいてTaxonomyクエリは破綻するのか

大規模なエンタープライズ開発や高トラフィックなメディア・ECサイトにおいて、`WP_Query` のボトルネックとして頻繁に槍玉に挙げられるのがタクソノミークエリです。

まずは、WordPress標準の階層型タクソノミー(カテゴリやカスタム分類)で、特定の子孫タームを含むクエリを発行した際に生成される標準SQLを解剖してみましょう。

— カテゴリID: 42(およびその子孫ターム)に属する公開記事を取得する典型的なクエリ
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 (42, 43, 44, 105, 106, 201)
)
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, 20;

このSQLが抱える致命的な構造的欠陥は主に3点あります。

1. 多段JOINとカーディナリティの爆発
`wp_posts` と `wp_term_relationships` を結合した際、1対Nのリレーションによって中間結果セットが膨張します。
2. `GROUP BY` によるテンポラリテーブルとFilesortの強制
重複行を排除するために `GROUP BY wp_posts.ID` が必須となり、MySQLはメモリ上(またはディスク上)に一時テーブルを作成してソート処理(`Using temporary; Using filesort`)を行います。データ件数が数十万〜数百万行に達した段階で、バッファプールを食いつぶしI/Oが枯渇します。
3. 階層解決に伴うIN句の肥大化
子孫タームを網羅するためにPHPレイヤーで階層ツリーを展開し、数十から数百個の `term_taxonomy_id` を `IN (…)` に展開します。オプティマイザのインデックスダイブ負荷を増大させ、実行計画の揺らぎを引き起こします。

今回はこの問題を根本から解消するため、タクソノミー階層構造の非正規化(Denormalization)テーブル を導入し、JOINコストを極小化してクエリを $O(1)$ のルックアップ性能へと引き上げるアーキテクチャを実装します。

—

2. アーキテクチャ設計:非正規化フラットインデックス

アプローチは極めてシンプルかつ強力です。
「どの投稿が、どのターム(親・先祖を含む)を保持しているか」を完全にフラット化した専用のルックアップテーブルを定義します。

テーブル設計(`wp_post_term_flat_index`)

CREATE TABLE `wp_post_term_flat_index` (
`post_id` bigint(20) unsigned NOT NULL,
`taxonomy` varchar(32) NOT NULL,
`term_id` bigint(20) unsigned NOT NULL,
`is_direct` tinyint(1) NOT NULL DEFAULT 1 COMMENT ‘直接紐付いている場合は1、先祖からの継承は0’,
`post_date` datetime NOT NULL DEFAULT ‘0000-00-00 00:00:00’,
PRIMARY KEY (`taxonomy`, `term_id`, `post_date`, `post_id`),
KEY `post_id` (`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

この物理設計の勘所

  • カバリングインデックスの最適化

複合主キー `(taxonomy, term_id, post_date, post_id)` の順序が最大の肝です。
特定のタクソノミーかつ特定タームの投稿を日付降順で取得する際、テーブルの実データ(Clustered Index)を見に行くことなく、このインデックスツリーの走査のみ(Using index)でソートとページネーションが完結します。

  • `GROUP BY` の完全排除

非正規化段階で重複を解決して格納するため、クエリ時の `GROUP BY` を完全に排除できます。

—

3. 実装:イベント駆動型同期とクエリバイパスエンジン

ここからは、実務でそのまま導入可能なプロダクションコードを提示します。

設計の要件は以下の通りです。
1. タームの割り当て変更、投稿の公開・削除、タームの親子階層変更時に同期ずれ(Data Drift)を起こさず整合性を保つ。
2. `WP_Query` の実行時に、タクソノミークエリが指定されていれば自動的に `posts_clauses` を書き換えて非正規化テーブルを経由させる。

以下のコードをテーマの `functions.php` または専用プラグインとして配置します。

  • Plugin Name: High-Performance Taxonomy Flat Indexer
  • Description: Denormalizes hierarchical taxonomy relations for O(1) query performance.
  • Author: Legendary Full-Stack Engineer
  • Version: 1.0.0
  • /

    declare(strict_types=1);

    namespace FlatTaxonomy;

    if (!defined(‘ABSPATH’)) {
    exit;
    }

    final class IndexManager
    {
    private const TABLE_NAME = ‘post_term_flat_index’;

    public static function get_table_name(): string
    {
    global $wpdb;
    return $wpdb->prefix . self::TABLE_NAME;
    }

    /

    • テーブルの初期化

    /
    public static function create_table(): void
    {
    global $wpdb;
    $table_name = self::get_table_name();
    $charset_collate = $wpdb->get_charset_collate();

    $sql = “CREATE TABLE IF NOT EXISTS {$table_name} (
    post_id bigint(20) unsigned NOT NULL,
    taxonomy varchar(32) NOT NULL,
    term_id bigint(20) unsigned NOT NULL,
    is_direct tinyint(1) NOT NULL DEFAULT 1,
    post_date datetime NOT NULL DEFAULT ‘0000-00-00 00:00:00’,
    PRIMARY KEY (taxonomy, term_id, post_date, post_id),
    KEY post_id (post_id)
    ) {$charset_collate};”;

    require_once ABSPATH . ‘wp-admin/includes/upgrade.php’;
    dbDelta($sql);
    }

    /

    • 対象投稿の非正規化インデックスを再構築する

    /
    public static function sync_post_terms(int $post_id): void
    {
    // リビジョンや自動保存は対象外
    if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
    return;
    }

    $post = get_post($post_id);
    if (!$post || $post->post_status !== ‘publish’) {
    self::delete_post_index($post_id);
    return;
    }

    global $wpdb;
    $table_name = self::get_table_name();
    $taxonomies = get_object_taxonomies($post->post_type);

    $insert_rows = [];

    foreach ($taxonomies as $taxonomy) {
    $terms = wp_get_object_terms($post_id, $taxonomy, [‘fields’ => ‘all’]);
    if (is_wp_error($terms) || empty($terms)) {
    continue;
    }

    foreach ($terms as $term) {
    // 直接紐付いているターム
    $insert_rows[] = [
    ‘post_id’ => $post_id,
    ‘taxonomy’ => $taxonomy,
    ‘term_id’ => (int) $term->term_id,
    ‘is_direct’ => 1,
    ‘post_date’ => $post->post_date,
    ];

    // 先祖タームを展開して登録(階層のフラット化)
    $ancestors = get_ancestors($term->term_id, $taxonomy, ‘taxonomy’);
    foreach ($ancestors as $ancestor_id) {
    $insert_rows[] = [
    ‘post_id’ => $post_id,
    ‘taxonomy’ => $taxonomy,
    ‘term_id’ => (int) $ancestor_id,
    ‘is_direct’ => 0,
    ‘post_date’ => $post->post_date,
    ];
    }
    }
    }

    // 重複排除 (同一タームへの複数経路がある場合)
    $unique_rows = [];
    foreach ($insert_rows as $row) {
    $key = sprintf(‘%s_%d_%d’, $row[‘taxonomy’], $row[‘term_id’], $row[‘post_id’]);
    if (!isset($unique_rows[$key]) || $row[‘is_direct’] === 1) {
    $unique_rows[$key] = $row;
    }
    }

    // トランザクション制御下での書き換え
    $wpdb->query(‘START TRANSACTION’);
    try {
    $wpdb->delete($table_name, [‘post_id’ => $post_id], [‘%d’]);

    foreach ($unique_rows as $row) {
    $wpdb->insert(
    $table_name,
    $row,
    [‘%d’, ‘%s’, ‘%d’, ‘%d’, ‘%s’]
    );
    }
    $wpdb->query(‘COMMIT’);
    } catch (\Throwable $e) {
    $wpdb->query(‘ROLLBACK’);
    error_log(‘[FlatTaxonomy] Sync failed: ‘ . $e->getMessage());
    }
    }

    /

    • 投稿インデックスの物理削除

    /
    public static function delete_post_index(int $post_id): void
    {
    global $wpdb;
    $wpdb->delete(self::get_table_name(), [‘post_id’ => $post_id], [‘%d’]);
    }
    }

    /

    • ライフサイクルイベントのフックバインド

    /
    add_action(‘init’, function () {
    // 開発環境やマイグレーション用: 必要に応じてコール
    if (is_admin() && current_user_can(‘manage_options’) && isset($_GET[‘rebuild_flat_index_schema’])) {
    IndexManager::create_table();
    }
    });

    // 投稿の保存・ステータス変更時
    add_action(‘save_post’, [IndexManager::class, ‘sync_post_terms’], 20, 1);
    // ターム関連付けの変更時
    add_action(‘set_object_terms’, function ($object_id) {
    IndexManager::sync_post_terms((int) $object_id);
    }, 20, 1);
    // 投稿削除時
    add_action(‘deleted_post’, [IndexManager::class, ‘delete_post_index’], 20, 1);

    /

    • クエリオプティマイザ: WP_Query をフックして非正規化テーブルへと迂回させる

    /
    add_filter(‘posts_clauses’, function (array $clauses, \WP_Query $query) {
    // 管理画面メインクエリ、またはフラットインデックス無効フラグが立っている場合はバイパス
    if (is_admin() && $query->is_main_query()) {
    return $clauses;
    }

    $tax_query = $query->get(‘tax_query’);
    // 単純なタクソノミークエリ(1つのタクソノミー指定かつ単一ターム)を検知して最適化
    if (!empty($tax_query) && is_array($tax_query)) {
    global $wpdb;
    $flat_table = IndexManager::get_table_name();

    // ここでは最も頻出する「1つのTaxonomy & 1つのTerm条件」の最適化を例示
    // 条件が一致する場合にSQLを再構成する
    if (isset($tax_query[0][‘taxonomy’], $tax_query[0][‘terms’])) {
    $tax = $tax_query[0][‘taxonomy’];
    $term = is_array($tax_query[0][‘terms’]) ? (int)$tax_query[0][‘terms’][0] : (int)$tax_query[0][‘terms’];

    if ($term > 0) {
    // デフォルトの wp_term_relationships JOINを剥がす
    $clauses[‘join’] = preg_replace(
    “/LEFT JOIN {$wpdb->term_relationships}.?ON \({$wpdb->posts}\.ID = {$wpdb->term_relationships}\.object_id\)/s”,
    ”,
    $clauses[‘join’]
    );

    // 標準のWHERE句に含まれる term_taxonomy_id 条件を消去
    $clauses[‘where’] = preg_replace(
    “/\s+AND\s+\(\swp_term_relationships\.term_taxonomy_id.?\)/s”,
    ”,
    $clauses[‘where’]
    );

    // 最適化テーブルとの高速INNER JOINに置換
    $clauses[‘join’] .= ” INNER JOIN {$flat_table} AS fti ON ({$wpdb->posts}.ID = fti.post_id) “;
    $clauses[‘where’] .= $wpdb->prepare(
    ” AND fti.taxonomy = %s AND fti.term_id = %d “,
    $tax,
    $term
    );

    // 不要になった GROUP BY を剥がして Filesort を防ぐ
    $clauses[‘groupby’] = ”;
    }
    }
    }

    return $clauses;
    }, 10, 2);

    —

    4. 実行計画(EXPLAIN)の比較検証

    この非正規化設計によって、MySQL内部の挙動がどう変化するのかを `EXPLAIN` で確認します。

    最適化前の実行計画(Before)

    id | select_type | table | type | key | ref | rows | Extra
    —+————-+————————-+——–+————-+——————–+——–+———————————————-
    1 | SIMPLE | wp_posts | ref | type_status | const,const | 150000 | Using where; Using temporary; Using filesort
    1 | SIMPLE | wp_term_relationships | ref | PRIMARY | wp_posts.ID | 3 | Using where; Using index

    • `wp_posts` 全件に近い走査からテンポラリテーブルが生成され、最後に `Using filesort` が走る最悪の実行計画です。データ量に比例してレイテンシが線形増加します。

    最適化後の実行計画(After)

    id | select_type | table | type | key | ref | rows | Extra
    —+————-+———–+——-+———+—————————-+——+————————–
    1 | SIMPLE | fti | ref | PRIMARY | const,const | 20 | Using where; Using index
    1 | SIMPLE | wp_posts | eq_ref| PRIMARY | fti.post_id | 1 | NULL

    • ドライビングテーブルが `fti`(非正規化テーブル)に切り替わり、PRIMARYインデックスのプレフィックス一致で瞬時に特定行がヒットします。
    • `wp_posts` に対しても主キーでの一意参照(`eq_ref`)となるため、`Using temporary; Using filesort` が完全に消滅します。

    —

    5. 運用設計:WP-CLI によるインデックス・リビルドコマンド

    データ移行時やスキーマ変更時に必須となる、WP-CLIを用いた非同期一括リビルドコマンドも設計しておきます。

    get_col(“SELECT ID FROM {$wpdb->posts} WHERE post_status = ‘publish'”);
    $total = count($post_ids);
    $progress = \WP_CLI\Utils\make_progress_bar(‘Syncing posts’, $total);

    foreach ($post_ids as $post_id) {
    \FlatTaxonomy\IndexManager::sync_post_terms((int) $post_id);
    $progress->tick();
    }

    $progress->finish();
    \WP_CLI::success(“Successfully re-indexed {$total} posts.”);
    });
    }

    —

    6. まとめ

    WordPressの「あらゆるデータ構造を柔軟に表現できる」汎用スキーマ設計は、一定以上のデータ規模に達した瞬間に大きな技術的負債へと変化します。

    • 階層解決を読み込み時(Read-time)から書き込み時(Write-time)へシフトする
    • 複合主キーをカバリングインデックスとして機能させ、ソート・重複排除をストレージエンジン層で完結させる
    • WordPress標準フック(`save_post`, `posts_clauses`)を用いて、コアを改変せずにクエリパスをバイパスする

    パフォーマンスが出ない原因を「DBサーバーのスペック」に押し付ける前に、内部構造の物理層に立ち返った非正規化設計を取り入れてみてください。

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