2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WordPressプラグインで「DBが泣く」アンチパターン5つ。実務で踏んだNG例と直し方

2
Posted at

先に一覧から。WordPressプラグインを書いていて、実際に自分が踏んだ「DBが泣く」アンチパターンを5つ、NG例と直し方で並べます。どれもコピペで直せる形にしました。

  1. 値を直接連結する(SQLインジェクション)
  2. autoloadで巨大データを wp_options に置く
  3. postmeta を検索して全走査させる
  4. ループの中でクエリを回す(N+1)
  5. カウントを 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_cacheIN で一括に
  • カウントは read-modify-write をやめ、DB側で n = n + 1

どれも、動いているうちは見えません。データが増えたとき、アクセスが重なったときに、初めてDBが泣きます。増える前提・重なる前提で書いておくと、あとの自分が助かります。役に立ったらストックして、実装で迷ったとき見返してください。


ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?