👁‍🗨

Bulletを導入してN+1を検知する

2024/05/30に公開

はじめに

RailsアプリケーションのN+1クエリ問題を検出し、通知してくれるBulletの導入と使い方までの紹介です。

動作環境

RSpecの導入と使い方で構築した環境を使います。
Ruby 3.2.2
Rails 7.0.8
MySQL 8.0

Bulletとは

Bulletは、Ruby on Railsアプリケーションのパフォーマンスを向上させるために、クエリ数を減らすことを目的とした便利なGemです。開発中にクエリを監視し、N+1クエリ問題が発生している場合には、eager loadingを追加するよう通知します。また、不要なeager loadingの使用や、カウンターキャッシュの利用が推奨される場合にも通知します。

N+1クエリ問題とは、データベースからデータを取得する際に、1つのクエリで多くのデータを取得し、その後、個別のデータごとにさらにクエリを実行するという非効率なパターンのことです。これにより、データベースへのアクセスが増え、アプリケーションのパフォーマンスが低下することがあります。

インストールと設定

Gemfileに追加

テストでも検出させるようにするので、:testにも追加します

api/Gemfile
group :development, :test do
  gem "rspec-rails"
  gem "factory_bot_rails"
+ gem "bullet"
end

Bundleインストール

ターミナル
docker compose run --rm api bundle install
docker compose build api # イメージの再構築

設定ファイルの準備

ターミナル
docker compose run --rm api bundle exec rails g bullet:install
# 実行結果
  Enabled bullet in config/environments/development.rb
  Would you like to enable bullet in test environment? (y/n) y
  Enabled bullet in config/environments/test.rb

config/environments/test.rbに以下が追加されます

api/config/environments/development.rb
Rails.application.configure do
+ config.after_initialize do
+   Bullet.enable        = true
+   Bullet.alert         = true
+   Bullet.bullet_logger = true
+   Bullet.console       = true
+   Bullet.rails_logger  = true
+   Bullet.add_footer    = true
+ end
  ...
end
api/config/environments/test.rb
Rails.application.configure do
+ config.after_initialize do
+   Bullet.enable        = true
+   Bullet.bullet_logger = true
+   Bullet.raise         = true # raise an error if n+1 query occurs
+ end
  ...
end

設定の主な内容になります。
Bullet.enable: Bullet gemを有効にします。
Bullet.alert: ブラウザにJavaScriptアラートを表示する
Bullet.bullet_logger: Bullet ログファイル (Rails.root/log/bullet.log) にログを記録します。
Bullet.console: ブラウザの console.log に警告を記録します。
Bullet.rails_logger: Railsログに直接警告を追加する
Bullet.add_footer: ページの左下隅に詳細を追加します。
Bullet.raise: エラーを発生させます。

その他設定は Bullet Configuration で確認してください

api/spec/rails_helper.rb
RSpec.configure do |config|
  if Bullet.enable?
    config.before(:each) do
      Bullet.start_request
    end

    config.after(:each) do
      Bullet.perform_out_of_channel_notifications if Bullet.notification?
      Bullet.end_request
    end
  end
  ...
end

実施

スキーマファイルの準備

ターミナル
touch api/db/schemas/authors.schema.rb
touch api/db/schemas/books.schema.rb
# api/db/schemas/authors.schema.rb
create_table :authors, force: :cascade, charset: 'utf8mb4', collation: 'utf8mb4_bin', options: 'ENGINE=InnoDB ROW_FORMAT=DYNAMIC' do |t|
  t.string "name", null: false
end

# api/db/schemas/books.schema.rb
create_table :books, force: :cascade, charset: 'utf8mb4', collation: 'utf8mb4_bin', options: 'ENGINE=InnoDB ROW_FORMAT=DYNAMIC' do |t|
  t.string "title", null: false
  t.references :author, null: false
end
api/db/schemas/Schemafile
  require 'users.schema.rb'
+ require 'authors.schema.rb'
+ require 'books.schema.rb'

マイグレーション

ターミナル
docker compose run --rm api bundle exec rails db_migration:apply
# 実行結果
=== run db migration... ===
[Running] bundle exec ridgepole --config config/database.yml --env development --file db/schemas/Schemafile --apply
Apply `db/schemas/Schemafile`
-- create_table("authors", {:charset=>"utf8mb4", :collation=>"utf8mb4_bin", :options=>"ENGINE=InnoDB ROW_FORMAT=DYNAMIC"})
   -> 0.0820s
-- create_table("books", {:charset=>"utf8mb4", :collation=>"utf8mb4_bin", :options=>"ENGINE=InnoDB ROW_FORMAT=DYNAMIC"})
   -> 0.0415s
-- add_index("books", ["author_id"])
   -> 0.0388s

Model

# api/app/models/author.rb
class Author < ApplicationRecord
  has_many :books
end

# api/app/models/book.rb
class Book < ApplicationRecord
  belongs_to :author
end

Routes

api/config/routes.rb
Rails.application.routes.draw do
  namespace 'api' do
    namespace 'v1' do
      resources :authors
    end
  end
