0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「N+1問題」って結局なに?ダメな理由と3つの対策

Posted at

N+1問題とは何か

本来1回で済むデータベースへの問い合わせを、N+1回も繰り返してしまうことです。

具体例

ブログの記事一覧ページで、「記事のタイトル」と「書いた人の名前」を表示する場面を考えます。

// 問題のあるコード
$posts = getAllPosts(); // 記事を全部取得

foreach ($posts as $post) {
    echo $post->title;
    echo $post->author->name; // ここで毎回データベースに問い合わせ
}

このコードでは、裏側でこんなことが起きています。

-- 1回目:記事を取得
SELECT * FROM posts;

-- 2回目以降:各記事の著者を1件ずつ取得
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
...

記事が10件なら、合計11回の問い合わせが発生します。これが「N+1問題」です。

なぜダメなのか?

1. とにかく遅い

データベースへの問い合わせは、普通の処理より圧倒的に時間がかかります。

例えば、1回の問い合わせに0.1秒かかるとします。

  • 問題なし:2回 × 0.1秒 = 0.2秒
  • N+1問題あり(100件):101回 × 0.1秒 = 10.1秒

10秒も待たされたら、ユーザーは離れていきます。

2. データが増えると悪化する

開発中は少量のテストデータで動かすので気づきませんが、本番環境でデータが増えると突然使い物にならなくなります。

  • 記事10件 → 11回の問い合わせ
  • 記事100件 → 101回の問い合わせ
  • 記事1,000件 → 1,001回の問い合わせ

最初は快適だったのに、使っているうちにどんどん重くなっていくのがN+1問題の怖いところです。

3. サーバーに負荷がかかる

大量の問い合わせが同時に発生すると、データベースサーバーが耐えられなくなります。

複数のユーザーが同時にアクセスすると、さらに深刻です。10人が同時にアクセスして、それぞれ100回の問い合わせを発生させたら、合計1,000回の問い合わせがサーバーに降りかかります。

よくあるケース

SNSのタイムライン

$posts = getTimelinePosts(); // 投稿100件を取得

foreach ($posts as $post) {
    echo $post->user->name;    // ユーザー名
    echo $post->likeCount;     // いいね数
}

投稿100件に対して、ユーザー情報といいね数を取得すると、1 + 100 + 100 = 201回の問い合わせが発生します。

商品一覧ページ

$products = getAllProducts(); // 商品500件

foreach ($products as $product) {
    echo $product->category->name; // カテゴリー名を表示
}

商品500件のカテゴリー名を表示するだけで、501回の問い合わせになります。

陥りやすいパターン

ループの中でデータ取得

// NG
$items = getItems();
foreach ($items as $item) {
    $owner = getUser($item->ownerId); // 毎回DB問い合わせ
    echo $owner->name;
}

ループで回しながらデータベースに問い合わせると、ほぼ確実にN+1問題が発生します。

テンプレートでデータ取得

<!-- NG -->
{% for post in posts %}
  <p>{{ post.author.name }}</p>  <!-- 毎回DB問い合わせ -->
{% endfor %}

見た目はシンプルですが、裏側では毎回データベースにアクセスしています。

N+1問題の対策3つ

1. Eager Loading(先読み込み)を使う

フロントエンドではLazy Loadという手法を使うことがよくありますが、それの逆バージョンみたいなものがEager Loadingです。

これは、関連データを最初にまとめて取得する方法です。ORMの多くがこの機能を持っています。

Laravelの例

// NG:N+1問題が発生
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name; // 毎回DB問い合わせ
}

// OK:with()で先読み込み
$posts = Post::with('author')->get();
foreach ($posts as $post) {
    echo $post->author->name; // すでに読み込み済み
}

実行されるSQL

-- 1回目:記事を取得
SELECT * FROM posts;

-- 2回目:関連する著者をまとめて取得
SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5);

2回のクエリで済むようになります。

2. JOINを使って一度に取得する

SQLのJOIN句を使えば、1回のクエリで全データを取得できます。

$query = "
    SELECT 
        posts.*,
        users.name as author_name
    FROM posts
    LEFT JOIN users ON posts.user_id = users.id
";

$results = DB::query($query);

1回のクエリで記事と著者の情報が両方手に入ります。

3. データを事前にまとめて取得する

必要なIDをすべて集めてから、一度にまとめて取得する方法です。

// 記事を取得
$posts = getAllPosts();

// 必要なユーザーIDを集める
$userIds = array_map(function($post) {
    return $post->userId;
}, $posts);

// ユーザーをまとめて取得
$users = User::whereIn('id', $userIds)->get();

// 配列に変換して使いやすくする
$userMap = [];
foreach ($users as $user) {
    $userMap[$user->id] = $user;
}

// マッピング
foreach ($posts as $post) {
    $post->author = $userMap[$post->userId];
}

少し手間ですが、ORMがEager Loadingをサポートしていない場合に有効です。

まとめ

N+1問題とは、本来1回で済むDB問い合わせをN+1回も繰り返してしまうこと。

なぜダメか

  • ページの読み込みが遅くなる
  • データが増えると指数関数的に悪化する
  • サーバーがパンクする

よくあるパターン

  • ループの中でデータを取得している
  • テンプレートで関連データを表示している

N+1問題は初心者が最も陥りやすい落とし穴ですが、理解すれば避けられます。

コードを書くときは「ループの中でデータベースに問い合わせていないか?」を意識するだけで、大きく改善できます。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?