先に一覧から。WordPressプラグインを書いていて、実際に自分が踏んだ「DBが泣く」アンチパターンを5つ、NG例と直し方で並べます。どれもコピペで直せる形にしました。
- 値を直接連結する(SQLインジェクション)
- autoloadで巨大データを wp_options に置く
- postmeta を検索して全走査させる
- ループの中でクエリを回す(N+1)
- カウントを read-modify-write で更新する(lost update)
上から順に、悪い例・良い例・なぜ、で書きます。
1. 値を直接連結する(SQLインジェクション)
いちばん危ないやつです。ユーザーやAIから来た値を、クエリにそのまま埋め込む。
// ❌ NG:値を直接連結(SQLインジェクション)
$title = $_GET['title'];
$rows = $wpdb->get_results(
"SELECT * FROM {$wpdb->posts} WHERE post_title = '$title'"
);
// ✅ OK:prepare でプレースホルダに渡す
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_title = %s",
$_GET['title']
)
);
なぜ。値を文字列連結すると、' OR 1=1 -- のような入力でクエリの意味を書き換えられます。$wpdb->prepare は値をプレースホルダ(%s / %d / %f)として安全に埋め込むので、そこが塞がります。テーブル名は変数化できないので、{$wpdb->posts} のように定義済みのプロパティを使います。
2. autoloadで巨大データを wp_options に置く
見落としやすい、重さの原因です。update_option は、指定しないと autoload が有効になります。
// ❌ NG:巨大な配列を autoload 付きで保存
update_option('myplugin_big_cache', $huge_array); // autoload = yes
// ✅ OK:autoload を no にする(または専用の置き場へ)
add_option('myplugin_big_cache', $huge_array, '', 'no');
// 一時データなら transient に逃がす
set_transient('myplugin_cache', $data, HOUR_IN_SECONDS);
なぜ。autoload が yes のオプションは、毎リクエストの最初にまとめて読み込まれます(wp_load_alloptions)。ここに巨大なデータを置くと、そのプラグインを使っていないページまで、毎回そのデータを引きずって重くなる。大きいもの・たまにしか使わないものは、autoload を切るか、transient や専用テーブルに逃がします。
3. postmeta を検索して全走査させる
データが増えてから効いてくる、静かな地雷です。
// ❌ NG:件数が増えると重くなる meta_value 検索
$q = new WP_Query([
'meta_query' => [
['key' => 'price', 'value' => 5000, 'compare' => '>'],
],
]);
// ✅ OK:検索・並べ替えするキーは、専用テーブルにインデックス付きで持つ
// 例) myplugin_items(item_id, price INT, INDEX(price))
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT item_id FROM {$wpdb->prefix}myplugin_items WHERE price > %d",
5000
)
);
なぜ。postmeta は key と value を縦持ちする汎用テーブルで、meta_value は可変長のため、大小比較や範囲検索にインデックスが効きにくい。数十件なら平気でも、数万件になると全走査になって一気に遅くなります。頻繁に検索・ソートするキーは、正規化した専用テーブルにインデックス付きで持たせるのが安全です。
4. ループの中でクエリを回す(N+1)
コードは素直なのに、DBだけが悲鳴を上げるパターンです。
// ❌ NG:ID ごとに1回ずつ問い合わせる(N+1)
foreach ($post_ids as $id) {
$price = get_post_meta($id, 'price', true); // 件数ぶんDBを叩く
}
// ✅ OK:まとめて1回で取得してキャッシュに載せる
update_meta_cache('post', $post_ids); // 一括ロード
foreach ($post_ids as $id) {
$price = get_post_meta($id, 'price', true); // キャッシュから返る
}
なぜ。ループの中で1件ずつ問い合わせると、件数ぶんクエリが飛びます。100件なら100回。update_meta_cache で一括ロードしておけば、あとの get_post_meta はキャッシュから返るので、DBへの往復が1回で済みます。生SQLなら IN (...) でまとめて取る、でも同じです。
5. カウントを read-modify-write で更新する(lost update)
同時アクセスで、数字が合わなくなるやつです。閲覧数・在庫・利用回数で踏みます。
// ❌ NG:読んで、足して、書き戻す(競合で更新が消える)
$count = (int) get_option('myplugin_count');
update_option('myplugin_count', $count + 1);
// ✅ OK:DB側でアトミックに加算する
$wpdb->query(
"UPDATE {$wpdb->prefix}myplugin_counters
SET count = count + 1
WHERE name = 'view'"
);
なぜ。読んで足して書き戻す間に、別のリクエストが同じ値を読むと、片方の +1 が消えます(lost update)。アクセスが重なるほどズレる。SET count = count + 1 のようにDB側で加算すれば、その1文がアトミックに処理されるので、同時に来ても取りこぼしません。自分は消費量のカウントでこれを踏んで、アトミックな加算に直しました。
早見でまとめ
- 値は連結しない。
$wpdb->prepareでプレースホルダに渡す - 巨大データは autoload を切る。transient か専用テーブルへ
- 検索・ソートするキーは、postmeta ではなくインデックス付きの専用テーブルに
- ループ内クエリ(N+1)は、
update_meta_cacheやINで一括に - カウントは read-modify-write をやめ、DB側で
n = n + 1
どれも、動いているうちは見えません。データが増えたとき、アクセスが重なったときに、初めてDBが泣きます。増える前提・重なる前提で書いておくと、あとの自分が助かります。役に立ったらストックして、実装で迷ったとき見返してください。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。