はじめに
みなさん、こんにちは。Happiness Chainメンターのryoです。
今回は、RailsやDjangoの課題を取り組んでいる受講生のコードレビューをする際に、よく指摘するN + 1について、詳細と解決策をお話します。
N+1問題とは
RailsやDjango等に付帯するORMで、1つのテーブルから関連するテーブルのデータも取得する際に、発生する問題です。
例えばXのツイート機能で、ツイート一覧を表示する画面にて説明すると、
ツイートには、ユーザーが紐づいており、ユーザー画像を表示しています。
Railsに以下の様なcontroller, viewを記述しているとします。
class TweetsController < ApplicationController
def index
@tweets = Tweet.all
end
end
<% @tweets.each do |tweet| %>
<div>
<div>
<p><%= tweet.content %></p>
</div>
</div>
<% end %>
Djangoには以下の様なview、templateを記述しているとします。
class TweetListView(Listview):
template_name = 'tweet_list.html'
query_set = Tweet.objects.all()
{% for tweet in object_list %}
<div>
<div>
<p>{{ tweet.content }}</p>
</div>
</div>
{% endfor %}
上記の場合に、裏側ではSQLが叩かれてデータを取得するかと思いますが、以下のようなSQLが叩かれます。
SELECT `tweets`.`id`, `content`, `user_id` FROM `tweets`;
上記の場合、特に問題ないのですが、以下のようにユーザー画像を表示するとします。
<% @tweets.each do |tweet| %>
<div>
+ <img src={{ tweet.user.img }}>
<div>
<p><%= tweet.content %></p>
</div>
</div>
<% end %>
{% 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_load
、select_related
がよいです。
- Railsの場合:
eager_load
Tweet.eager_load(:user)
- Djangoの場合:
select_related
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を絞り込むpreload
、prefetch_related
が良いです。
- Railsの場合:
preload
User.preload(:tweets)
- Djangoの場合:
prefetch_related
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を対応しておかないと、パフォーマンスは目に見えて落ちますので、是非おさえておきましょう。