Ruby on Rails

【Rails】N+1クエリを防ぐTips

N+1クエリに散々悩まされてきたので、これを解決するためのTipsを備忘録としてまとめておきたいと思います。
今回紹介する対策は2つです。

1. 安易にアソシエーションを使わずにカラムの値を使う

次のようなケースを考えます。

記事とコメントが1対多、ユーザとコメントが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のオブジェクトも生成してしまっているので、パフォーマンスが悪いです。

【Rails】パフォーマンスを意識してアプリを高速化する コントローラーやモデルでの処理でパフォーマンスを意識した書き方ができてなかったので、備忘録にまとめていきたいと思います。 ① 繰...

実際に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すること

もっといい方法があったり、間違っている点などあれば指摘大歓迎です。
ここまで読んでいただきありがとうございました。

ABOUT ME
sakai
三重出身の28歳。前職はメーカーで働いていて、プログラミングスクールに通って未経験からWeb業界に転職しました。Railsをメインで使っていて、AWSも少しできます。音楽を聞くこととYoutubeを見るのが好きです。最近はへきトラ劇場にハマってます