wp_postsの孤児カラム `post_content_filtered` を支配せよ:大規模WordPressにおける全文検索の物理的解放と非同期データパイプライン
WordPressのコアアーキテクチャにおいて、`wp_posts` テーブルはすべてのコンテンツの母艦である。しかし、数百万レコードを超えるスケールに達した瞬間、デフォルトの `post_content` に対する `LIKE ‘%keyword%’` クエリは、MySQL/InnoDBストレージエンジンにとって悪夢へと変貌する。
巨大なHTMLマークアップ、Gutenbergのシリアライズされたブロックコメント(JSON)、ショートコードの残骸。これらが混在するバイナリ・テキストプールに対してインデックスは無力であり、フルテーブルスキャン(全表走査)とディミッシングなランダムI/OがCPUとバッファプールを焼き尽くす。
ここで、コアテーブルの構造を凝視してみるがいい。
`wp_posts` には、長年ほとんどのプラグイン開発者から無視され続けてきた、ある「隠されたカラム」が存在する。
それが
`post_content_filtered` だ。
本稿では、この忘れ去られたカラムを物理的な検索高速化のキーストーンとして再定義し、HTMLパースから非同期パイプライン、そして検索クエリのオフロードに至るまで、極限の低レイヤ知見を紐解いていく。
—
1. なぜ `post_content_filtered` なのか? ― コアスキーマの意図と解釈
MySQLの `wp_posts` テーブル定義を再確認する。
CREATE TABLE `wp_posts` (
`ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_author` bigint(20) unsigned NOT NULL DEFAULT ‘0’,
`post_date` datetime NOT NULL DEFAULT ‘0000-00-00 00:00:00’,
…
`post_content` longtext NOT NULL,
`post_title` text NOT NULL,
`post_excerpt` text NOT NULL,
…
`post_content_filtered` longtext NOT NULL,
…
PRIMARY KEY (`ID`)
) ENGINE=InnoDB;
`post_content_filtered` は、WordPressの歴史的経緯において「パース済み、またはフィルタリング済みのコンテンツを格納する」という曖昧な目的で用意された。しかし、標準のWPコア機能はこのカラムをほとんど使用しない。つまり、
開発者が自由長(Longtext)のワークスペースとして完全に独占できる、数少ないコアカラムである。
ここに「HTMLタグ、Gutenberg構文、ショートコードをすべて剥ぎ取った、純粋なプレーンテキスト(Normalized Plain Text)」を格納する。これにより、検索インデックスの効率は劇的に跳ね上がる。
—
2. データパイプラインの設計:汚染されたHTMLから純粋テキストへの変換
書き込み時(Write-time)にテキストの正規化を行い、`post_content_filtered` を同期させるパイプラインを構築する。読み込み時(Read-time)の動的な正規化は、CPUサイクルとメモリ帯域の無駄遣いでしかない。
以下のコードは、GutenbergブロックのシリアライズデータやHTMLタグを安全に剥ぎ取り、低フットプリントなプレーンテキストを生成・保存するフックの 구현である。
/
- Post Save Pipeline: post_content_filtered の同期処理
- @param int $post_id Post ID.
- @param WP_Post $post Post object.
- @param bool $update Whether this is an existing post being updated.
/
function architecture_sync_filtered_content( int $post_id, WP_Post $post, bool $update ): void {
// リビジョンや自動保存、ゴミ箱行きはスキップし、I/Oを保護する
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) || ‘publish’ !== $post->post_status ) {
return;
}
// 無限ループを防ぐため、一度フラグを立てるかトランザクション内で処理する
remove_action( ‘save_post’, ‘architecture_sync_filtered_content’, 10 );
// 1. Gutenbergブロックのコメント構文を除去( など)
$raw_content = preg_replace( ‘//s’, ”, $post->post_content );
// 2. ショートコードの展開または除去
$raw_content = strip_shortcodes( $raw_content );
// 3. HTMLタグの完全排除とエンティティのデコード
$plain_text = wp_strip_all_tags( $raw_content, true );
$plain_text = html_entity_decode( $plain_text, ENT_QUOTES | ENT_HTML5, ‘UTF-8’ );
// 4. 空白文字の正規化(マルチバイトスペースや連続する改行の圧縮)
$plain_text = preg_replace( ‘/[\p{Z}\s]+/u’, ‘ ‘, $plain_text );
$plain_text = trim( $plain_text );
global $wpdb;
// 5. wp_posts テーブルの該当カラムを直接高速更新(meta_cache や余計なフックをバイパス)
$wpdb->update(
$wpdb->posts,
array( ‘post_content_filtered’ => $plain_text ),
array( ‘ID’ => $post_id ),
array( ‘%s’ ),
array( ‘%d’ )
);
// フックを復元
add_action( ‘save_post’, ‘architecture_sync_filtered_content’, 10, 3 );
}
add_action( ‘save_post’, ‘architecture_sync_filtered_content’, 10, 3 );
アーキテクチャ上の注意点
大量の既存記事に対してこのマイグレーションを行う場合、PHPのメモリ制限(`memory_limit`)とMySQLのパケットサイズ(`max_allowed_packet`)の壁にぶつかる。必ず WP-CLI を用いたバッチ処理(チャンク分割)で非同期マイグレーションを実行すべきである。
—
3. 検索クエリの最適化:FULLTEXTインデックスとの統合
プレーンテキスト化された `post_content_filtered` が手に入れば、MySQLの `FULLTEXT` インデックス(InnoDB FTS)のポテンシャルを100%引き出すことができる。
デフォルトの `wp_posts` では、`post_content` にFULLTEXTインデックスを貼ると、インデックスサイズが肥大化しすぎてメモリ(`innodb_buffer_pool_size`)を圧迫する。しかし、純粋なテキストのみが格納された `post_content_filtered` であれば、インデックスのフットプリントは最小限に抑えられる。
1. 物理インデックスの付与(MySQLコンソール等で実行)
ALTER TABLE wp_posts ADD FULLTEXT INDEX fx_content_filtered (post_content_filtered);
2. WordPressの `posts_clauses` フィルターによるクエリ書き換え
ユーザーからの検索リクエストを受け取った際、デフォルトの `post_content LIKE …` を捨て、`MATCH() AGAINST()` 構文へダイレクトにルーティングする。
/
- 検索クエリを wp_posts.post_content_filtered に対する FULLTEXT 検索へ強制バイパスする
/
function architecture_optimize_search_query( array $clauses, WP_Query $query ): array {
if ( ! is_admin() && $query->is_main_query() && $query->is_search() ) {
global $wpdb;
$search_term = $query->get( ‘s’ );
if ( empty( $search_term ) ) {
return $clauses;
}
// 自然言語モードまたはブールモードによるエスケープ
$escaped_term = esc_sql( $wpdb->_real_escape( $search_term ) );
// WHERE 句の置き換え(post_content への LIKE 検索を排除)
// ※ 複数キーワードへの対応やスコアリングの調整は要件に合わせて拡張
$clauses[‘where’] = sprintf(
” AND MATCH (%s.post_content_filtered) AGAINST (‘%s’ IN BOOLEAN MODE) “,
$wpdb->posts,
$escaped_term
);
// 高関連度順(Relevance)でソートする場合の ORDER 句
$clauses[‘orderby’] = sprintf(
” MATCH (%s.post_content_filtered) AGAINST (‘%s’ IN BOOLEAN MODE) DESC “,
$wpdb->posts,
$escaped_term
);
}
return $clauses;
}
add_filter( ‘posts_clauses’, ‘architecture_optimize_search_query’, 10, 2 );
—
4. パフォーマンスの境界線:なぜこの手法がスケールするのか
一般的なWordPressサイトでは、検索が行われるたびに以下の地獄のような処理が実行される。
1. `wp_posts` の全レコードに対して `LIKE ‘%keyword%’`(インデックス不使用)
2. ヒットしたIDに対して `WP_Post` オブジェクトを生成
3. `post_content` に含まれる重たいショートコードやブロックレンダリングが実行されるリスク
本手法(`post_content_filtered` パイプライン)を導入したシステムの挙動はこうだ。
1.
O(1)に近いI/O: InnoDBのB-Treeインデックス(FTS)が直接メモリ上のポインタを指すため、検索クエリのレイテンシが数十ミリ秒から数ミリ秒へ劇的に短縮される。
2.
CPUキャッシュの効率化: ノイズ(HTMLタグやCSSクラス名)を含まないため、トークナイザーのヒット率が向上し、MySQL内部の検索バッファ効率が最大化される。
3.
プラグイン競合の回避: メタテーブル(`wp_postmeta`)を汚染せず、コアの拡張領域であるカラムを直接利用するため、他のプラグインとのデッドロックリスクが極小化される。
—
結語:コアの設計思想への回帰
WordPressは「ブログエンジン」から「エンタープライズCMS」へと進化する過程で、多くの抽象化レイヤーを重ねてきた。その結果、開発者はデータベースの物理層から遠ざけられ、非効率なクエリの温床を見過ごしがちになっている。
`post_content_filtered` のような「忘れられた仕様」に光を当て、書き込み時のデータパイプラインとストレージエンジンの特性を調律すること。それこそが、数千万PVを叩き出す高負荷環境において、WordPressを単なるスクリプトから「強靭なエンタープライズプラットフォーム」へと昇華させる唯一の道である。
妥協なきアーキテクチャの構築を楽しめ。