【実務・中級編】wp_postsテーブルのpost_content_filteredカラムの活用と検索負荷の分散 – WordPress 内部コア・データベース構造とパフォーマンス最適化解析バイブル

WordPressを極限までチューニングせよ:`wp_posts.post_content_filtered`による検索負荷分散とデータパイプラインの設計

コードレビューをしていて、次のような実装に出くたことはないだろうか。

// 最悪なアンチパターン:メタクエリによる動的検索
$args = array(
‘post_type’ => ‘post’,
‘s’ => $search_keyword, // または重いメタクエリ
);
$query = new WP_Query( $args );

大規模なメディアサイトやECサイトにおいて、`wp_posts` テーブルの `post_content` に格納された膨大なHTMLタグを含むテキストに対し、MySQLの `LIKE` 検索(`%keyword%`)を走らせるのは自殺行為だ。インデックスは効かず、テーブルフルスキャン(全件走査)が発生し、QPS(Query Per Second)が跳ね上がった瞬間にデータベースのCPU使用率は100張り付き、死に至る。

さらに、Gutenberg(ブロックエディター)の普及に伴い、`post_content` の中身はHTMLコメント(シリアライズされたJSON構造のブロック属性)で汚染されている。この混沌としたバイナリに近いテキストをそのまま検索対象にすること自体、アーキテクチャ設計の敗北と言っていい。

今回は、WordPressコアテーブルの奥底に眠りながら、多くの開発者に無視されてきた隠し宝石――`post_content_filtered` カラムを活用し、検索負荷を劇的に分散・削減するためのプロダクションレディなデータパイプライン構築手法を伝授する。

—

1. なぜ `post_content_filtered` なのか? コアスキーマの思想を読み解く

`wp_posts` テーブルのスキーマ構造を直視したことがあるだろうか。

DESCRIBE wp_posts;

この中に `post_content_filtered` というカラムが存在する。歴史的経緯や一部のプラグイン(キャッシュ系など)で使われることもあるが、WordPressコアのデフォルトロジックでは現在、ほとんど積極的な利用がされていない空き地である。

ここに何を入れるべきか?
答えは明確だ。「HTMLタグやブロック構文を完全に剥ぎ取った、純粋なテキストデータ(Plain Text)」である。

アーキテクチャの比較

  • 従来方式 (Runtime Search):

`wp_posts.post_content` (HTML/Gutenberg JSON) を検索時に毎回パース、または `LIKE` で直叩き。 -> CPUバウンド、スケーラビリティ皆無。

  • 本設計方式 (Pre-computed Pipeline):

保存時(Write時)にHTMLをストリップし、`post_content_filtered` に純粋なテキストを永続化。検索時はこのカラムに対してインデックス(あるいは全文検索)を直撃させる。 -> I/O・CPU負荷を極小化。

「検索はコストが高いもの」という固定観念を捨て、書き込み時にコストを払い、読み込み時はゼロコストに近い状態を作るのが、ハイパフォーマンスなWordPress設計の鉄則だ。

—

2. 堅牢なデータパイプラインの設計

データパイプラインは、以下の3つのフェーズで構成する。

1. Ingestion (取り込み): 記事の保存・更新 (`save_post`)
2. Transformation (変換): Gutenbergブロックのデシリアライズ、HTMLタグの除去、正規化
3. Persistence (永続化): `wp_posts.post_content_filtered` への書き込み(無限ループの防止に細心の注意を払う)

ここで最も重要なのは、「いつ、どのフックで実行するか」だ。`save_post` はリビジョンやオートセーブの際にも発火するため、適切なガード節を書かないと、データベースが無限ループの泥沼にはまる。

—

3. プロダクションコード:ゼロから構築するインデックスパイプライン