end

Controller

api/app/controllers/api/v1/authors_controller.rb
class Api::V1::AuthorsController < ApplicationController
  def index
    authors = Author.all
    render json: authors.map { |author| 
          {
            id: author.id,
            name: author.name,
            books: author.books.map { |book| 
              {
                id: book.id,
                title: book.title
              }
            }
          }
        } 
  end
end

デモデータの用意

api/db/seeds.rb
# Clear existing data
Author.destroy_all
Book.destroy_all

# Create authors with books
author1 = Author.create(name: "Author 1")
author2 = Author.create(name: "Author 2")

author1.books.create([
  { title: "Book 1 by Author 1" },
  { title: "Book 2 by Author 1" }
])

author2.books.create([
  { title: "Book 1 by Author 2" },
  { title: "Book 2 by Author 2" },
  { title: "Book 3 by Author 2" }
])

シードデータの投入

ターミナル
docker compose run --rm api bundle exec rails db:seed

確認

ターミナル
# Rails起動
docker compose up -d api
curl -X GET http://localhost:3000/api/v1/authors
{"data":
  [
    {"id":1,
      "name":"Author 1",
      "books":[
        {"id":1,"title":"Book 1 by Author 1"},
        {"id":2,"title":"Book 2 by Author 1"}
      ]
    },
    {"id":2,
      "name":"Author 2",
      "books":[
        {"id":3,"title":"Book 1 by Author 2"},
        {"id":4,"title":"Book 2 by Author 2"},
        {"id":5,"title":"Book 3 by Author 2"}
      ]
    }
  ]
}

ログの確認

api/log/bullet.log
2024-01-01 12:00:00[WARN] user: root
GET /api/v1/authors
USE eager loading detected
  Author => [:books]
  Add to your query: .includes([:books])
Call stack
  /api/app/controllers/api/v1/authors_controller.rb:8:in `map'
  /api/app/controllers/api/v1/authors_controller.rb:8:in `block in index'
  /api/app/controllers/api/v1/authors_controller.rb:4:in `map'
  /api/app/controllers/api/v1/authors_controller.rb:4:in `index'

RSpecで確認する

FactoryBotのファクトリを作成

# api/spec/factories/authors.rb
FactoryBot.define do
  factory :author do
    name { "Author #{rand(1000)}" }

    trait :with_books do
      after(:create) do |author|
        create_list(:book, 3, author: author)
      end
    end
  end
end
# api/spec/factories/books.rb
FactoryBot.define do
  factory :book do
    title { "Book #{rand(1000)}" }
    author
  end
end

RSpecのテストを作成

api/spec/requests/api/v1/authors_spec.rb
require 'rails_helper'

RSpec.describe "Api::V1::Authors", type: :request do
  describe "GET /api/v1/authors" do
    before do
      create(:author, :with_books)
      create(:author, :with_books)
    end

    it "returns a list of authors with their books" do
      get api_v1_authors_path

      expect(response).to have_http_status(:success)
      json = JSON.parse(response.body)
      expect(json.length).to eq(2)

      json.each do |author|
        expect(author).to have_key("id")
        expect(author).to have_key("name")
        expect(author).to have_key("books")

        author["books"].each do |book|
          expect(book).to have_key("id")
          expect(book).to have_key("title")
        end
      end
    end
  end
end

テストの実行

ターミナル
# テスト用のDBのマイグレーション
docker compose run --rm api bundle exec rails db_migration:apply RAILS_ENV=test
# テスト実行
docker compose run --rm api bundle exec rspec
# 実行結果
.F

Failures:

  1) Api::V1::Authors GET /api/v1/authors returns a list of authors with their books
     Failure/Error: get api_v1_authors_path
     
     Bullet::Notification::UnoptimizedQueryError:
       user: root
       GET /api/v1/authors
       USE eager loading detected
         Author => [:books]
         Add to your query: .includes([:books])
       Call stack
         /api/app/controllers/api/v1/authors_controller.rb:8:in `map'
         /api/app/controllers/api/v1/authors_controller.rb:8:in `block in index'
         /api/app/controllers/api/v1/authors_controller.rb:4:in `map'
         /api/app/controllers/api/v1/authors_controller.rb:4:in `index'
         /api/spec/requests/api/v1/authors_spec.rb:11:in `block (3 levels) in <main>'

Finished in 0.21614 seconds (files took 1.35 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./spec/requests/api/v1/authors_spec.rb:10 # Api::V1::Authors GET /api/v1/authors returns a list of authors with their books

Github Actionsで確認

N+1の解消

api/app/controllers/api/v1/authors_controller.rb
class Api::V1::AuthorsController < ApplicationController
  def index
-   authors = Author.all
+   authors = Author.includes(:books).all
    render json: authors.map { |author| 
          {
            id: author.id,
            name: author.name,
            books: author.books.map { |book| 
              {
                id: book.id,
                title: book.title
              }
            }
          }
        } 
  end
end

Github Actionsで確認

Discussion