はじめに
プログラミングスクール「RUNTEQ」で、Rubyを中心に学習中のあっしーです。
学習中のアウトプットのため、N+1問題を検知するgem bulletについてまとめてみました。
※初学者のため、内容に誤りがある場合もあります。ご容赦ください。コメント等で教えていただけますと幸いです。
環境
- Ruby 3.3.6
- Rails 7.1.5
N+1問題とは
ご存知の方は、以下の説明は読み飛ばしてください。
N+1問題とは?(ご存知ない方はこちらをクリック)
ビュー等でのループ処理の中で、毎回余計なSQLを発行してしまい、パフォーマンスが低下する問題です。
例えば、投稿アプリで投稿一覧ページがあった場合、ビューでユーザーの名前を表示したいとき、以下のようなコードがあったとします。
class PostsController < ApplicationController
def index
@posts = Post.all
end
end
<% @posts.each do |post| %>
<p><%= post.user.name %></p>
<% end %>
モデルのアソシエーションは下記の通りです。
class Post < ApplicationRecord
belongs_to :user
end
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
このような、N+1問題が発生している時にgem bulletを導入すると簡単に問題箇所を発見することができます。
bulletについて
ページにアクセスしたタイミングで、N+1問題が発生していれば検知して教えてくれるgem になります。
最初は自分も「どうやって教えてくれるんだろう?」と不思議に思いましたが、実際にやってみたらわかりました。導入してみましょう!
導入手順
1 Gemfileに以下の記述を書いて、gem「bullet」を導入
group :development do
gem 'bullet'
end
2 bundle install を実施
3 bundle exec rails g bullet:installを実施。
すると、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問題を発生させて、どのように教えてくれるのか確認してみます。
投稿アプリで投稿一覧ページがあった場合、ビューでユーザーの名前を表示してみます。
コードは以下の通りです。
class PostsController < ApplicationController
def index
@posts = Post.all
end
end
<% @posts.each do |post| %>
<p><%= post.user.name %></p>
<% end %>
モデルのアソシエーションは下記の通りです。
class Post < ApplicationRecord
belongs_to :user
end
class User < ApplicationRecord
has_many :posts
end
実際に一覧画面を表示してみると、以下のとおりブラウザに通知(アラート)が表示されました。
また、Bulletはブラウザ以外にも以下のような方法で通知を出してくれます。
- コンソール(ターミナル)
- Railsのログファイル(log/development.log)
- Bullet専用のログファイル(log/bullet.log)
- ページのフッター
…と、いろんな場所に出してくれるので、開発中に気づきやすい仕組みとなっています。
どこに出るかはconfig/environments/development.rb
に設定された内容によって変わるので、 実際に手元で試してみて、「どこに出るか」「どんなふうに見えるか」を確認してみてください 💡
解決策
解決方法として、今回の例ではコントローラーでPostの情報を取得する際に、includesメソッドを使用して、事前にUserの情報も一度に取得する方法が挙げられます。
includes(:user) を使うことで、Postを取得する際に関連するUser情報も一括で読み込むため、ビューのループ内で毎回SQLを発行する必要がなくなります。
class PostsController < ApplicationController
def index
@posts = Post.includes(:user)
end
end
※allは省略可
再度、ページを表示してみると、ブラウザの通知は表示されませんでした。
また、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がどんどん発行され、アプリの表示速度やユーザー体験にも悪影響を与えるようになります。
こちらを参考にパフォーマンス向上をさせてみてください!