N+1問題とは何か?原因と対策をきちんと理解する

公開日:
目次

N+1問題という言葉は聞いたことがあるけど、きちんと理解したかったので調べてまとめました。

図書館で理解するN+1問題

プログラミングの前に、日常の例で考えてみます。

あなたは図書館で10冊の本を借りたいとします。

方法A(N+1方式)

  1. 司書さんに「この本ください」と1冊お願いする
  2. 司書さんが書庫に行って本を取ってくる
  3. 1冊受け取る
  4. 次の本をお願いする
  5. また司書さんが書庫に行って本を取ってくる
  6. ...これを10回繰り返す

方法B(一括取得方式)

  1. 司書さんに「この10冊ください」とリストを渡す
  2. 司書さんが書庫に1回行って10冊まとめて取ってくる
  3. 10冊まとめて受け取る

どちらが効率的かは明らかです。方法Aでは司書さんが 10回も書庫を往復 しなければなりません。方法Bなら 1回の往復 で済みます。

N+1問題とは、方法Aのように「1回のリクエストで済むはずのところを、N回+1回のリクエストにしてしまう」問題です。「+1」は最初にリストを取得するクエリで、「N」は各アイテムに対する追加クエリです。

なぜ問題になるのか

図書館の例で、書庫への往復に1分かかるとします。

  • 方法A: 1分 × 10回 = 10分
  • 方法B: 1分 × 1回 = 1分

10冊なら9分の差ですが、100冊だと99分の差になります。データベースやAPIでも同じことが起きます。

// N+1問題が発生するコード例
const users = await db.query('SELECT * FROM users');  // 1回目のクエリ

for (const user of users) {
  // ユーザーごとにクエリを実行(N回のクエリ)
  const posts = await db.query('SELECT * FROM posts WHERE user_id = ?', user.id);
  user.posts = posts;
}

ユーザーが100人いれば、1 + 100 = 101回のクエリが発生します。

N+1が問題にならないケース

N+1は 常に悪いわけではありません 。以下のケースでは問題にならないか、むしろN+1の方が良い場合もあります。

Nが小さい場合

対象が10件程度なら、オーバーヘッドは気にならないことが多いです。最適化にかける時間の方がもったいないかもしれません。

ネットワーク遅延がない場合

アプリケーションとデータベースが同じサーバーにある場合、通信コストはほぼゼロです。「書庫が目の前にある」状態なので、往復の負担は小さくなります。

キャッシュが効く場合

同じデータを繰り返し参照する場合、N+1でも2回目以降はキャッシュから取得できます。

// キャッシュを使う例
const cache = new Map();

async function getProductName(productId) {
  if (cache.has(productId)) {
    return cache.get(productId);  // キャッシュから取得
  }
  const product = await db.query('SELECT name FROM products WHERE id = ?', productId);
  cache.set(productId, product.name);
  return product.name;
}

JOINが遅い場合

一括取得(JOIN)はデータ転送量が増えることがあります。関連データが多いと、同じデータが何度も転送されて逆に遅くなることがあります[1]

どう判断するか

N+1問題への対処は 推測ではなく計測 が重要です。

計測する

実際にクエリがどれくらい時間がかかっているかを測定しましょう。JavaScriptではconsole.time()console.timeEnd()を使って簡単に計測できます。

console.time('query');
// 処理
console.timeEnd('query');  // query: 1234ms

判断のフローチャート

計測結果を元に、対処が必要かどうかを判断します。

  1. 遅いと感じる? → 感じないなら放置でOK
  2. Nはどれくらい? → 10件以下なら気にしなくていい場合が多い
  3. ネットワーク越し? → 同一サーバーなら影響小
  4. キャッシュ可能? → 可能ならキャッシュで解決

解決が必要な場合

問題があると判断した場合、どのように対処すればよいでしょうか。このブログのような投稿サイトを例に考えてみましょう。

問題のあるコード(N+1)

まず、N+1問題が発生しているコードを見てみます。

const users = await getUsers();
for (const user of users) {
  user.posts = await getPostsByUserId(user.id);
}

ユーザー一覧を取得した後、各ユーザーごとに投稿を取得しています。ユーザーが100人いれば、101回のクエリが発生します。

解決策1:一括取得(Eager Loading)

ユーザーIDをまとめて渡し、一度のクエリで全ての投稿を取得します。

const users = await getUsers();
const userIds = users.map(u => u.id);
const allPosts = await getPostsByUserIds(userIds);  // IN句で一括取得
for (const user of users) {
  user.posts = allPosts.filter(p => p.userId === user.id);
}

これでクエリ回数は2回になります。ただし、filter()は各ユーザーごとに全投稿をスキャンするため、計算量はO(N×M)になります(Nはユーザー数、Mは投稿数)。ユーザー100人×投稿1000件なら10万回の比較が発生します。データ量が多い場合は次の方法を検討します。

解決策2:インデックス(事前にマッピングを作る)

投稿をユーザーIDでグループ化しておくと、O(1)で取得できるようになります。

const postsByUserId = new Map();
for (const post of allPosts) {
  if (!postsByUserId.has(post.userId)) {
    postsByUserId.set(post.userId, []);
  }
  postsByUserId.get(post.userId).push(post);
}

for (const user of users) {
  user.posts = postsByUserId.get(user.id) || [];
}

Mapを使うことで、ユーザーIDから投稿を直接取得できます。

参考

脚注
  1. https://techblog.raccoon.ne.jp/archives/1738891957.html ↩︎