🤬

RailsでBulletを入れているのにN+1が起きてしまう

に公開

年末に差し掛かりバタバタしてきましたね。
さて、今回は弊社のアドベントカレンダーの4日目としてRailsでBulletを使っているのにN+1が起きてしまうという悲しい事象をしていきます。
Railsユーザーには当たり前かもですが、個人的にやられたので温かい目で見てください...

Bulletとは

https://github.com/flyerhzm/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'

):

悲しい事件

悲しい事件を再現するためにまずは今回扱う簡単なテーブル構成を紹介します。

テーブル構成

HogeFugaというテーブルを用意しました。

class Hoge < ApplicationRecord
  has_many :fugas, dependent: :destroy
end

class Fuga < ApplicationRecord
  belongs_to :hoge

  enum :status, { published: "published", archived: "archived" }
end

N+1を回避する普通のパターン

普通に書いていればこのようにincludespreloadなどを使って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のコードを追いたかったんですが、暇があれば更新します。)

SMARTCAMP Engineer Blog

Discussion