15
12

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とは【Rails、Django】

Last updated at Posted at 2024-11-07

はじめに

みなさん、こんにちは。Happiness Chainメンターのryoです。

今回は、RailsやDjangoの課題を取り組んでいる受講生のコードレビューをする際に、よく指摘するN + 1について、詳細と解決策をお話します。

N+1問題とは

RailsやDjango等に付帯するORMで、1つのテーブルから関連するテーブルのデータも取得する際に、発生する問題です。
例えばXのツイート機能で、ツイート一覧を表示する画面にて説明すると、
ツイートには、ユーザーが紐づいており、ユーザー画像を表示しています。

tweet_list.jpg

Railsに以下の様なcontroller, viewを記述しているとします。

(Rails)
class TweetsController < ApplicationController
  def index
    @tweets = Tweet.all
  end
end
(Rails)
<% @tweets.each do |tweet| %>
  <div>
    <div>
      <p><%= tweet.content %></p>
    </div>
  </div>
<% end %>

Djangoには以下の様なview、templateを記述しているとします。

(Django)
class TweetListView(Listview):
    template_name = 'tweet_list.html'
    query_set = Tweet.objects.all()
(Django)
{% for tweet in object_list %}
    <div>
        <div>
            <p>{{ tweet.content }}</p>
        </div>
    </div>
{% endfor %}

上記の場合に、裏側ではSQLが叩かれてデータを取得するかと思いますが、以下のようなSQLが叩かれます。

SELECT `tweets`.`id`, `content`, `user_id` FROM `tweets`;

上記の場合、特に問題ないのですが、以下のようにユーザー画像を表示するとします。

(Rails)
<% @tweets.each do |tweet| %>
    <div>
+      <img src={{ tweet.user.img }}>
       <div>
         <p><%= tweet.content %></p>
       </div>
    </div>
<% end %>
(Django)
{% for tweet in object_list %}
    <div>
+       <img src={{ tweet.user.img }}>
        <div>
            <p>{{ tweet.content }}</p>
        </div>
    </div>
{% endfor %}

上記の場合は、以下のようなSQLが叩かれます。
まず、ツイート一覧を取得したあとに、ユーザーを1つ1つSQLを分けて取得しています。

SELECT tweets.id, content, user_id FROM tweets;
SELECT users.id, img FROM users WHERE users.id = 1;
SELECT users.id, img FROM users WHERE users.id = 2;
SELECT users.id, img FROM users WHERE users.id = 3;
SELECT users.id, img FROM users WHERE users.id = 4;
...

これは何が起こっているのかというと、ツイート一覧を取得する段階では、以下のSQLのみですが、

SELECT tweets.id, content, user_id FROM tweets;

for tweet in object_list@tweets.each do |tweet|のループ処理内で、tweet.user.imgと記述しているので、tweetのuser_idから検索をかけて、userを一つ取得するため、tweetの数だけ、sqlが叩かれることになります。
tweetが10件の場合は、10回userを取得するSQLが叩かれます。レコードの数 + 1件SQLが叩かれるので、N+1問題と呼ばれています。

解決方法

Rails、DjangoのORM共に、一覧データ取得時に結合、もしくはあとで一括取得する(where id = (1, 2, 3, 4...)のような形でfilterする)メソッドがあります。
Rails、Djangoにはそれぞれいくつかメソッドがあるのですが、関連の種類によってSQLのパフォーマンスが変動します。

多対1の関係の場合

tweetに紐づくuserを取得するような親レコードを取得する場合、1つのSQLで、join句を使用し結合元のデータと一緒に取得する、eager_loadselect_relatedがよいです。

  • Railsの場合: eager_load
(Rails)
Tweet.eager_load(:user)
  • Djangoの場合: select_related
(Django)
Tweet.objects.select_related('user')

実際に発行されるSQL

SELECT tweets.id, tweets.content, tweets.user_id, users.id, users.img FROM tweets INNER JOIN users ON tweets.user_id = users.id;

1対多の関係の場合

userに紐づくtweetを複数取得する場合、user一覧を取得したあと、そのuserのid一覧にて、tweetを絞り込むpreloadprefetch_relatedが良いです。

  • Railsの場合: preload
(Rails)
User.preload(:tweets)
  • Djangoの場合: prefetch_related
(Django)
User.objects.prefetch_related('tweets')

実際に発行されるSQL

SELECT users.* FROM users;
SELECT tweets.* FROM tweets WHERE tweets.user_id IN (1, 2, 3, ...);

またN+1に関しては、検知できるライブラリがあるので、是非入れましょう。

  • Railsの場合: bullet
  • Djangoの場合: django-debug-toolbar

終わりに

いかがだったでしょうか?
現場では、取り扱うデータ数が多いので、N + 1を対応しておかないと、パフォーマンスは目に見えて落ちますので、是非おさえておきましょう。

15
12
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
15
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?