はじめに
普段、Railsを使って開発されている方であれば、関連するテーブルのデータを扱うときなど、N+1クエリを発行していないか、気をつけているかと思います。
また、うっかりN+1クエリを発行してしまうことを防ぐため、N+1クエリを自動で検出するBulletというgemを導入しているかたも多いかと思います。ただ、Bulletで検出されないクエリでもN+1になっているケースがあります。
この記事ではBulletでは検出されないが、N+1になっているクエリを改善する方法を紹介します。
N+1問題とは
N+1問題とは、1回のクエリで済むところをデータ量(N)の回数、クエリを発行してしまう問題のことです。
例えば以下のようなクエリはN+1クエリです。
Post Load (0.3ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:14
Comment Load (0.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
↳ app/views/posts/index.html.erb:17:in `map'
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]
↳ app/views/posts/index.html.erb:17:in `map'
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 3]]
↳ app/views/posts/index.html.erb:17:in `map'
上の例ではpostsを読み込むのに1回、そこからpostsに関連するcommentsを読み込むのにpostsのデータ量である3(N)回、合計4回のSQLが発行されています。
データ量(N)が増えるほど、発行されるSQLが多くなりその分パフォーマンスが低下します。
このN+1クエリを自動で検出してくれる便利なgemがBulletです。Bulletを使うと、RailsのログやブラウザでN+1であることを確認できます。
N+1クエリの解消
N+1クエリの検証のため、サンプルのRailsアプリケーションを作成します。
サンプルアプリケーション作成後、一般的なBulletで検出されたN+1クエリの解消法を紹介します。そのあと、Bulletでは検出されないN+1クエリの解消法を紹介します。
各ライブラリのバージョンは以下です。
- Rails: 6.0.2
- Bullet: 6.0.2
まずはアプリとテーブルを作成します。
$ rails new test_n_plus_1
$ cd test_n_plus_1
$ rails g scaffold post name:string
$ rails g scaffold comment name:string post:references positive_count:integer negative_count:integer
$ bundle exec rake db:migrate
モデルの関連を定義します。
# app/model/post.rb
class Post < ActiveRecord::Base
has_many :comments
end
# app/model/comment.rb
class Comment < ActiveRecord::Base
belongs_to :post
end
Railsコンソールからデータを作成します。
$ rails c
>> post1 = Post.create(:name => 'first')
>> post2 = Post.create(:name => 'second')
>> post3 = Post.create(:name => 'third')
>> post1.comments.create(:name => 'first', positive_count: 3, negative_count: 1)
>> post1.comments.create(:name => 'second', positive_count: 10, negative_count: 4)
>> post2.comments.create(:name => 'first', positive_count: 3, negative_count: 1)
>> post2.comments.create(:name => 'second', positive_count: 10, negative_count: 4)
>> post3.comments.create(:name => 'first', positive_count: 3, negative_count: 1)
>> post3.comments.create(:name => 'second', positive_count: 10, negative_count: 4)
Bulletをインストールします。
Gemfileに以下を追加:
gem "bullet"
$ bundle install
$ bundle exec rails g bullet:install
Bulletで検出されるN+1クエリを解消する
まずはBulletで検出されるN+1クエリの解消法を紹介します。
以下のように、関連するテーブルデータをeach内で参照するケースを考えます。
<!-- app/views/posts/index.html.erb -->
<% @posts.each do |post| %>
<tr>
<td><%= post.comments.map(&:name) %></td>
</tr>
<% end %>
Bulletを導入することでN+1クエリを検出した際、以下のようにログやブラウザでN+1クエリであることを知らせてくれます。
USE eager loading detected
Post => [:comments]
Add to your finder: :includes => [:comments]
Bulletで検出されたN+1クエリは以下のように解消します。
$ diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index afbce67..53b1e2d 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -4,7 +4,7 @@ class PostsController < ApplicationController
# GET /posts
# GET /posts.json
def index
- @posts = Post.all
+ @posts = Post.includes(:comments)
end
このように、#includes
を指定することで関連をまとめて取得、キャッシュし、最小限のクエリ回数で読み込まれるようになります。
変更前のクエリ
Post Load (0.3ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:14
Comment Load (0.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
↳ app/views/posts/index.html.erb:17:in `map'
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]
↳ app/views/posts/index.html.erb:17:in `map'
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 3]]
↳ app/views/posts/index.html.erb:17:in `map'
変更後のクエリ
Post Load (0.2ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:14
Comment Load (0.8ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?, ?) [["post_id", 1], ["post_id", 2], ["post_id", 3]]
↳ app/views/posts/index.html.erb:14
変更前はpostsの3(N)個のデータ量の回数、発行されていた3回のクエリが1回になりました。
Bulletで検出されないN+1クエリを解消する
次に、関連テーブルのカラムを参照するだけでなく、関連テーブルの該当カラムの合計を計算してそれを表示するケースを考えてみます。例えば、以下のようなケースです。
<!-- app/views/posts/index.html.erb -->
<% @posts.each do |post| %>
<tr>
<td><%= post.comments.sum(:positive_count) %></td>
<td><%= post.comments.sum(:negative_count) %></td>
</tr>
<% end %>
この場合、each内で都度、合計値を取得する余分なクエリが発生します。
Post Load (0.2ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:14
(0.2ms) SELECT SUM("comments"."positive_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
↳ app/views/posts/index.html.erb:16
(0.1ms) SELECT SUM("comments"."negative_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
↳ app/views/posts/index.html.erb:17
(0.1ms) SELECT SUM("comments"."positive_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]
↳ app/views/posts/index.html.erb:16
(0.1ms) SELECT SUM("comments"."negative_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]
↳ app/views/posts/index.html.erb:17
(0.1ms) SELECT SUM("comments"."positive_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 3]]
↳ app/views/posts/index.html.erb:16
(0.2ms) SELECT SUM("comments"."negative_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 3]]
↳ app/views/posts/index.html.erb:17
また、BulletはN+1であると検出してくれません。
この場合、以下のようなscopeを定義します。JOINでテーブルを結合することで、1回のクエリで必要な情報を取得します。
# app/models/post.rb
scope :likes_count_per_id, -> {
joins(:comments)
.select(
"posts.id,
posts.name,
SUM(comments.positive_count) as sum_positive,
SUM(comments.negative_count) as sum_negative"
).group(:id)
}
このようなscopeを定義することで、each内では、取得したsum_positive
、sum_negative
を呼び出すことができます。
使い方は以下です。
# diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index afbce67..cfdc463 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -4,7 +4,7 @@ class PostsController < ApplicationController
# GET /posts
# GET /posts.json
def index
- @posts = Post.all
+ @posts = Post.likes_count_per_id
end
<!-- app/views/posts/index.html.erb -->
<% @posts.each do |post| %>
<tr>
<td><%= post.sum_positive %></td>
<td><%= post.sum_negative %></td>
</tr>
<% end %>
変更前のクエリ
Post Load (0.2ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:14
(0.2ms) SELECT SUM("comments"."positive_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
↳ app/views/posts/index.html.erb:16
(0.1ms) SELECT SUM("comments"."negative_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
↳ app/views/posts/index.html.erb:17
(0.1ms) SELECT SUM("comments"."positive_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]
↳ app/views/posts/index.html.erb:16
(0.1ms) SELECT SUM("comments"."negative_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]
↳ app/views/posts/index.html.erb:17
(0.1ms) SELECT SUM("comments"."positive_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 3]]
↳ app/views/posts/index.html.erb:16
(0.2ms) SELECT SUM("comments"."negative_count") FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 3]]
↳ app/views/posts/index.html.erb:17
変更後のクエリ
Post Load (0.1ms) SELECT posts.id,
posts.name,
SUM(comments.positive_count) as sum_positive,
SUM(comments.negative_count) as sum_negative FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" GROUP BY "posts"."id"
↳ app/views/posts/index.html.erb:14
変更前はデータ量(N)分、発行されていた6回(positive_count
, negative_count
で各3回)のクエリが1回になりました。
まとめ
Bulletには検出されていなくても、N+1クエリであったときに改善する方法を紹介しました。データ量がそれなりにあって、each内で都度、集計などの重い処理をする場合、思わぬパフォーマンスの低下を招くことがあります。
普段からlogでクエリの確認をすることは大事ですし、できる限り本番相当のデータを用意してパフォーマンスは問題ないか、N+1クエリを発行していないかを意識していきたいところです。