WordPressデータベーススキーマの極限最適化:`wp_posts.post_content_filtered` を用いた検索負荷軽減と非正規化の設計思想
大規模なWordPressサイトにおいて、数百万件規模の `wp_postmeta` や `wp_posts` を抱えるシステムでの最大のボトルネックは、決まって「テキスト検索」のコストである。特に `wp_posts.post_content` に格納された膨大なHTMLタグ、ショートコードの残骸、JSONデータに対して `LIKE ‘%keyword%’` を発行するクエリは、MySQL/InnoDBのバッファプールを圧迫し、フルテーブルスキャン(あるいはそれに準ずる非効率なインデックススキャン)を引き起こす。
本稿では、コアテーブルの一つでありながら長らくデッドスペース化していた `wp_posts.post_content_filtered` カラムに焦点を当て、検索負荷を劇的に削減する非正規化(Denormalization)のアーキテクチャと、その実戦的な実装コードを解説する。
—
1. なぜ `post_content_filtered` なのか?
InnoDBストレージエンジンにおける可変長文字列カラム(`LONGTEXT` や `TEXT`)へのインデックス付与は、行サイズ制限(65,535バイト)やプレフィックスインデックスのパフォーマンス劣化という構造的ジレンマを孕んでいる。
WordPressコアにおいて、`post_content_filtered` は歴史的経緯(主に古のビジュアルエディタや特定のキャッシュ機構)を除き、デフォルトではほとんど利用されていない。つまり、このカラムは「プラグイン開発者やアーキテクトがカスタムペイロードを安全に退避・インデックス化するための空白地帯」として機能する。
ここに、HTMLタグを完全にパース(Strip)し、正規化されたプレーンテキストのみを格納することで、以下のメリットが生まれる。
1. LIKE検索のコスト激減: 対象データ量(バイト数)がHTMLタグ除去により平均40〜70%削減される。
2. ストレージ局所性の向上: InnoDBのページキャッシュヒット率が向上し、ディスクI/Oが抑制される。
3. 将来的なFULLTEXTインデックスへの移行容易性: プレーンテキスト化されているため、MySQLの `FULLTEXT` インデックス(InnoDB FTS)や外部検索エンジン(Elasticsearch等)へのパイプライン構築が極めて容易になる。
—
2. アーキテクチャ設計:書き込み時非正規化(Write-time Denormalization)
データベースのスケーラビリティの鉄則は、「読み込み時の計算を、書き込み時の計算コストに肩代わりさせること」である。
ポストの保存・更新時(`save_post` フック)に非同期または同期的にパース処理走り、`post_content_filtered` を更新する。これにより、フロントエンドの検索クエリは常に最適化されたインデックス対象領域を参照できる。
[WP Admin: Post Save]
│
▼ (save_post hook)
[Text Extraction & Normalization Filter]
│
▼
[wp_posts.post_content_filtered 更新 (Plain Text)]
│
▼
[Frontend SEARCH / WP_Query] ──> Index Scan on post_content_filtered (高速)
—
3. 実装:低レイヤからのテキスト抽出とカラム同期
以下のコードは、`save_post` ライフサイクルにおいて、HTMLタグ、CSS、JS、不要なエンティティを除去し、`post_content_filtered` へアトミックに書き込むための堅牢な実装である。
/
declare(strict_types=1);
namespace WP_Core_Optimizer;
class ContentFilteredSync {
/
- フックの登録
/
public static function boot(): void {
// 優先度を最後に設定し、他のプラグインによるコンテンツ加工が完了した後に実行
add_action( ‘save_post’, [ self::class, ‘sync_filtered_content’ ], PHP_INT_MAX, 3 );
}
/
- コンテンツをパースして post_content_filtered を更新する
- @param int ecosystem post ID
- @param \WP_Post post object
- @param bool whether this is an update
/
public static function sync_filtered_content( int $post_id, \WP_Post $post, bool $update ): void {
// リビジョンや自動保存、ゴミ箱行きはスキップ
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) || ‘publish’ !== $post->post_status ) {
return;
}
// 無限ループ防止のためのトランジェントチェック(必要に応じて)
// データベースへの無駄な書き込みを防ぐため、ハッシュ比較を行うのがシニアの作法
$raw_content = $post->post_content;
// 1. HTMLタグの除去、ショートコードの展開、不要な空白の正規化
$plain_text = self::extract_searchable_text( $raw_content );
// 2. 既存の post_content_filtered と比較し、変更がある場合のみ DB を叩く(WriteAmplificationの抑制)
global $wpdb;
// 直接クエリで現在の値を高速取得(WP_Postのメモリキャッシュ汚染を防ぐ)
$current_filtered = $wpdb->get_var( $wpdb->prepare(
“SELECT post_content_filtered FROM {$wpdb->posts} WHERE ID = %d”,
$post_id
) );
$new_hash = md5( $plain_text );
$old_hash = md5( (string) $current_filtered );
if ( $new_hash === $old_hash ) {
return;
}
// 3. アトミックな更新処理(フックの再再帰を防ぐために wp_update_post は使わず直接 UPDATE)
$wpdb->update(
$wpdb->posts,
[ ‘post_content_filtered’ => $plain_text ],
[ ‘ID’ => $post_id ],
[ ‘%s’ ],
[ ‘%d’ ]
);
// オブジェクトキャッシュのパージ
clean_post_cache( $post_id );
}
/
- 高速なテキスト抽出と正規化パイプライン
- @param string $content
- @return string
/
private static function extract_searchable_text( string $content ): string {
// ショートコードを実行または除去
$content = strip_shortcodes( $content );
// "