こんにちは! WordPressの内部構造やデータベースの最適化の世界へようこそ。
今回は、多くの開発者が頭を悩ませる「階層構造(ツリー構造)のデータ取得」をテーマに、データベースのパフォーマンスを劇的に改善するテクニックを解説しますね。
他のプログラミング言語からWordPressに入ってきた方だと、「親IDを辿って階層を作る」という処理を自然に書きたくなるものですが、実はここにWordPress最大のパフォーマンス・トラップが潜んでいます。
ここをクリアすれば、あなたも一歩進んだWordPressエンジニアになれますよ。一緒に本質を学んでいきましょう!
—
なぜ `wp_posts` の `post_parent` 再帰クエリは危険なのか?
WordPressの「固定ページ(Page)」や「カスタム投稿タイプ」で、親・子・孫という階層構造を作ったことはありますよね。この時、データベースの `wp_posts` テーブルにある `post_parent` というカラムには、親となる投稿のIDが記録されます。
例えば、「親ページID: 10」の下に「子ページID: 25」、さらにその下に「孫ページID: 42」があるとします。
[ID: 10 (親)]
└── [ID: 25 (子)]
└── [ID: 42 (孫)]
この「孫ページから親(あるいは先祖)までの一覧」を取得したい時、素朴なプログラミング初心者は次のようなコードを書きがちです。
陥りがちなアンチパターン(再帰的クエリ)
// 良くない例:ループの中で何度もデータベースに問い合わせてしまう
function get_ancestors_recursively( $post_id, &$ancestors = [] ) {
global $wpdb;
// 現在の投稿の post_parent を取得するSQL
$parent_id = $wpdb->get_var( $wpdb->prepare(
“SELECT post_parent FROM {$wpdb->posts} WHERE ID = %d”,
$post_id
) );
if ( $parent_id ) {
$ancestors[] = $parent_id;
// 親がいなくなるまで関数を自分自身で呼び出し続ける(再帰)
get_ancestors_recursively( $parent_id, $ancestors );
}
return $ancestors;
}
この書き方、動くには動くのですが、データベースの観点からは最悪(N+1問題の亜種)です。
階層が深ければ深いほど、データベースへの通信回数(クエリ数)が増加します。もしサイトのアクセスが急増したら、MySQLサーバーは悲鳴を上げ、CPU使用率が跳ね上がってサイトがダウンしてしまうでしょう。
—
救世主:「パス列挙モデル(Path Enumeration)」という非正規化
データベースの正規化理論では、「同じデータを重複して持たせてはならない」と教わりますよね。しかし、Webアプリケーションのパフォーマンスを極限まで高める現場では、あえてデータを重複・加工して持たせる「非正規化(Denormalization)」が強力な武器になります。
そこで導入するのが 「パス列挙モデル」 です。
考え方はとてもシンプル。`wp_posts` テーブル(あるいはカスタムメタ、あるいは専用テーブル)に、先祖から自分までのIDの並びを「スラッシュ区切りの文字列」としてあらかじめ保存しておくのです。
パス列挙のイメージ図
| ID | post_title | post_parent | ancestor_path(新しく持たせるパス情報) |
| :— | :— | :— | :— |
| 10 | ホーム | 0 | `/10/` |
| 25 | サービス紹介 | 10 | `/10/25/` |
| 42 | 料金プラン | 25 | `/10/25/42/` |
この `ancestor_path` さえあれば、「ID: 42の先祖をすべて知りたい」と思ったときに、再帰クエリを使う必要が一切なくなります。 SQLの `LIKE` 検索を一発投げるだけで、瞬時にすべての先祖や子孫が取得できるようになるんです。
—
実装アプローチ:保存時にパスを自動計算する
では、実際にこのパスをどのようにWordPressに組み込むのか、具体的なコードを見ていきましょう。
WordPressには投稿が保存されたときに走るフック `save_post` があります。このタイミングで、親のパスを取得し、自分のIDを結合してデータベースに保存する仕組みを作ります。
今回はわかりやすくするために、カスタムフィールド(`wp_postmeta`)に `ancestor_path` というキーで保存する例で解説しますね。
/
- 投稿が保存・更新された時に、階層パスを自動計算してメタデータに保存する
/
function update_post_ancestor_path( $post_id, $post, $update ) {
// リビジョンや自動保存の場合は何もしない
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
// 管理画面からの保存など、特定の権限や条件チェックが必要な場合はここに追加します
global $wpdb;
$parent_id = $post->post_parent;
$path = ‘/’ . $post_id . ‘/’;
if ( $parent_id > 0 ) {
// 親のパスをメタデータから取得する
$parent_path = get_post_meta( $parent_id, ‘ancestor_path’, true );
if ( $parent_path ) {
// 親のパスが存在する場合は、結合する(例: “/10/” + “25/” -> “/10/25/”)
$path = rtrim( $parent_path, ‘/’ ) . ‘/’ . $post_id . ‘/’;
}
}
// 計算したパスをメタデータに保存(なければ追加、あれば更新)
update_post_meta( $post_id, ‘ancestor_path’, $path );
}
add_action( ‘save_post’, ‘update_post_ancestor_path’, 10, 3 );
コードの意味を紐解くポイント
1. `save_post` フック: 投稿が新規追加・更新された瞬間に自動実行されます。
2. 安全な判定: 自動保存(Autosave)やリビジョン(過去の履歴)の保存時には処理をスルーするようにガードを入れています(これがないと無駄なDB負荷がかかります)。
3. 文字列の結合: 親のパスの末尾に自分のIDを繋げることで、常に「ルートから自分までの正確な家系図(パス)」が維持されます。
—
パスを使った超高速なデータ取得クエリ
パスが保存できたら、あとはこのデータを活用して検索するだけです。
例えば、「ある特定の親ページ(ID: 10)配下にあるすべてのサブページ・孫ページを一網打尽に取得したい」という場合は、以下のようなSQLを書きます。
/
- 特定の親IDの配下にあるすべての投稿を、再帰クエリなしで一括取得する
/
function get_descendants_by_parent_id( $parent_id ) {
global $wpdb;
// 親のパスを特定する(例: “/10/”)
$target_path = ‘/’ . $parent_id . ‘/’;
// wp_postmeta テーブルから、パスが指定の文字列で始まるものを一発検索
$sql = $wpdb->prepare(
“SELECT post_id FROM {$wpdb->postmeta}
WHERE meta_key = ‘ancestor_path’
AND meta_value LIKE %s
AND post_id != %d”,
‘%’ . $wpdb->esc_like( $target_path ) . ‘%’,
$parent_id
);
$post_ids = $wpdb->get_col( $sql );
// 取得したID配列から、実際のWP_Postオブジェクトの配列に戻す
return get_posts( array(
‘post__in’ => $post_ids,
‘posts_per_page’ => -1,
‘orderby’ => ‘menu_order’,
‘order’ => ‘ASC’,
) );
}
このアプローチの圧倒的なメリット
- データベースへの負荷が劇的に軽減: ループ内で何回もクエリを投げる必要がなくなり、1回のSQL発行(`LIKE` 検索)で完結します。
- 拡張性の高さ: 階層が何段階(10階層、20階層……)に深くても、取得速度のパフォーマンスが落ちにくいという大きなメリットがあります。
—
開発現場で陥りやすい文法・設計エラーと注意点
最後に、このパス列挙モデルを実務に導入する際につまずきやすいポイントをいくつかシェアしておきますね。
1. 既存データへのマイドルウェア(一括処理)の必要性
- すでに何千件も投稿がある既存サイトにこの仕組みを入れる場合、過去の投稿には `ancestor_path` が入っていません。導入時には、一度すべての既存投稿を走査してパスを計算・保存する「移行スクリプト(ワンタイムスクリプト)」を必ず実行する必要があります。
2. 親を変更したときの子孫への伝播
- 途中の階層にあるページ(例: ID: 25)の親を別の親に変更した場合、その子である孫(ID: 42)のパスも連動して書き換える必要があります。`save_post` 内で子孫のパスも再計算して一括更新するロジック(トランザクション的な処理)を組むのが理想的です。
—
まとめ
今回は、WordPressのデータベース構造を深く理解し、パフォーマンスを限界まで引き上げる「パス列挙モデル」について解説しました。
- 再帰的クエリはコードがシンプルに見える反面、データベースの負荷(N+1問題)を引き起こすため大規模サイトでは御法度。
- パス列挙モデル(非正規化)を使うことで、階層構造の検索を「1回のシンプルなクエリ」に置き換えることができる。
- `save_post` フックを巧みに利用して、データの保存時にパスを自動管理する。
ここをクリアできれば、単なる「使い方を知っている人」から「WordPressの構造をコントロールできるエンジニア」へと大きくステップアップできます。ぜひ次のプロジェクトで試してみてくださいね!