読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

N+1問題 / Eager Loading とは

パフォーマンス

N+1問題とは

SQLクエリが 「データ量N + 1回 」走ってしまい、取得するデータが多くなるにつれて(Nの回数が増えるにつれて)パフォーマンスを低下させてしまう問題です。
次のように、何度もクエリが走ってしまい、その度に0.1msほどかかってしまってます。

Processing by PostsController#index as HTML
  Post Load (0.2ms) SELECT "posts".* FROM "posts"
  User Load (0.2ms) SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT 1  [["id", 1]]
  User Load (0.1ms) SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT 1  [["id", 2]]
  User Load (0.1ms) SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT 1  [["id", 3]]
  User Load (0.1ms) SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT 1  [["id", 4]]
  User Load (0.1ms) SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT 1  [["id", 5]]
  User Load (0.1ms) SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT 1  [["id", 6]]
  User Load (0.1ms) SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT 1  [["id", 7]]
  User Load (0.1ms) SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT 1  [["id", 8]]
  User Load (0.1ms) SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT 1  [["id", 9]]
  User Load (0.1ms) SELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT 1  [["id", 10]]
  Rendered posts/index.html.erb within layouts/application (32.9ms)
Completed 200 OK in 147ms (Views: 132.6ms | ActiveRecord: 2.0ms)

これを解消させると、2回のクエリだけでよくなり、データが多くなってもクエリの発行回数は増えず、パフォーマンスを向上させることができます。

Processing by PostsController#index as HTML
  Post Load (0.2ms)  SELECT "posts".* FROM "posts"
  User Load (0.2ms)  SELECT "users".* FROM "users"  WHERE "users"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
  Rendered posts/index.html.erb within layouts/application (26.2ms)
Completed 200 OK in 138ms (Views: 124.4ms | ActiveRecord: 1.1ms)

N+1問題の具体例

N+1問題のよくある例としては次のようなコードを記載しているとN+1クエリとなってしまいます。

まず、モデルでは1対Nなどの関係になっている。

# app/models/user.rb
class User < ActiveRecord::Base
  has_many :posts
end

# app/models/post.rb
class Post < ActiveRecord::Base
  belongs_to :user
end

そして、コントローラーで、Postモデルのみを取得する。

# app/contollers/posts_controller.rb
  def index
    @posts = Post.all  # SELECT "posts".* FROM "posts" が発行される。実際にはViewで発行される
  end

最後に、ビューでUserモデルの情報も表示する。
このときに、Userモデルを事前にローディングしていないので、表示しようとするたびにSQLが発行されてしまいます。

<h1>Listing posts</h1>

<table>
  <thead>
    <tr>
      <th>Title</th>
      <th>Content</th>
      <th>User</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <!-- @posts.eachで SELECT "posts".* FROM "posts" クエリが発行される。 -->
    <% @posts.each do |post| %>
      <tr>
        <td><%= post.title %></td>
        <td><%= post.content %></td>
        <!-- post.user.name で ELECT  "users".* FROM "users"  WHERE "users"."id" = ? LIMIT 1  [["id", user_id]] クエリが発行される。 -->
        <td><%= post.user.name %></td>
        <td><%= link_to 'Show', post %></td>
        <td><%= link_to 'Edit', edit_post_path(post) %></td>
        <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

これにより、@posts.eachのたびにSQLクエリが発行されて、N+1問題が発生していまいます。


Eager Loading とは

このN+1問題を解決するにはEager Loading(事前にデータをロードしておく)をしておこうという話です。

上記の例にEager Loading(事前にデータをロードする)を適用してみます。
コントローラーに、Eager Loadingをするメソッドのincludesで、Postモデルの取得時に、それに関連するUserモデルも取得します。

# app/contollers/posts_controller.rb
  def index
    @posts = Post.all.includs(:user)
    # 下記2つのSQLが発行される
    # SELECT "posts".* FROM "posts"
    # SELECT "users".* FROM "users"  WHERE "users"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
  end

これにより、ビューのpost.user.nameの際にいちいちSQLクエリを発行しなくてもよくなり、N+1問題を解決できます。


N+1問題は気をつけていても見逃してしまう可能性もあるので、それをN+1問題を発見する手助けになるGemのbulletを使ってみてはいかがでしょうか。
導入方法は、コチラ