RailsでBulletを入れているのにN+1が起きてしまう
年末に差し掛かりバタバタしてきましたね。
さて、今回は弊社のアドベントカレンダーの4日目としてRailsでBulletを使っているのにN+1が起きてしまうという悲しい事象をしていきます。
Railsユーザーには当たり前かもですが、個人的にやられたので温かい目で見てください...
Bulletとは
BulletとはN+1が起きているときに教えてくれるgemです。
READMEにも書いてある通り、eager loadingの追加や削除を教えてくれる便利なライブラリですね。
こんな感じで教えてくれます。
Bullet::Notification::UnoptimizedQueryError (user: root
GET /hoges
USE eager loading detected
Hoge => [:fugas]
Add to your query: .includes([:fugas])
Call stack
/app/app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
/app/app/controllers/hoges_controller.rb:13:in 'block in HogesController#index'
/app/app/controllers/hoges_controller.rb:9:in 'Enumerable#map'
/app/app/controllers/hoges_controller.rb:9:in 'HogesController#index'
):
悲しい事件
悲しい事件を再現するためにまずは今回扱う簡単なテーブル構成を紹介します。
テーブル構成
HogeとFugaというテーブルを用意しました。
class Hoge < ApplicationRecord
has_many :fugas, dependent: :destroy
end
class Fuga < ApplicationRecord
belongs_to :hoge
enum :status, { published: "published", archived: "archived" }
end
N+1を回避する普通のパターン
普通に書いていればこのようにincludesやpreloadなどを使ってN+1を回避するかなと思います。
(雑なコードお許しを...)
class HogesController < ApplicationController
def index
hoges = Hoge.includes(:fugas).all
render json: {
hoges: hoges.map do |hoge|
{
id: hoge.id,
name: hoge.name,
fugas: hoge.fugas.map do |fuga|
{
id: fuga.id,
uuid: fuga.uuid,
status: fuga.status,
}
end,
}
end
}
end
end
悲しい事件が起きたケース
ここでfuga.statusがpublishedのみに絞りたいとし、scopeを経由したとします。
class HogesController < ApplicationController
def index
hoges = Hoge.includes(:fugas).all
render json: {
hoges: hoges.map do |hoge|
{
id: hoge.id,
name: hoge.name,
- fugas: hoge.fugas.map do |fuga|
+ fugas: hoge.fugas.published.map do |fuga|
{
id: fuga.id,
uuid: fuga.uuid,
status: fuga.status,
}
end,
}
end
}
end
end
するとBulletにincludesを外せと怒られます。
Bullet::Notification::UnoptimizedQueryError (user: root
GET /hoges
AVOID eager loading detected
Hoge => [:fugas]
Remove from your query: .includes([:fugas])
Call stack
/app/app/controllers/hoges_controller.rb:9:in 'Enumerable#map'
/app/app/controllers/hoges_controller.rb:9:in 'HogesController#index'
):
ログを見てみるとscopeを使うとデータアクセス時にクエリが発行されるため、eager loadingが効いておらず、それならばincludesを外せ、ということっぽいです。
データアクセス時とはfugas: hoge.fugas.published.map do |fuga|のmapをした際にクエリが実行されると考えていいでしょう。
ログ
Started GET "/hoges" for 192.168.97.1 at 2025-12-01 04:48:28 +0000
Processing by HogesController#index as HTML
Hoge Load (0.4ms) SELECT `hoges`.* FROM `hoges` /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:9:in 'Enumerable#map'
Fuga Load (0.1ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` IN (13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23) /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:9:in 'Enumerable#map'
Fuga Load (0.2ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` = 13 AND `fugas`.`status` = 'published' /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
Fuga Load (0.1ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` = 14 AND `fugas`.`status` = 'published' /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
Fuga Load (0.1ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` = 15 AND `fugas`.`status` = 'published' /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
Fuga Load (0.1ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` = 16 AND `fugas`.`status` = 'published' /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
Fuga Load (0.1ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` = 17 AND `fugas`.`status` = 'published' /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
Fuga Load (0.1ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` = 18 AND `fugas`.`status` = 'published' /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
Fuga Load (0.1ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` = 19 AND `fugas`.`status` = 'published' /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
Fuga Load (0.1ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` = 20 AND `fugas`.`status` = 'published' /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
Fuga Load (0.1ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` = 21 AND `fugas`.`status` = 'published' /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
Fuga Load (0.1ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` = 22 AND `fugas`.`status` = 'published' /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
Fuga Load (0.1ms) SELECT `fugas`.* FROM `fugas` WHERE `fugas`.`hoge_id` = 23 AND `fugas`.`status` = 'published' /*action='index',application='Backend',controller='hoges'*/
↳ app/controllers/hoges_controller.rb:13:in 'Enumerable#map'
Completed 200 OK in 27ms (Views: 0.1ms | ActiveRecord: 4.2ms (13 queries, 0 cached) | GC: 5.9ms)
解決策(その1)
一番初めに思いつく解決策はループの中でインスタンスメソッド等を使ってあげるケースですね。
class HogesController < ApplicationController
def index
hoges = Hoge.includes(:fugas).all
render json: {
hoges: hoges.map do |hoge|
{
id: hoge.id,
name: hoge.name,
fugas: hoge.fugas.map do |fuga|
+ next unless fuga.published?
{
id: fuga.id,
uuid: fuga.uuid,
status: fuga.status,
}
end,
}
end
}
end
end
解決策(その2)
あまり好みではないですが、has_manyにscopeをつける方法もあります。
class Hoge < ApplicationRecord
has_many :fugas, dependent: :destroy
+ has_many :published_fugas, -> { published }, class_name: "Fuga"
end
class HogesController < ApplicationController
def index
- hoges = Hoge.includes(:fugas).all
+ hoges = Hoge.includes(:published_fugas).all
render json: {
hoges: hoges.map do |hoge|
{
id: hoge.id,
name: hoge.name,
- fugas: hoge.fugas.map do |fuga|
+ fugas: hoge.published_fugas.map do |fuga|
{
id: fuga.id,
uuid: fuga.uuid,
status: fuga.status,
}
end,
}
end
}
end
end
データ量が多い場合やコアテーブルなどであれば部分的にこの対応をしてもいいかなとも思います。
まとめ
BulletというよりもRailsの挙動にやられて、N+1が発生してそうでした。
scopeを使うとき(exists?なども)はN+1が発生することを考慮して実装したほうが良さそうですね。
(本当はBulletやRailsのコードを追いたかったんですが、暇があれば更新します。)
Discussion