Tech Racho エンジニアの「?」を「!」に。
  • 開発

Rails: Bulletで検出されないN+1クエリを解消する

はじめに

普段、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_positivesum_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クエリを発行していないかを意識していきたいところです。

関連記事

Rails: JOINすべきかどうか、それが問題だ — #includesの振舞いを理解する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。