N+1クエリに散々悩まされてきたので、これを解決するためのTipsを備忘録としてまとめておきたいと思います。
今回紹介する対策は2つです。
1. 安易にアソシエーションを使わずにカラムの値を使う
次のようなケースを考えます。

コントローラーでは次のようになっているとします。
def show
@article = Article.find(params[:id])
@comments = @article.comments
end
そして、viewではそのコメントがログイン中のユーザのものかどうかを判定しているとします。
# 略
<div class="container">
<% @comments.each do |comment| %>
<p>
<%= comment.content %>
<%= comment.is_comment_by?(current_user) ? '自分のコメント' : 'コメント' %>
</p>
<% end %>
</div>
is_comment_by?メソッドがこちらです。
def is_comment_by?(user)
self.user == user
end
これだと、コメントそれぞれに対して紐づくusersテーブルのデータを取得しているので、N+1クエリが発生します。また、usersテーブルのデータすべてを取得してかつActiveRecordのオブジェクトも生成してしまっているので、パフォーマンスが悪いです。

実際にN+1が発生しているログです。
Processing by ArticlesController#show as HTML
Parameters: {"id"=>"1"}
Article Load (1.3ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 1 LIMIT 1
↳ app/controllers/articles_controller.rb:79:in `set_article'
CACHE Article Load (0.0ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 1 LIMIT 1
↳ app/controllers/articles_controller.rb:24:in `show'
Rendering layout layouts/application.html.erb
Rendering articles/show.html.erb within layouts/application
User Load (0.8ms) SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
↳ app/views/articles/show.html.erb:13
Comment Load (1.3ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 1
↳ app/views/articles/show.html.erb:15
User Load (1.4ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/models/comment.rb:6:in `is_comment_by?'
CACHE User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/models/comment.rb:6:in `is_comment_by?'
CACHE User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/models/comment.rb:6:in `is_comment_by?'
CACHE User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/models/comment.rb:6:in `is_comment_by?'
CACHE User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/models/comment.rb:6:in `is_comment_by?'
CACHE User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/models/comment.rb:6:in `is_comment_by?'
Rendered articles/show.html.erb within layouts/application (Duration: 37.9ms | Allocations: 10916)
[Webpacker] Everything's up-to-date. Nothing to do
Rendered layout layouts/application.html.erb (Duration: 79.5ms | Allocations: 14392)
Completed 200 OK in 140ms (Views: 75.3ms | ActiveRecord: 25.5ms | Allocations: 25603)
そのコメントがユーザのものかどうかはusersテーブルを見なくても、commentレコードのuser_idを見れば分かるので、is_comment_by?メソッドはこのように書けます。
def is_comment_by?(user)
user_id == user.id
end
このようにするとusersテーブルは関係なくなるので、クエリもこのようにシンプルになります。
Processing by ArticlesController#show as HTML
Parameters: {"id"=>"1"}
Article Load (1.8ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 1 LIMIT 1
↳ app/controllers/articles_controller.rb:79:in `set_article'
CACHE Article Load (0.0ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 1 LIMIT 1
↳ app/controllers/articles_controller.rb:24:in `show'
Rendering layout layouts/application.html.erb
Rendering articles/show.html.erb within layouts/application
User Load (1.9ms) SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
↳ app/views/articles/show.html.erb:13
Comment Load (1.6ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 1
↳ app/views/articles/show.html.erb:15
Rendered articles/show.html.erb within layouts/application (Duration: 10.9ms | Allocations: 1878)
[Webpacker] Everything's up-to-date. Nothing to do
Rendered layout layouts/application.html.erb (Duration: 48.3ms | Allocations: 5240)
Completed 200 OK in 63ms (Views: 47.7ms | ActiveRecord: 5.3ms | Allocations: 6867)
N+1が発生しなくなってますね。
2. where句を使わず、pluckメソッドを使う
繰り返し構文の中でwhere句を使うと都度SQLが発行されてしまいます。
以下のようなケースを考えます。
def index
@articles = Article.all.includes(:comments)
@current_user = current_user
end
def commented_by?(user)
comments.where(user_id: user.id).present?
end
<% @articles.each do |article| %>
<p><%= article.commented_by?(@current_user) %></p>
<% end %>
このケースだとcommentsテーブルはincludesメソッドでN+1対策がしてあり、かつusersテーブルの連結もしていないことからN+1クエリは発生しないように見えます。
しかし、実際に動かしてみると、where文によりSQLが走ってしまっています。
Started GET "/articles" for 172.24.0.1 at 2022-02-21 12:33:20 +0000
Cannot render console from 172.24.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by ArticlesController#index as HTML
Rendering layout layouts/application.html.erb
Rendering articles/index.html.erb within layouts/application
User Load (1.4ms) SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
↳ app/views/articles/index.html.erb:1
Article Load (1.4ms) SELECT `articles`.* FROM `articles`
↳ app/views/articles/index.html.erb:16
Comment Load (1.9ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` IN (1, 2, 3, 4, 5, 6)
↳ app/views/articles/index.html.erb:16
Comment Load (1.9ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 1 AND `comments`.`user_id` = 1
↳ app/models/article.rb:16:in `commented_by?'
Comment Load (1.3ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 2 AND `comments`.`user_id` = 1
↳ app/models/article.rb:16:in `commented_by?'
Comment Load (1.2ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 3 AND `comments`.`user_id` = 1
↳ app/models/article.rb:16:in `commented_by?'
Comment Load (1.2ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 4 AND `comments`.`user_id` = 1
↳ app/models/article.rb:16:in `commented_by?'
Comment Load (1.5ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 5 AND `comments`.`user_id` = 1
↳ app/models/article.rb:16:in `commented_by?'
Comment Load (1.3ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 6 AND `comments`.`user_id` = 1
↳ app/models/article.rb:16:in `commented_by?'
Rendered articles/index.html.erb within layouts/application (Duration: 86.1ms | Allocations: 21579)
[Webpacker] Everything's up-to-date. Nothing to do
Rendered layout layouts/application.html.erb (Duration: 129.3ms | Allocations: 25088)
Completed 200 OK in 145ms (Views: 98.1ms | ActiveRecord: 34.8ms | Allocations: 27008)
このようなケースはpluckメソッドを使うことでN+1クエリを回避できます。
def commented_by?(user)
comments.pluck(:user_id).include?(user.id)
end
pluckメソッドは指定されたカラムの取得値配列を返すメソッドです。なので、includesでキャッシュしているデータを使えるのでN+1が起きないというわけでした。
まだincludesの詳しい機構が分かっていないので、なぜwhereを使うとN+1が起きるのか完全には分かっていませんが、機会があればソースコード読んでブログに載せたいと思います。
3. どうしてもViewでwhere句を使いたい場合、アソシエーションを定義してincludesする
Viewで記事ごとに表示するコメントを制御したい場合を考えます。コードで示すと以下のようになります。
<% article.comments.where(is_public: true).each do |comment| %>
<span><%= comment.content %></span>
<% end %>
この場合、whereで絞り込んでいる箇所でN+1が発生します。
Comment Load (1.3ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 1 AND `comments`.`is_public` = TRUE
Comment Load (2.2ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 2 AND `comments`.`is_public` = TRUE
Comment Load (1.4ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 3 AND `comments`.`is_public` = TRUE
このような場合では、Articleモデルに条件を加えたアソシエーションを定義して、includesしてやることで、N+1を回避できます。
class Article < ApplicationRecord
has_many :comments
has_many :public_comments, -> { where is_public: true }, class_name: 'Comment'
# 略
end
class ArticlesController < ApplicationController
def index
@articles = Article.includes(:public_comments)
end
# 略
end
<% @articles.each do |article| %>
# 略
<p>
<% article.public_comments.each do |comment| %>
<span><%= comment.content %></span>
<% end %>
</p>
<% end %>
このようにすると、下のようにN+1が解消できているのが分かるかと思います。
Comment Load (2.0ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`is_public` = TRUE AND `comments`.`article_id` IN (1, 2, 3)
まとめ
ポイントとして下のようにまとめました。
特にテーブル連結をすることは負荷をかけているということを意識しながらコーディングをしていきたいと思いました。
- 不要なテーブル連結はしない
- viewの中でwhere句を使わないこと
- どうしても使う場合はアソシエーションを定義して、条件ごとincludesすること
もっといい方法があったり、間違っている点などあれば指摘大歓迎です。
ここまで読んでいただきありがとうございました。