2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

N + 1問題, そしてincludesとは何なのか?

Last updated at Posted at 2019-12-24

はじめに

Ruby on Railsを学習中ですが、その中でも、分かったような分からないようなモヤモヤする...
と個人的に感じた内容について、記事化していこうと思います。

今回のお題

「N + 1問題とは何?, そして、includesを使うことのメリットは?」

「データベースからデータを取り出す際にincludesを使用すると、 N+1問題を解消し、効率的である(?)」

???:frowning2:
N+1問題や、それを解決するためのincludesメソッドについて、もやっとした理解だったので、
個人的に整理をつけるべく、まとめてみました。

N + 1問題とは何だろう?

下記のようなアプリを考えます。

チャットアプリ

よくある掲示板サイトのような感じです

  • 機能
    • 複数人のユーザーがいる
    • チャットグループがある(スレッドのようなもの)
    • チャットグループには、チャットテーマがあり、複数人のメンバーが参加することができる
    • そのチャットグループに所属しているメンバーは、メッセージを投稿できる
      01_チャットアプリ概要.png

モデルとアソシーション

このチャットアプリは下記のmodelで成り立っています

  • Model
    • Group
    • Message
    • User

これらのmodelは、下記のような関係になっています

  • Group(チャットグループ)は、多数のMessagesを持っている
    • (MessageはGropに属している)
  • Group(チャットグループ)には、多数のUser(メンバー)がいる
    • (UserはGroupに属している)
  • GroupとUserは多対多の関係である
    • Groupには多数のUserが存在し、また、Userも多数のGroupに属する
    • 中間テーブルが必要。 ※今回は説明は割愛させて頂きます

ER図と、associationを示すコードは下記のようになります

スクリーンショット 2019-12-15 23.58.01.png

# 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

さて、以上の前提条件を元にして、
メッセージ一覧を表示させる 処理を行ってみましょう

下図のように、左のチャットグループ一覧から、閲覧したいグループをクリックすると、
右に、チャット概要(タイトルとメンバー)とメッセージ一覧が表示されます

03_#index説明(修正済).png

この処理を indexメソッド として、messages_controller に書いていきます

message_controller.rb
  def index
    @group = Group.find(params[:group_id])
    @messages = @group.messages
  end

<解説>

  1. チャットグループを選択した時に、params[:group_id]というparamsが送られるので、そのparamsから@group = Group.find(params[:group_id])で、対象のgroupを取得します
  2. @messages = @group.messagesにて、そのgroup内のmessagesを取り出しています

取り出した@messagesを表示させましょう!
indexメソッドで取り出したデータを、ビューindex.html.hamlに表示させます。

@messages内には4つのmessageが入っており、下図の赤枠のように表示させます。

04_index.hamlの説明(修正).png

赤枠部分に対応するコードは下記になります
(※細かいクラス名などは省略しています)

index.html.haml
.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 でお世話になっている、ターミナルから確認することができます。

07_N+1のSQL.png

このSQLで、4個のメッセージを取得 => 描画するための必要なデータの取り出しが行われています。
上記の例だと、計5個 のクエリが実行されています。4個のメッセージを表示させるのに、5回のクエリが実行される??:thinking:
これが、まさしく N + 1 問題の正体です。

ちなみに、このクエリと、先ほど実装したコードとの対応関係は下記のようになります。

07_SQLとコードの対応表(size修正済).png

という流れになっています。
@messagesを取得した後に、1件1件のmessage.user.nameを取得するためにのクエリがそれぞれ 実行されています。
今回は、メッセージが4個だけですが、これが何千・何万個とあれば、その分+1個のクエリが実行されてしまいます。これが、不特定多数の利用があったら、サーバーへの負荷は相当なものと考えられます。

これを解消するために、includesメソッド を使用しましょう :desktop:

includesを使用してみる

includesを用いて、indexメソッドを書いてみましょう

先ほどの、messages_controllerの @messages = @group.messages
includesメソッド を追記します。

messages_controller.rb
  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の情報も、同時に取得出来るということです。

なんとなく分かったような、分からないような:hushed:
ここで、再度、SQLクエリを見てみましょう!

08_includesを使用した場合のSQL.png

ずいぶんシンプルになりました!
先ほどは、5回実行されていましたが、includesメソッドを使うことで、2回に減りました。
これは、includesメソッドによって、@group.messagesを取得する際に、関連する user の情報(メッセージを投稿したuserの情報)も取得しているので、たった2回で済むようになります。

処理時間の比較

クエリの実行回数が減ることは確認出来ましたが、実際の処理時間に変化はあるのでしょうか:watch:
たった4個の情報の取得ですので、果たして差はあるのか?・・・
確かめてみました。

SQLクエリの実行時間は、下図のように、処理が終わった際に表示される、
Completed 200 OK in 100ms (Views: 64.5ms | ActiveRecord: 2.3ms)
のActiveRecordのミリ秒を評価しました(もし間違っていたらご指摘お願いします:beer:)

09_SQL(includesなし) .png

  • includesを使用しない場合
  • includesを使用する場合

で、それぞれ indexメソッド を10回動作させてみて、ActiveRecordの動作時間の平均を取りました。

結果
  • includesを使用しない場合
    • 2.74ms
  • includesを使用する場合
    • 2.33ms

劇的な変化はありませんでしたが、表示したいmessagesが大量の場合だと、かなり違ってくるのかもしれません。
(※興味がある方は、このようなアプリに、seedで大量にダミーデータを入れてみて、検証してみるのもいいかもしれません)

おわりに

RubyやRailsなどを学習してきて、なんとなく分かったような、分からないような概念やメソッドなど結構あるのですが、今回、そのようなテーマを深堀りし、個人的に モヤモヤからスッキリ に変わったような気がします。
includesメソッドが無くても機能的には何ら問題はないのですが、サーバーの内部的には負荷が違ってくるし、業務用のアプリで膨大な件数のデータを取得する時は、目に見える形で効率が違うのかもしれません。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?