今回は、N+1問題をアラート表示してくれるgem「bullet」について解説したいと思います。N+1問題はアプリケーションのパフォーマンス低下に繋がってしまうため、動きさえすれば良いというテストアプリケーション以外はぜひ導入を検討していただけたらと思います。
N+1問題とは
まずはN+1問題の概要について簡単におさらいしておきます。
簡単に言うと、N+1問題とはデータベースへのアクセス回数が余計に多くなってしまう現象です。これは、モデル間のアソシエーションを利用する場合に発生します。
例を見ていきましょう。
usersテーブルとpostsテーブルがあり、それらは1対多の関係で結びついています。

そしてpostsテーブルからレコードを全件取得し、取得した投稿に紐づくユーザーの名前を表示したいとします。N+1問題を考慮しなかった場合、記述は以下のようになります。
Post.all.each do |post|
post.user.name
end
上記の記述を実行すると4回ものクエリが発行してしまいます。これではユーザー数が増えれば増えるほどクエリの発行回数が多くなってしまい、パフォーマンス的に良くありません。
SELECT `posts`.* FROM `posts`
SELECT `users`.* FROM `users` WHERE `users`.id = 1 LIMIT 1
SELECT `users`.* FROM `users` WHERE `users`.id = 2 LIMIT 1
SELECT `users`.* FROM `users` WHERE `users`.id = 3 LIMIT 1
代表的なN+1問題を解決するメソッドはincludesメソッドになります。
includesメソッドはPostモデルと関連するUserモデルを一括で読み込んだ上で、each文を実行してくれます。

以下のようにincludesメソッドを使用した記述に修正することで、クエリの発行回数を2回に抑えることができ、パフォーマンス改善が見込めます。
Post.all.includes(:user).each do |post|
post.user.name
end
SELECT `posts`.* FROM `posts`
SELECT `users`.* FROM `users` WHERE `users`.`id` IN(1, 2, 3)

gem「bullet」とは
では本題のgem「bullet」について見ていきましょう。
bulletとは、N+1問題が起きている箇所を特定・アラート表示してくれるgemです。
テーブル数が増えてくると、N+1問題が起きているのにも関わらず見逃してしまうケースが多発します。それを避けるためにも、導入しておくべきgemの1つだと考えています。
The Bullet gem is designed to help you increase your application’s performance by reducing the number of queries it makes. It will watch your queries while you develop your application and notify you when you should add eager loading (N+1 queries), when you’re using eager loading that isn’t necessary and when you should use counter cache.
Bullet gemは、実行するクエリの数を減らすことで、アプリケーションのパフォーマンスを向上させるように設計されています。アプリケーションの開発中にクエリを監視し、積極的な読み込み(N + 1クエリ)を追加する必要がある場合、不要な積極的な読み込みを使用する場合、およびカウンターキャッシュを使用する必要がある場合に通知します。
bulletを実際に使ってみよう
では簡単なアプリケーションを作成しながらbulletの使い方を学んでいきましょう。
アプリケーションの作成にはscaffoldを使用するので、scaffoldが曖昧な方はこちらの記事に目を通しておいてください。

アプリケーションの作成
まずは以下のコマンドでアプリケーションを作成します。同時にデータベースも作成しておきましょう。
% rails new test_bullet -d mysql
% cd test_bullet
% rails db:create
今回開発するアプリケーションの環境は以下のようになります。
- アプリケーション名:test_bullet
- Rails:6.0.3.2
- Ruby:2.6.5
- DB:MySQL
次にscaffoldの機能を使い、PostモデルとCommentモデルに関する土台を作成します。土台作成後はマイグレートも忘れずに行ましょう。
% rails g scaffold post name:string
% rails g scaffold comment name:string post_id:integer
% bundle exec rails db:migrate
# マイグレート後のschema.rb
ActiveRecord::Schema.define(version: 2021_12_27_083332) do
create_table "comments", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", force: :cascade do |t|
t.string "name"
t.integer "post_id"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
create_table "posts", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", force: :cascade do |t|
t.string "name"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
end
先ほど作成したPostモデルとCommentモデルのアソシエーションも組んでおきましょう。PostモデルとCommentモデルは1対多の関係になるので、以下のように記述することができます。
# post.rb
class Post < ApplicationRecord
has_many :comments
end
# comment.rb
class Comment < ApplicationRecord
belongs_to :post
end
続いてirbを立ち上げ、データベースに値を入れていきましょう。(投入するデータ数が少ないため、今回はコンソール上で直接値を入れています)
% rails c
irb > post1 = Post.create(:name => 'first')
irb > post2 = Post.create(:name => 'second')
irb > post1.comments.create(:name => 'first')
irb > post1.comments.create(:name => 'second')
irb > post2.comments.create(:name => 'third')
irb > post2.comments.create(:name => 'fourth')
irb > exit


