Ruby勉強会を開催しました(2016#03)

今年3回目の勉強会を開催しました。
今回の参加者も11名!

今回はActiveRecordのN+1問題とその対策方法についての講義の時間を設けました。

Railsのモデル間のアソシエーションを設定した場合、アソシエーションが宣言されていれば関連付けした モデルを宣言時に定義されたメソッドで呼び出して取得することが可能です。

Web+DBという雑誌でも以前(Vo.70)にも紹介されていますが、注意深く扱わないとデータベースへのクエリーが非常に多く発行されてしまうこともあります。

たとえば、グループウェアのメッセージのモデルを例にあげると

# メッセージモデル

class Message < ActiveRecord::Base
  belongs_to :users
  has_many :replies # 返信モデル

end

# メッセージの受信(返信)モデル

class Reply < ActiveRecord::Base
  belongs_to :users
  belongs_to :message
end

# ユーザ

class User < ActiveRecord::Base
  has_many :messages
  has_many :replies
end

このときに、画面に受信モデル(Reply)の一覧を表示することとします。

↓メッセージの受信画面

回覧受信画面

実際のSQLは複雑なので、コンソールで以下の処理を実行してみます。

Reply.own.includes(:message).each { |reply| p reply.message.user.username }

発行されたSQLは次の通りです。

Reply Load (1.2ms)  SELECT "replies".* FROM "replies" WHERE "replies"."user_id" = $1, []["user_id", 33]]
Message Load (1.4ms)  SELECT "messages".* FROM "messages" WHERE "messages"."id" IN (1, 2, 3, 4)  ORDER BY "messages"."sent_at" DESC

User Load (0.8ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1  [["id", 29]]
User Load (3.5ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1  [["id", 29]]
User Load (1.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1  [["id", 32]]
User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1  [["id", 32]]

受信一覧のデータを取得するためのSQLはMessageとRpleyそれぞれ1回だけ発行されていますが、Messageに紐づくユーザー(送信者)の情報を取得するため、 SQLが続いて4回(4行分)発行されています。
(Webサーバーからのアクセスした際には、同じユーザであれば2回目のSQLはキャッシュから読み込まれていたようなのですが・・・)

これを次のように変更してみましょう。includes, eager_loadの引数に :user のアソシエーションも加えました。

Reply.own.includes(:message => :user).each { |reply| p reply.message.user.username }

すると発行されるSQLは次のように変わります。

Reply Load (1.3ms)  SELECT "replies".* FROM "replies" WHERE "replies"."user_id" = $1 ["user_id", 33]]
Message Load (0.5ms)  SELECT "messages".* FROM "messages" WHERE "messages"."tenant_id" = $1 AND "messages"."id" IN (1, 2, 3, 4)  ORDER BY "messages"."sent_at" DESC

User Load (0.8ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (32, 29)

ユーザの取得のためのSQLが1行となっていることがわかります。
このN+1問題の回避を早い段階で実施しておいたほうがいいですね。

includesを使用した際、結合する側のモデルの条件を加えて検索する場合(mergeメソッドを使うなど)には、eager_loadも付け加えなければならないこともあります。
それでは記述が長くなってしまいますし、検索処理が複数行われていると冗長に感じます。

私はこの対策として scope を利用しています。
ActiveRecord の scope はなんとなく、検索条件や並び順を定義する印象を持たれているかもしれませんが、結合の条件を記述することもできます。

下記に例を示します。

# メッセージの受信(返信)モデル

class Reply < ActiveRecord::Base
  belongs_to :users
  belongs_to :message

  scope :own, -> { where(:user_id = User.current_id) }
  scope :includes_message_user, -> { includes(:message => :user).eager_load(:message => :user) }
end

ActiveRecordは強力で便利ではありますが、思わぬ落とし穴もあったりしますのでうまく活用できるように仕様を理解していきたいと思います。