以下のコードは、テーマの `functions.php` やカスタムMU-Plugin(Must-Use Plugins)として配置することを想定した、実戦投入可能なモジュールだ。

  • Plugin Name: WP Content Filtered Pipeline
  • Description: post_content_filtered を活用した検索高速化パイプライン
  • Version: 1.0.0
  • Author: Lead Systems Architect
  • /

    namespace Enterprise\SearchPipeline;

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

    class ContentIndexer {

    /

    • フックの登録

    /
    public static function init(): void {
    // save_post はリビジョンやオートセーブでも発火するため、タイミングを厳密に制御する
    add_action( ‘save_post’, [ self::class, ‘handle_save_post’ ], 10, 3 );
    }

    /

    • 投稿保存時のハンドラ
    • @param int $post_ID 投稿ID
    • @param \WP_Post $post 投稿オブジェクト
    • @param bool $update 既存の更新か否か

    /
    public static function handle_save_post( int $post_ID, \WP_Post $post, bool $update ): void {
    // 1. 無限ループと不要な実行のガード
    if ( defined( ‘DOING_AUTOSAVE’ ) && DOING_AUTOSAVE ) {
    return;
    }
    if ( wp_is_post_revision( $post_ID ) || wp_is_post_autosave( $post_ID ) ) {
    return;
    }
    // リクエストが特定のポストタイプ以外、または権限がない場合などは適宜ガード
    if ( ‘post’ !== $post->post_type && ‘page’ !== $post->post_type ) {
    return;
    }

    // 2. 権限チェックやBulk Edit時の安全装置
    if ( ! current_user_can( ‘edit_post’, $post_ID ) && ! wp_doing_cron() && ! ( defined( ‘REST_REQUEST’ ) && REST_REQUEST ) ) {
    return;
    }

    // 3. テキストの抽出と変換 (Transformation)
    $clean_text = self::extract_pure_text( $post->post_content );

    // 4. 永続化 (Persistence) – wp_update_post を使うと無限ループするため直接DBを叩くか、フィルターフックを一時解除する
    self::update_filtered_content( $post_ID, $clean_text );
    }

    /

    • Gutenbergのブロック構造やHTMLから純粋なテキストを抽出する
    • @raw_content string
    • @return string

    /
    private static function extract_pure_text( string $raw_content ): string {
    // Gutenbergコメントを除去(必要に応じてstrip_shortcodesも)
    $content = strip_shortcodes( $raw_content );

    // HTMLタグの除去
    $content = wp_strip_all_tags( $content );

    // 実体参照のデコードとスペースの正規化
    $content = html_entity_decode( $content, ENT_QUOTES | ENT_HTML5, ‘UTF-8’ );

    // 改行や連続する空白を1つのスペースに正規化(検索インデックス用)
    $content = preg_replace( ‘/\s+/u’, ‘ ‘, $content );

    return trim( $content );
    }

    /

    • wp_posts.post_content_filtered を安全に更新する
    • wp_update_post() を呼ぶと再度 save_post が発火するため、
    • $wpdb->update を用いてピンポイントで高速に更新する。
    • @param int $post_ID
    • @param string $filtered_text

    /
    private static function update_filtered_content( int $post_ID, string $filtered_text ): void {
    global $wpdb;

    // 直接UPDATEを発行することで、無駄なフックの連鎖を断ち切る
    $wpdb->update(
    $wpdb->posts,
    [ ‘post_content_filtered’ => $filtered_text ],
    [ ‘ID’ => $post_ID ],
    [ ‘%s’ ],
    [ ‘%d’ ]
    );

    // オブジェクトキャッシュの破棄(必須)
    clean_post_cache( $post_ID );
    }
    }

    // 起動
    ContentIndexer::init();

    コードレビューのポイント:なぜ `wp_update_post()` を使わないのか?

    ジュニアエンジニアがやりがちなミスとして、データの保存に `wp_update_post()` をそのまま使ってしまうケースがある。これをしてしまうと、`save_post` が再帰的に呼び出され、「最大関数ネスト深度超過(Maximum function nesting level reached)」の致命的エラーを引き起こす。
    ここでは直接 `$wpdb->update` を叩き、かつ最小限のカラムのみを書き換えることで、オーバーヘッドを極限まで排除している。

    —

    4. 検索クエリの最適化:カスタム `posts_search` フィルターの実装

    データが綺麗に `post_content_filtered` に格納されたら、次は検索時にそのカラムをターゲットにするよう `WP_Query` を乗っ取る。

    class OptimizedSearch {

    public static function init(): void {
    add_filter( ‘posts_search’, [ self::class, ‘override_search_column’ ], 10, 2 );
    }

    /

    • WP_Query の検索対象を post_content から post_content_filtered にすげ替える
    • @param string $search SQLのWHERE句の部分
    • @param \WP_Query $query WP_Queryインスタンス
    • @return string

    /
    public static function override_search_column( string $search, \WP_Query $query ): string {
    if ( is_admin() || ! $query->is_search() || ! $query->is_main_query() ) {
    return $search;
    }

    global $wpdb;

    $search_term = $query->get( ‘s’ );
    if ( empty( $search_term ) ) {
    return $search;
    }

    // キーワードをエスケープ
    $like = ‘%’ . $wpdb->esc_like( $search_term ) . ‘%’;

    // 通常の wp_posts.post_content を対象にした検索ロジックを、
    // post_content_filtered を対象にしたロジックへと完全に書き換える
    $search = $wpdb->prepare(
    ” AND ({$wpdb->posts}.post_title LIKE %s OR {$wpdb->posts}.post_content_filtered LIKE %s)”,
    $like,
    $like
    );

    return $search;
    }
    }

    OptimizedSearch::init();

    これで、検索クエリは膨大なHTMLやGutenbergのJSONゴミデータを無視し、人間が読める純粋なテキストが詰まった `post_content_filtered` のみをスキャンするようになる。

    —

    5. 本番運用におけるインフラストラクチャの注意点

    ここまででアプリケーション層の最適化は完了したが、シニアエンジニアとしてインフラレベルの視点も忘れてはならない。

    1. MySQLインデックスの追加:
    `post_content_filtered` はデフォルトではインデックスを持っていない。データ量が数十万件を超える場合、このカラムに対してMySQLのインデックス、あるいはより高度なFULLTEXTインデックス(全文検索インデックス)の付与を検討すべきだ。

    ALTER TABLE wp_posts ADD FULLTEXT INDEX idx_content_filtered (post_content_filtered);

    ※ FULLTEXTインデックスを導入する場合は、`LIKE ‘%keyword%’` ではなく `MATCH (…) AGAINST (…)` を使うように `posts_search` フィルター側のSQLを調整する必要がある。

    2. 既存データの一括マイグレーション(バックフィル):
    この仕組みを導入した際、過去に公開された数万件の記事には `post_content_filtered` が空のまま残る。必ずWP-CLIやバッチスクリプトを書き、既存データの変換バッチを一度だけ流すこと。

    # 例:WP-CLIで全投稿を再保存してフックを走らせるバッチのイメージ
    wp post list –post_type=post –format=ids | xargs -I {} wp post update {} –force-regenerate

    (※ 実際にはカスタムWP-CLIコマンドを書き、バッチサイズを区切って `$wpdb` で一括変換するスクリプトを書くのがプロの仕事だ)

    —

    結び:アーキテクチャの美しさはパフォーマンスに宿る

    WordPressは「ブログエンジン」としてスタートしたため、デフォルトのデータベース設計や検索クエリは、決して大規模なWebアプリケーションに最適化されているわけではない。

    しかし、コアが用意している拡張ポイントや、普段光の当たらないカラム(`post_content_filtered`)の本質を理解し、正しいデータパイプラインを設計すれば、数百万PVを誇るエンタープライズ環境であっても、高速で堅牢なシステム基盤へと昇華させることができる。

    「とりあえずプラグインを入れる」という思考停止から脱却し、データベースの物理構造からロジカルにボトルネックを叩き潰す――それこそが、真のWordPressエンジニアリングである。

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