次に「app/views/posts/index.html.erb」を以下のように編集し、あえてN+1問題が起きるよう記述します。<%= post.comments.map(&:name) %>
がN+1問題が発生している箇所になります。
<p id="notice"><%= notice %></p>
<h1>Posts</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Comments</th>
<th colspan="3"></th>
</tr>
</thead>
<tbody>
<% @posts.each do |post| %>
<tr>
<td><%= post.name %></td>
<td><%= post.comments.map(&:name) %></td>
<td><%= link_to 'Show', post %></td>
<td><%= link_to 'Edit', edit_post_path(post) %></td>
<td><%= link_to 'Destroy', post, :confirm => 'Are you sure?', :method => :delete %></td>
</tr>
<% end %>
</tbody>
</table>
<br>
<%= link_to 'New Post', new_post_path %>
ここまででどのような挙動になるか一旦確かめてみます。「rails s」でサーバーを立ち上げ、「http://localhost:3000/posts」にアクセスしてみましょう。先ほどコンソール上でデータベースに投入した値(nameとcomment)が表示されていれば、事前準備完了です。

bulletの導入
ではここから実際にbulletを導入し、N+1問題を検知していきます。
Gemfileのdevelopment環境とtest環境に「gem ‘bullet’」を記載し、「bundle install」を実行しましょう。
group :development, :test do
gem 'bullet'
end
% bundle install
続いて以下のコマンドでbulletの初期設定を行います。対話スクリプトが起動しますが、今回はtest環境ではbulletを使用しないため「n」を入力して進めます。
% bundle exec rails g bullet:install
Running via Spring preloader in process 42961
Enabled bullet in config/environments/development.rb
Would you like to enable bullet in test environment? (y/n)
=> nを入力
config/environments/development.rb
を開き、以下のコードが追加されている事を確認しましょう。ここのtrue/falseを切り替えることでN+1問題のアラート設定を行うことができます。
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.console = true
# Bullet.growl = true
Bullet.rails_logger = true
Bullet.add_footer = true
end
# 省略
end
記述 | 記述の意味 |
Bullet.enable | bulletを有効化するかどうか。falseの場合はbulletが無効になります。 |
Bullet.alert | N+1問題の検知時、ブラウザにJavaScriptのポップアップアラートを表示するかどうか。 |
Bullet.bullet_logger | N+1問題の検知時、bullet専用のログであるlog/bullet.log を出力するかどうか。 |
Bullet.console | N+1問題の検知時、ブラウザのconsole.logにアラートを表示するかどうか。 |
Bullet.growl | N+1問題の検知時、かつGrowlがインストールされている場合にGrowlメッセージを表示するかどうか。デフォルトはfalse。 |
Bullet.rails_logger | N+1問題の検知時、ターミナルのログにアラート表示するかどうか。 |
Bullet.add_footer | N+1問題の検知時、ブラウザ左下にアラートを表示するかどうか。 |
これで自動的にbulletがN+1問題が起きている箇所を検知し、アラートを表示してくれるようになりました。再度「rails s」でサーバーを立ち上げ、「http://localhost:3000/posts」にアクセスしてみましょう。


