🔫

【Rails】gem使ってみたシリーズ 〜bullet編〜 N+1問題を可視化

2025/02/13に公開

はじめに

お疲れ様です。
おおくまです。

今回は、「【Rails】gem使ってみたシリーズ 〜bullet編〜 N+1問題を可視化」ということで、Ruby on Railsで、bulletというgemを使って、N+1問題を可視化する方法について、まとめてみました。

少しでも皆様の参考になりますと幸いです。

対象読者

注意点

環境

N+1問題とは

N+1問題とは、データベースへの不要なクエリが大量に発生することで、パフォーマンスが低下する問題のことです。

そのため、データベースに高負荷がかかってしまったり、レスポンスが遅くなってしまったりすることがあります。

Ruby on Railsにおいては、ActiveRecordの関連(belongs_tohas_many)を扱うときに発生しやすいです。

発生例

以下のようなUserテーブルとPostテーブルがあるとします。

db/schema.rb
ActiveRecord::Schema[7.0].define(version: 2024_12_21_083443) do
  create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
    t.bigint "user_id", null: false
    t.string "title", null: false
    t.text "content", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["user_id"], name: "index_posts_on_user_id"
  end

  create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
    t.string "name", null: false
    t.string "email", null: false
    t.integer "age", null: false
    t.string "login_id", null: false
    t.string "encrypted_password", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["email"], name: "index_users_on_email", unique: true
    t.index ["login_id"], name: "index_users_on_login_id", unique: true
  end

  add_foreign_key "posts", "users"
end
app/models/user.rb
class User < ApplicationRecord
  has_many :posts, dependent: :destroy
end
app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
end

ここで、Post一覧画面を作成し、わざとN+1問題を発生させてみます。

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end
app/views/posts/index.html.slim
div.p-5
  h1.mb-3 投稿一覧
  p style="color: green" = notice
  = link_to "新規追加", new_post_path, class: 'btn btn-outline-info btn-sm m-3'
  .d-flex.flex-wrap.align-items-center
    - @posts.each do |post|
      .card.border.m-3 style="width: 400px;"
        .card-header.h5 = post.title
        .card-body
          .float-right
            button.copy-button.btn.btn-outline-secondary.btn-sm data-target="post_#{post.id}"
              i.far.fa-copy.mr-1
              span.copy-text コピー
          div
            .mb-3 = post.user.name
          div id="post_#{post.id}"
            .mb-3 = post.content
          = link_to "詳細", post, class: 'btn btn-outline-secondary btn-sm mr-1'
          - if current_user.id == post.user_id
            = link_to "編集", edit_post_path(post), class: 'btn btn btn-outline-info btn-sm mr-1'
            = link_to "削除", post, method: :delete, data: { turbo: "true", turbo_method: :delete, turbo_confirm: '削除しますか?' }, class: "btn btn-outline-danger btn-sm"

このように、コントローラーではPostの情報のみを取得し、ビューを描画する際に、個々にUserの情報を取得しているため、N+1問題が発生しています。

あわせて、発行されたクエリも確認してみます。

