はじめに
お疲れ様です。
新卒エンジニアです。
N+1問題って知ってますか。
僕は正直、全然知りませんでした。
そんな中調べていて頭をよぎったのが、あのセリフ。
「キミの敗因は…容量(メモリ)のムダ使い❤︎」
くっ...
N+1を放置すると、気づかないうちにパフォーマンスを食いつぶしていく───。
だからこそ今回は、無駄を削ぎ落とし、より強いコードへ進化するためのN+1問題回避について学んでいきます。
N+1問題とは?
- 一覧を1回取得
- ループの中で関連データをN回取得
- 結果:合計 N+1回 のクエリ
悪い例(N+1発生)
$users = $this->userDao->findAll(); // ユーザー一覧
foreach ($users as $user) {
// 各ユーザーのポイント履歴を毎回問い合わせる
$points = $this->pointsDao->findByUserId($user->id);
echo "{$user->name} の獲得ポイント合計: " . array_sum(array_column($points, 'amount'));
}
何が悪いの???
-
クエリ回数が人数分ふえる
ユーザー100人なら101回、1000人なら1001回。 -
毎回かかる準備コストが無駄
クエリ1回ごとに「接続 → SQLをパース → 実行」という準備が走る。 -
キャッシュがほぼ効かない
似たようなSQLを大量に発行しても、WHERE句の値が毎回違うのでキャッシュにのらない。 -
人数が増えると雪だるま式に遅くなる
クエリ数がユーザー数に比例して増えるので、利用者が増えるほど処理時間が爆発。
→ ページネーションしても根本的な改善にはならない。 -
レビューで必ず止められる
コードレビューでの鉄板NGワードは「ループの中でDB呼ぶな」。
→ 書いた瞬間に赤ペンで直されるよ
良い例 1 :まとめて集計しちゃおう
// ユーザーと合計ポイントをまとめて取得するメソッド
$usersWithPoints = $this->userDao->findAllWithPointSums();
foreach ($usersWithPoints as $user) {
echo "{$user->name} の獲得ポイント合計: {$user->total_points}";
}
- クエリは 1回
- DBの集合処理(SUM, GROUP BY)を活用
- 「全体をまとめて処理する」設計にするだけで大幅に高速化
何が良いの???
- ネットワーク往復やSQLパースの固定コストを1回に圧縮
- DBが得意な集約処理(SUM, COUNT)に任せることで効率化
- 「N件のループ」ではなく「1回のまとまった処理」に変わる
良い例 : 一括取得 + マージ
$users = $this->userDao->findAll();
$userIds = array_column($users, 'id');
// ユーザーIDごとの合計を一気に取る
$pointSums = $this->pointsDao->sumByUserIds($userIds);
foreach ($users as $user) {
$sum = $pointSums[$user->id] ?? 0;
echo "{$user->name} の獲得ポイント合計: {$sum}";
}
- ユーザー一覧(1回)+ ポイント集計(1回) = 合計 2回
- N回にはならない設計
- JOINが複雑化しすぎる場合の現実的な解決策
何が良いの???
- 集約を2回に分けても「N回ループ」よりは圧倒的に効率的
- 複雑なJOINよりも保守しやすいケースがある
ただし、Dao側での処理が一度にgroup-byで取ってくるような処理の場合に限ります。
結局Dao側でN回のループをしていたら元も子もないので。
Laravelでの実装例
// 件数や合計値をまとめて取得する
$users = User::withSum('points', 'amount')->paginate(50);
foreach ($users as $user) {
echo "{$user->name} の獲得ポイント合計: {$user->points_sum_amount}";
}
-
withSumやwithCountを使うとEager Loading(先にまとめて取っておく)される - N+1をORMレベルで防げる
N+1以外の代表的なSQLアンチパターン
N+1問題はSQLアンチパターンの一つに過ぎません。
他にも避けるべき例を挙げます。
1. SELECT * の乱用
問題
不要なカラムまで取得してしまい、通信量やメモリ消費が増える。
また、カラム構成が変わると影響範囲が広くなる。
悪い例
SELECT * FROM users;
良い例
SELECT id, name, email FROM users;
解説
必要なカラムだけ明示的に指定することで、転送量とメモリ消費を抑えられ、
保守性も高まります。
2. ワイルドカード前置検索(LIKE '%…')
問題
先頭にワイルドカードを置くとインデックスが効かず、全件検索になります。
悪い例
SELECT * FROM users
WHERE name LIKE '%太郎';
良い例
-- 前方一致にすればインデックス利用可
SELECT * FROM users
WHERE name LIKE '太郎%';
解説
先頭固定ならインデックスを利用できます。
どうしても部分一致が必要なら全文検索エンジンを検討しましょう。
3. スカラ・サブクエリの多用
問題
行ごとにサブクエリが実行され、事実上N+1問題になってしまう。
悪い例
SELECT id, name,
(SELECT SUM(amount)
FROM points
WHERE points.user_id = users.id) AS total_points
FROM users;
良い例
SELECT u.id, u.name, COALESCE(SUM(p.amount), 0) AS total_points
FROM users u
LEFT JOIN points p ON p.user_id = u.id
GROUP BY u.id, u.name;
解説
JOINとGROUP BYでまとめれば、1回のクエリで済む。
パフォーマンスが大幅に改善されます。
まとめ
以上!
みなさんも容量(メモリ)の無駄遣いには気をつけてください。