2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

N+1問題を検知するため、gem bulletを使ってみた

Posted at

はじめに

プログラミングスクール「RUNTEQ」で、Rubyを中心に学習中のあっしーです。
学習中のアウトプットのため、N+1問題を検知するgem bulletについてまとめてみました。
※初学者のため、内容に誤りがある場合もあります。ご容赦ください。コメント等で教えていただけますと幸いです。

環境

  • Ruby 3.3.6
  • Rails 7.1.5

N+1問題とは

ご存知の方は、以下の説明は読み飛ばしてください。

N+1問題とは?(ご存知ない方はこちらをクリック)

ビュー等でのループ処理の中で、毎回余計なSQLを発行してしまい、パフォーマンスが低下する問題です。

例えば、投稿アプリで投稿一覧ページがあった場合、ビューでユーザーの名前を表示したいとき、以下のようなコードがあったとします。

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end
app/views/posts/index.html
<% @posts.each do |post| %>
 <p><%= post.user.name %></p>
<% end %>

モデルのアソシエーションは下記の通りです。

app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
end
app/models/user.rb
class User < ApplicationRecord
  has_many :posts
end

この場合、発行されているSQLは以下の2箇所になります。

1 コントローラーの@posts = Post.all(投稿を全て取得)
2 ビューの<%= post.user.name %>(投稿に関連付いているユーザーを取得)

この2に注目すると、ループ処理の中で、投稿の数の分だけSQLを発行しています。

このように、ループ処理の中で

・Post.all → 全ての投稿を1回で取得
・post.user → 投稿の数(N件)をN回取得

→合計で「N+1回」SQLが発行されてしまい、パフォーマンスの低下(アプリの読み込みや動作が遅くなる)を引き起こしてしまう問題を 「N+1問題」 と言います。

【参考】ログ 以下(ログの抜粋)のように、Post一覧を表示する際に、Postごとに関連Userを取得してしまっており、 「User Load」が複数回発行されています。

Processing by PostsController#index as HTML
Rendering layout layouts/application.html.erb
Rendering posts/index.html.erb within layouts/application
Post Load (5.2ms)  SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:1
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
解決方法の例として、コントローラーでPostの情報を取得する際に、includesメソッドを使用して、事前にUserの情報も一度に取得する方法が挙げられます。 ※後に記述します

このような、N+1問題が発生している時にgem bulletを導入すると簡単に問題箇所を発見することができます。

bulletについて

ページにアクセスしたタイミングで、N+1問題が発生していれば検知して教えてくれるgem になります。

最初は自分も「どうやって教えてくれるんだろう?」と不思議に思いましたが、実際にやってみたらわかりました。導入してみましょう!

導入手順

1 Gemfileに以下の記述を書いて、gem「bullet」を導入
Gemfile
group :development do
  gem 'bullet'
end
2 bundle install を実施
3 bundle exec rails g bullet:installを実施。

すると、config/environments/development.rbファイルに以下のコードがを生成されます。

config/environments/development.rb
config.after_initialize do
    Bullet.enable        = true # Bulletを有効にする
    Bullet.alert         = true # ブラウザに通知(アラート)を出す
    Bullet.bullet_logger = true # log/bullet.log にログを出力する
    Bullet.console       = true # ブラウザの開発者ツールのコンソールにログを出力する
    Bullet.rails_logger  = true # log/development.log にログを出力する
    Bullet.add_footer    = true # フッター(画面左下)に通知を出す
  end

これで導入完了です。

実際にN+1問題を発生させてみる

実際にわざとN+1問題を発生させて、どのように教えてくれるのか確認してみます。

投稿アプリで投稿一覧ページがあった場合、ビューでユーザーの名前を表示してみます。
コードは以下の通りです。

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end
app/views/posts/index.html
<% @posts.each do |post| %>
 <p><%= post.user.name %></p>
<% end %>

モデルのアソシエーションは下記の通りです。

app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
end
app/models/user.rb
class User < ApplicationRecord
  has_many :posts
end

実際に一覧画面を表示してみると、以下のとおりブラウザに通知(アラート)が表示されました。

bulletアラート.png

また、Bulletはブラウザ以外にも以下のような方法で通知を出してくれます。

  • コンソール(ターミナル)
  • Railsのログファイル(log/development.log)
  • Bullet専用のログファイル(log/bullet.log)
  • ページのフッター

…と、いろんな場所に出してくれるので、開発中に気づきやすい仕組みとなっています。

どこに出るかはconfig/environments/development.rbに設定された内容によって変わるので、 実際に手元で試してみて、「どこに出るか」「どんなふうに見えるか」を確認してみてください 💡

解決策

解決方法として、今回の例ではコントローラーでPostの情報を取得する際に、includesメソッドを使用して、事前にUserの情報も一度に取得する方法が挙げられます。
includes(:user) を使うことで、Postを取得する際に関連するUser情報も一括で読み込むため、ビューのループ内で毎回SQLを発行する必要がなくなります。

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:user)
  end
end

※allは省略可

再度、ページを表示してみると、ブラウザの通知は表示されませんでした。
bulletアラートなし.png

また、includesメソッドを使用する前と後のログを比較してみると

before

Processing by PostsController#index as HTML
Rendering layout layouts/application.html.erb
Rendering posts/index.html.erb within layouts/application
Post Load (5.2ms)  SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:1
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
after

Processing by PostsController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering posts/index.html.erb within layouts/application
  Post Load (3.8ms)  SELECT "posts".* FROM "posts"
  ↳ app/views/posts/index.html.erb:1
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ?  [["id", 1]]
  ↳ app/views/posts/index.html.erb:1

発行されているクエリの数が減っていることがわかります。

まとめ

今回は、gem「bullet」を使って、N+1問題を検知する方法を紹介しました。

  • N+1問題とは、ループ処理の中で、毎回余計なSQLを発行してしまい、パフォーマンスが低下する問題
  • bulletを導入すると、アラートやログなどでN+1問題が発生してくれている箇所を教えてくれる
  • 代表的な例として、includesメソッドを使うことで、関連データをまとめて取得でき、N+1問題を回避できる

今回の例では投稿数が5件だったため、パフォーマンスの低下は目立ちませんでしたが、投稿数が増えるにつれて余計なSQLがどんどん発行され、アプリの表示速度やユーザー体験にも悪影響を与えるようになります。

こちらを参考にパフォーマンス向上をさせてみてください!

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?