log/development.log
Processing by PostsController#index as HTML
  [1m[36mUser Load (2.5ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` = 1 ORDER BY `users`.`id` ASC LIMIT 1 /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/controllers/application_controller.rb:9:in `authenticate_user!'
  Rendering layout layouts/application.html.slim
  Rendering posts/index.html.slim within layouts/application
  [1m[36mPost Load (0.6ms)[0m  [1m[34mSELECT `posts`.* FROM `posts` ORDER BY `posts`.`id` ASC /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:6
  [1m[36mUser Load (0.1ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:15
  [1m[36mUser Load (0.1ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1 /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:15
  [1m[36mUser Load (0.1ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1 /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:15
  [1m[36mUser Load (0.1ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` = 4 LIMIT 1 /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:15
  [1m[36mUser Load (0.1ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` = 5 LIMIT 1 /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:15
  [1m[36mUser Load (0.1ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` = 6 LIMIT 1 /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:15
  [1m[36mUser Load (0.1ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` = 7 LIMIT 1 /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:15
  [1m[36mUser Load (0.1ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` = 8 LIMIT 1 /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:15
  [1m[36mUser Load (0.1ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` = 9 LIMIT 1 /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:15
  Rendered posts/index.html.slim within layouts/application (Duration: 13.3ms | Allocations: 7565)
  Rendered layout layouts/application.html.slim (Duration: 29.2ms | Allocations: 11491)
Completed 200 OK in 61ms (Views: 28.9ms | ActiveRecord: 5.7ms | Allocations: 12857)

ビューを描画する際に、Userテーブルに対して、Postの数だけクエリが発行されていることがわかります。

Postの数が増えるほど、発行されるクエリの数も増えてしまうため、パフォーマンスが低下してしまいます。

解決方法

今回の例では、コントローラーでPostの情報を取得する際に、Userの情報も一緒に取得するように修正することで、N+1問題を解消することができます。

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
+   @posts = Post.includes(:user)
  end
end
log/development.log
Processing by PostsController#index as HTML
  [1m[36mUser Load (1.6ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` = 1 ORDER BY `users`.`id` ASC LIMIT 1 /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/controllers/application_controller.rb:9:in `authenticate_user!'
  Rendering layout layouts/application.html.slim
  Rendering posts/index.html.slim within layouts/application
  [1m[36mPost Load (0.4ms)[0m  [1m[34mSELECT `posts`.* FROM `posts` ORDER BY `posts`.`id` ASC /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:6
  [1m[36mUser Load (0.3ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9) /*application:TestApp,controller:posts,action:index*/[0m
  ↳ app/views/posts/index.html.slim:6
  Rendered posts/index.html.slim within layouts/application (Duration: 18.3ms | Allocations: 7235)
  Rendered layout layouts/application.html.slim (Duration: 57.7ms | Allocations: 37078)
Completed 200 OK in 79ms (Views: 57.9ms | ActiveRecord: 3.4ms | Allocations: 41825)

発行されたクエリの数が減ったことが分かります。

コードを少し修正するだけで、N+1問題を解消することができました。

しかし、普段、コードを書いている中で、N+1問題が発生しているかどうかを把握することは難しいです。

そこで、bulletというgemを使うことで、N+1問題を可視化することができます。

bulletとは

bulletは、N+1問題を可視化するためのgemです。

bulletを導入することで、N+1問題が発生した際に教えてくれるため、N+1問題を解消することができます。

https://github.com/flyerhzm/bullet

導入方法

まず、Gemfilebulletを追加し、bundle installを実行します。

次に、bundle exec rails g bullet:installを実行します。

そうすると、config/environments/development.rbに以下のような設定が追加されます。

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

他にも様々な設定がありますが、今回はデフォルトの設定で進めていきます。

これで、bulletの導入が完了しました。

N+1問題を可視化

先ほど修正したPost一覧画面で、N+1問題を再度、発生させてみます。

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
+   @posts = Post.all
  end
end

Post一覧画面を表示すると、N+1問題が発生していることが可視化されます。

アラート

Post一覧画面を表示すると、アラートが表示されました。

log/bullet.log

log/bullet.log
2025-02-11 17:24:22[WARN] user: root
GET /
USE eager loading detected
  Post => [:user]
  Add to your query: .includes([:user])
Call stack
  /test_app/app/views/posts/index.html.slim:15:in `block in _app_views_posts_index_html_slim___3451928325704220638_26000'
  /test_app/app/views/posts/index.html.slim:6:in `_app_views_posts_index_html_slim___3451928325704220638_26000'

log/bullet.logにも、ログが出力されました。

開発者ツールのコンソール

開発者ツールのコンソールにも、アラートが表示されました。

log/development.log

log/development.log
user: root
GET /
USE eager loading detected
  Post => [:user]
  Add to your query: .includes([:user])
Call stack
  /test_app/app/views/posts/index.html.slim:15:in `block in _app_views_posts_index_html_slim___3451928325704220638_26000'
  /test_app/app/views/posts/index.html.slim:6:in `_app_views_posts_index_html_slim___3451928325704220638_26000'

log/development.logにも、ログが出力されました。

フッター

フッターにも、アラートが表示されました。

まとめ

今回、N+1問題や、N+1問題を可視化するためのgemであるbulletについて、まとめてみました。

導入も簡単で、N+1問題を可視化することができるため、開発中にN+1問題が発生してしまっても、すぐに気づくことができます。

こちらを参考に、アプリケーションのパフォーマンスを向上させてみてください。

少しでも皆様の参考になりますと幸いです。

最後まで読んでいただき、ありがとうございました。

GitHubで編集を提案
株式会社L&E Group

Discussion