# ターミナル上のログ
user: yoshiharu.chiba
GET /posts
USE eager loading detected
Post => [:comments]
Add to your query: .includes([:comments])
Call stack
/Users/yoshiharu.chiba/Desktop/test_bullet/app/views/posts/index.html.erb:18:in `map'
/Users/yoshiharu.chiba/Desktop/test_bullet/app/views/posts/index.html.erb:18:in `block in _app_views_posts_index_html_erb__1211332998519677680_70255496990820'
/Users/yoshiharu.chiba/Desktop/test_bullet/app/views/posts/index.html.erb:15:in `_app_views_posts_index_html_erb__1211332998519677680_70255496990820'
# log/bullet.log
2021-12-27 22:39:27[WARN] user: yoshiharu.chiba
GET /posts
USE eager loading detected
Post => [:comments]
Add to your query: .includes([:comments])
Call stack
/Users/yoshiharu.chiba/Desktop/test_bullet/app/views/posts/index.html.erb:17:in `map'
/Users/yoshiharu.chiba/Desktop/test_bullet/app/views/posts/index.html.erb:17:in `block in _app_views_posts_index_html_erb__4455284572466763881_70286658634000'
/Users/yoshiharu.chiba/Desktop/test_bullet/app/views/posts/index.html.erb:14:in `_app_views_posts_index_html_erb__4455284572466763881_70286658634000'
ではposts_controller.rb
をN+1問題が起きないように編集します。posts_controller.rb
のindexアクションを以下のように書き換えてください。
def index
@posts = Post.includes(:comments)
respond_to do |format|
format.html # index.html.erb
format.xml { render :xml => @posts }
end
end
もう一度ブラウザをリロードしてみましょう。
アラート表示が消えましたね。
このようにgem「bullet」を用いることで、パフォーマンスに大きく影響するN+1問題をアラート表示してくれます。
bulletを導入したけど「アラートがうまく表示されない」「修正したのにアラートが消えない」といった場合は、ブラウザのキャッシュが悪さをしている場合があります。その場合は、ブラウザのキャッシュ機能をOFFにしておきましょう。
bulletを使いこなす
bulletには様々なオプションがあり、それらを使うには明示的に記述する必要があります。このオプションはconfig/environments/development.rb
に追記することができます。
config.after_initialize do
Bullet.enable = true
Bullet.sentry = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.console = true
Bullet.growl = true
Bullet.xmpp = { :account => 'bullets_account@jabber.org',
:password => 'bullets_password_for_jabber',
:receiver => 'your_account@jabber.org',
:show_online_status => true }
Bullet.rails_logger = true
Bullet.honeybadger = true
Bullet.bugsnag = true
Bullet.airbrake = true
Bullet.rollbar = true
Bullet.add_footer = true
Bullet.skip_html_injection = false
Bullet.stacktrace_includes = [ 'your_gem', 'your_middleware' ]
Bullet.stacktrace_excludes = [ 'their_gem', 'their_middleware', ['my_file.rb', 'my_method'], ['my_file.rb', 16..20] ]
Bullet.slack = { webhook_url: 'http://some.slack.url', channel: '#default', username: 'notifier' }
end
また修正する必要がない部分でアラート表示が出てしまう場合にも、明示的に記述することでアラートを無視することができます。こちらも同様にconfig/environments/development.rb
に記載します。
# 記述例
Bullet.add_whitelist :type => :n_plus_one_query, :class_name => "Post", :association => :comments
Bullet.add_whitelist :type => :unused_eager_loading, :class_name => "Post", :association => :comments
Bullet.add_whitelist :type => :counter_cache, :class_name => "Country", :association => :cities
使えるオプションや具体的な回避方法についてはGithubを覗いてみてください。
bulletを使用する上での注意点
bulletは非常に便利なgemですが、何点か注意して使用しなければなりません。
- 全てのN+1問題を検出できるわけではない
- gemの導入は慎重に行う
- N+1問題の発見をbulletに頼らない
全てのN+1問題を検出できるわけではない
bulletは大抵のN+1問題を検知しますが、複雑な原因のN+1問題は検出できない場合があります。そのためbulletに頼りすぎず、普段からN+1問題を意識してコードを書く必要があります。
gemの導入は慎重に行う
bulletに限らず何かしらのgemを導入することで、アップデート対応や他の開発メンバーのキャッチアップが必要になってきます。そのためむやみやたらに入れるのではなく、きちんと検討した上でgemを導入するようにしましょう。
N+1問題の発見をbulletに頼らない
N+1問題をアラートで分かりやすく表示してくれるbulletは便利ですが、そういったサポートツールを使わずに自身で発見することが開発の上では望ましいです。そのため、N+1問題の全てをbulletに頼ることは避けましょう。
まとめ
- N+1問題とはデータベースへのアクセス回数が余計に多くなってしまう現象のこと
- bulletとはN+1問題が起きている箇所を特定・お知らせしてくれるgem
- bulletは使いたいオプションを明示的に示すことでカスタマイズが可能
- bulletの使用には何点か注意するべきポイントがある
参考文献
https://github.com/flyerhzm/bullet
今回はN+1問題をアラート表示してくれるgem「bullet」について解説しました。個人開発や練習用のアプリケーションでは導入の必要はありませんが、大規模開発になるとパフォーマンスは非常に重要になってくるので、ぜひ「bullet」というgemがあるということを頭に入れておきましょう。