はじめに
Ruby on Railsを学習中ですが、その中でも、分かったような分からないようなモヤモヤする...
と個人的に感じた内容について、記事化していこうと思います。
今回のお題
「N + 1問題とは何?, そして、includesを使うことのメリットは?」
「データベースからデータを取り出す際にincludesを使用すると、 N+1問題を解消し、効率的である(?)」
???
N+1問題や、それを解決するためのincludesメソッドについて、もやっとした理解だったので、
個人的に整理をつけるべく、まとめてみました。
N + 1問題とは何だろう?
下記のようなアプリを考えます。
チャットアプリ
よくある掲示板サイトのような感じです
- 機能
モデルとアソシーション
このチャットアプリは下記のmodelで成り立っています
- Model
- Group
- Message
- User
これらのmodelは、下記のような関係になっています
- Group(チャットグループ)は、多数のMessagesを持っている
- (MessageはGropに属している)
- Group(チャットグループ)には、多数のUser(メンバー)がいる
- (UserはGroupに属している)
- GroupとUserは多対多の関係である
- Groupには多数のUserが存在し、また、Userも多数のGroupに属する
- 中間テーブルが必要。 ※今回は説明は割愛させて頂きます
ER図と、associationを示すコードは下記のようになります
# group.rb
class Group < ApplicationRecord
has_many :messages
has_many :users, through: :group_users
has_many :group_users
end
# message.rb
class Message < ApplicationRecord
belongs_to :user
belongs_to :group
end
# group_user.rb
class GroupUser < ApplicationRecord
belongs_to :user
belongs_to :group
end
# user.rb
class User < ApplicationRecord
has_many :messages
has_many :groups, through: :group_users
has_many :group_users
end
さて、以上の前提条件を元にして、
メッセージ一覧を表示させる 処理を行ってみましょう
下図のように、左のチャットグループ一覧から、閲覧したいグループをクリックすると、
右に、チャット概要(タイトルとメンバー)とメッセージ一覧が表示されます
この処理を indexメソッド として、messages_controller に書いていきます
def index
@group = Group.find(params[:group_id])
@messages = @group.messages
end
<解説>
- チャットグループを選択した時に、
params[:group_id]
というparamsが送られるので、そのparamsから@group = Group.find(params[:group_id])
で、対象のgroupを取得します -
@messages = @group.messages
にて、そのgroup内のmessagesを取り出しています
取り出した@messages
を表示させましょう!
indexメソッドで取り出したデータを、ビューindex.html.haml
に表示させます。
@messages
内には4つのmessage
が入っており、下図の赤枠のように表示させます。
赤枠部分に対応するコードは下記になります
(※細かいクラス名などは省略しています)
.messages
- @messages.each do |message|
= message.user.name
= message.created_at.strftime("%Y/%m/%d(%a) %H:%M:%S")
= message.content
このチャットグループには4件のmessageがあるので、eachメソッドで、@messages
を1個ずつバラしてmessage
にして、その中のcontentや、関連するuser.nameといった情報を描画しています
ここで、この処理が行われる時に実行される、SQLのクエリを見てみましょう
お馴染みの rails s
でお世話になっている、ターミナルから確認することができます。
このSQLで、4個のメッセージを取得 => 描画するための必要なデータの取り出しが行われています。
上記の例だと、計5個 のクエリが実行されています。4個のメッセージを表示させるのに、5回のクエリが実行される??
これが、まさしく N + 1 問題の正体です。
ちなみに、このクエリと、先ほど実装したコードとの対応関係は下記のようになります。
という流れになっています。
@messages
を取得した後に、1件1件のmessage.user.name
を取得するためにのクエリがそれぞれ 実行されています。
今回は、メッセージが4個だけですが、これが何千・何万個とあれば、その分+1個のクエリが実行されてしまいます。これが、不特定多数の利用があったら、サーバーへの負荷は相当なものと考えられます。
これを解消するために、includesメソッド を使用しましょう
includesを使用してみる
includesを用いて、indexメソッドを書いてみましょう
先ほどの、messages_controllerの @messages = @group.messages
に
includesメソッド を追記します。
def index
@group = Group.find(params[:group_id])
@messages = @group.messages.includes(:user)
end
簡単ですね。
さて、includesメソッドの引数 :user
とは一体何でしょうか?
railsガイドでは、includesメソッドについて、下記のように説明されています。
Active Recordは、読み込まれるすべての関連付けを事前に指定することができます。
(中略)
includesを指定すると、Active Recordは指定されたすべての関連付けが最小限のクエリ回数で読み込まれるようにしてくれます。
上記のindexメソッドの場合、取得した@group.messages
に関連するuser
の情報も、同時に取得出来るということです。
なんとなく分かったような、分からないような
ここで、再度、SQLクエリを見てみましょう!
ずいぶんシンプルになりました!
先ほどは、5回実行されていましたが、includesメソッドを使うことで、2回に減りました。
これは、includesメソッドによって、@group.messages
を取得する際に、関連する user
の情報(メッセージを投稿したuserの情報)も取得しているので、たった2回で済むようになります。
処理時間の比較
クエリの実行回数が減ることは確認出来ましたが、実際の処理時間に変化はあるのでしょうか
たった4個の情報の取得ですので、果たして差はあるのか?・・・
確かめてみました。
SQLクエリの実行時間は、下図のように、処理が終わった際に表示される、
Completed 200 OK in 100ms (Views: 64.5ms | ActiveRecord: 2.3ms)
のActiveRecordのミリ秒を評価しました(もし間違っていたらご指摘お願いします)
- includesを使用しない場合
- includesを使用する場合
で、それぞれ indexメソッド を10回動作させてみて、ActiveRecordの動作時間の平均を取りました。
結果
- includesを使用しない場合
- 2.74ms
- includesを使用する場合
- 2.33ms
劇的な変化はありませんでしたが、表示したいmessagesが大量の場合だと、かなり違ってくるのかもしれません。
(※興味がある方は、このようなアプリに、seedで大量にダミーデータを入れてみて、検証してみるのもいいかもしれません)
おわりに
RubyやRailsなどを学習してきて、なんとなく分かったような、分からないような概念やメソッドなど結構あるのですが、今回、そのようなテーマを深堀りし、個人的に モヤモヤからスッキリ に変わったような気がします。
includesメソッドが無くても機能的には何ら問題はないのですが、サーバーの内部的には負荷が違ってくるし、業務用のアプリで膨大な件数のデータを取得する時は、目に見える形で効率が違うのかもしれません。