Aqutras Members' Blog

株式会社アキュトラスのメンバーが、技術情報などを楽しく書いています。

railsで子モデルの要素をcountした際にN+1になった話

こんにちは、taniyuです。 今回は、railsでお馴染みのN+1問題についてお話します。

概要

冒頭でも述べましたが、今回は、eager loading(関連モデルの読み込み)を行った後に、countを使うとSQLが発行されるということについてお話します。

また、今回は、下記の構造を持つ、ユーザ(user)が複数の記事(post)を投稿できるようなシステムを例に挙げて説明します。

class User < ActiveRecord::Base
  has_many :posts
end
class Post < ActiveRecord::Base
  belongs_to :user
end

問題が発生するコード

以下の様に、ユーザ情報と一緒に記事の投稿数を表示するようなview(index.html.slim)を考えます。

table
  thead
    tr
      th
        | 名前
      th
        | 投稿数
  tbody
    - @users.each do |user|
      tr
        td = user.name
        td = user.posts.count  /<= この行に注目

user.posts.count という処理があります。この行は、N+1問題となります。 そのため、eager loading をするために、コントローラーで以下のような処理を記述すると思います。

class UsersController < ApplicationController
  def index
    @users = User.includes(:posts)
  end
end

今回は、includesを使っていますが、他にも色々あります。
それらの違いについては、以下のサイトが参考になります。

ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い - Qiita

しかし、eager loading をしても、サーバーのログを見ると、大量のクエリが流れています。
あらかじめ読み込んでいるはずなのになぜでしょうか?

問題の原因

原因は呼び出されるメソッドにあります。 countメソッドを呼び出すと、ActiveRecordのcountメソッドが呼ばれます。
countメソッドでは、内部で SELECT count(*) ~ を実行しており、
既に読み込んでいても毎回クエリが発行されるため、N+1問題が発生してしまいます。
では、どのようにすればよいでしょうか?

対策

対策について説明します。対策方法は、以下の様なものがあります。

  1. Userテーブルにカラム(posts_count)を追加する
  2. sizeメソッドを使う

「1. Userテーブルにカラム(posts_count)を追加する」 について

1.は単純にUserテーブルにカラムを追加し、そっちでデータを保存するという方法になります。
このようにすることで、 user.posts.count から user.posts_count という書き方にかわり、
関連するモデルを読み込む必要がなくなります。そのため、N+1問題が発生しなくなります。
しかし、子モデル(今回であれば、post)のデータが、追加・削除される際には、userモデルの
posts_countを更新する必要があり、SQLが発行されるようになります。

「2. sizeメソッドを使う」 について

2.は、使うメソッドを変えるというものです。sizeメソッドは、countメソッドと異なり、
既に読み込んでいる場合は、そのデータを使います。つまり、クエリが生成されることはありません。
そのため、解決できます。しかし、あらかじめ、includes等で読み込む処理が入るので、
ページ表示までの時間が1の方法よりも長くなります。

まとめ

1のようにカラムを追加する場合、子モデルの追加・削除時にSQLが発行されるようになります。
対して、2のようにeager loadingを使う場合、ページ表示までの時間が増加しますが、 子モデルの追加・削除時にSQLが発行されることはありません。
どちらか一方が優れているというわけではないため、どちらの実装方法を利用するかは、 ページがどのくらい利用されるか等を考慮した上で決めた方が良いです。

その他(条件を満たす要素を数える場合)

ちなみに、指定した要素の数を数えたい場合は、以下のように select と size を使うことで作成できます。

user.posts.select { |post| "条件文" }.size

参考

今回は以下のサイトを参考にさせていただきました。ありがとうございます。

countじゃなくてsizeを使った方がいい!! アソシエーションがLoadされてるか調べて常に高速なメソッドを作ろう! - Qiita