さかなソフトブログ

プログラミングやソフトウェア開発に関する情報

Web

ActiveRecordのpreload/eager_load/joins/incudesの使い方 〜N+1問題回避と関連先を含めた効率の良い絞り込み〜

更新日:

ActiveRecordのRelationにはpreload/eager_load/joins/includesというメソッドが用意されており、適切な使い方をすることにより余計なSQLクエリを発生させることを防いでパフォーマンス改善が出来るようになっています。

これらを簡単な関連付きモデルが含まれたRailsサンプルプロジェクトを用意して実際に何の問題を解決しようとしているのか、どういうクエリ・振る舞いをしているかを検証してみたいと思います。

目次


スポンサーリンク

正方形336

結論

細かいことはいいからどう使うのおすすめか結論をまとめておきます:

  • 多重度が多の関連先を持つモデルを関連先も含めて逐次参照すると一覧参照に加えてヒットしたレコード数のクエリが発生してしまう。これがN+1問題
  • N+1問題を解決するには先ずはpreloadを利用する。大体の場合で一番パフォーマンスが良い
  • 関連先で絞り込みを行いたい場合、関連が1対1の場合はeager_loadを利用する
  • 関連先で絞り込みを行うが関連先参照の必要が無い場合joinsを利用する
  • includesはpreloadとeager_loadを自動的に振り分けるが不十分な実装が多いので利用しない方が良い

どうしてこうなったかを知りたい場合は最後までお付き合い頂ければ幸いですm(_ _)m

関連付きモデルが含まれたRailsサンプル環境構築

今回は関連が含まれたモデルを一覧参照する際に発生するN+1問題を中心に検証したいと思いますので以下のRailsサンプル環境を用意します:

動作環境

  • node 11
  • yarn (npmでインストール)
  • ruby 2.6
  • rails 6
  • postgres (docker-compose)

rails newでオプション無しプロジェクトを作成するとsqlite3でDBが構築されますが、今回はパフォーマンス問題の検証で少し心許ないのでpostgresをdockerコンテナで構築してみます。実際に利用したサンプルはgithubに置きましたので必要があれば参照してください。

では構築していきます。nodeとyarn、rubyとdocker-composeはインストール済みとして進めます:

$ gem install rails
$ rails new active_record_includes
$ rails server

これでlocalhost:3000でrailsが動作出来ることを確認しておきます。自分の環境ではwebpackerが一発で動かなかったのでこれに加えてnode_modulesフォルダを削除してyarnコマンドを再度実行して正しく動作しました。なお、実際の動作確認はrails consoleでモデルを直接操作します。

次にsqlite3からpostgresにdockerコンテナを利用して移行します。railsルートフォルダに以下の様にdocker-compose.ymlを作成します:

postgresql:
  image: postgres
  environment:
    POSTGRES_USER: postgres
    POSTGRES_PASSWORD: postgres
  ports:
    - 15432:5432
  volumes:
    - postgresql.volume:/var/lib/postgresql/data
  container_name: postgres-db

ポートは既にローカル環境にpostgresがインストールされていることを考慮してport 15432に変更してあります。作成できたらコンテナを起動します:

$ docker-compose up -d

これで自動的にpostgres:latest(たぶん11.5)がbuildされて立ち上がります:

$ docker-compose ps
   Name                  Command              State            Ports
-----------------------------------------------------------------------------
postgres-db   docker-entrypoint.sh postgres   Up      0.0.0.0:15432->5432/tcp

次にrailsのDBをsqlite3からpostgresにGemfileとdatabase.ymlを変更して切り替えます:

diff --git a/Gemfile b/Gemfile
index eaf64de..79c9f37 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,8 +5,8 @@ ruby '2.6.4'

 # Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
 gem 'rails', '~> 6.0.0'
-# Use sqlite3 as the database for Active Record
-gem 'sqlite3', '~> 1.4'
+# Use postgres as the database for Active Record
+gem 'pg'
 # Use Puma as the app server
 gem 'puma', '~> 3.11'

diff --git a/config/database.yml b/config/database.yml
index 4a8a1b2..82fda33 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -5,21 +5,25 @@
 #   gem 'sqlite3'
 #
 default: &default
-  adapter: sqlite3
+  adapter: postgresql
   pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
   timeout: 5000
+  username: postgres
+  password: postgres
+  host: localhost
+  port: 15432

 development:
   <<: *default
-  database: db/development.sqlite3
+  database: active_record_includes_development

 # Warning: The database defined as "test" will be erased and
 # re-generated from your development database when you run "rake".
 # Do not set this db to the same as development or production.
 test:
   <<: *default
-  database: db/test.sqlite3
+  database: active_record_includes_test

 production:
   <<: *default
-  database: db/production.sqlite3
+  database: active_record_includes_production

設定が終わったらgemを更新しておきます:

$ bundle update

以上で環境構築が整いました。次にモデルを構築します。

モデル構築

関連付きのモデルとして今回は記事(Article)にコメント(Comment)が複数付けられるという設定にしたいと思います:

class Article < ApplicationRecord
  has_many :comments, dependent: :destroy
end

class Comment < ApplicationRecord
  belongs_to :article
end

モデル及びmigrateファイルはrails generateコマンドで自動作成出来ます。テストファイルが作成されないようにスキップオプションを付けて生成します:

$ rails g model Article title:string body:text --skip-test-framework
$ rails g model Comment article:references body:text --skip-test-framework

app/models/article.rbapp/models/comment.rb、migrationファイルが作成されると思います。Articleからの関連は自動生成されないので↑のクラスを参考にhas_manyを記述しておいて下さい。

これでデータベースとテーブルを作成できます。以下のコマンドを打ちます:

rails db:create db:migrate

N+1問題が分かりやすく発生出来るようにArticleを100レコード、各articleにCommentが10レコード関連付けられる様にダミーデータを作成するためのdb/seeds.rbを以下のように記述します:

100.times do |i|
  article = Article.new title: "タイトル#{format '%04d', i+1}", body: "本文#{format '%04d', i+1}"
  10.times do |j|
    article.comments.build body: "タイトル#{format '%04d', i+1}のコメント#{format '%03d', j+1}"
  end
  article.save!
end

ダミーデータを作成して完成です:

$ rails db:seed

これで検証環境が整いました。振る舞いを確認する際は以下のコマンドでrailsコンソールを利用して検証します:

$ rails c

N+1問題とは

それでは実際にモデルを操作してみます。記事一覧を全てのコメントも含めて参照したと想定してまずはなにも考えずArticleから順番に関連付けられたコメントも全部参照してみたモデル操作をベンチマークしてみます:

> Article.benchmark{ Article.all.each{|article| article.comments.to_a} }; nil
  Article Load (4.9ms)  SELECT "articles".* FROM "articles"
  Comment Load (1.7ms)  SELECT "comments".* FROM "comments"
      WHERE "comments"."article_id" = $1  [["article_id", 1]]
  Comment Load (1.2ms)  SELECT "comments".* FROM "comments"
      WHERE "comments"."article_id" = $1  [["article_id", 2]]
  ...
  Comment Load (1.8ms)  SELECT "comments".* FROM "comments"
      WHERE "comments"."article_id" = $1  [["article_id", 100]]
Benchmarking (385.7ms)
=> nil

ログで表示されているクエリを観てみると、まずArticleを取得してそれぞれのコメント一覧を参照しているので取得した記事に関連付けられているコメント一覧を記事毎に取得しています。

今回のダミーデータはArticleが100レコードあるので、記事一覧取得の為に1回、各記事のコメント一覧取得の為に100回クエリを発行することになります

これがN+1問題と呼ばれるもので、レコード数が大量になるとパフォーマンスが極端に落ちていくことは容易に想像できると思います。

N+1問題を解消するには先ずはpreloadを使う

それではこのN+1問題をどうやって解消したら良いでしょうか。まず考えられるのは、今回の様に記事一覧を取得した後それに関連付けられたコメントも全部参照したいと分かっている場合は、参照する全ての記事のコメントを一括で取得してしまえば1回のクエリで済みそうだというアプローチが考えられます。

これを実現するのがpreloadになります。それでは実際に使用してみます:

> Article.benchmark{ Article.preload(:comments).each{|article| article.comments.to_a}}; nil
  Article Load (6.7ms)  SELECT "articles".* FROM "articles"
  Comment Load (7.3ms)  SELECT "comments".* FROM "comments"
      WHERE "comments"."article_id" IN ($1, ... , $100)  [["article_id", 1], ... , ["article_id", 100]]
Benchmarking (60.4ms)
=> nil

クエリをみると、記事一覧を取得した後、取得したarticle_idに対して関連付いたコメントを1クエリで取得していることが分かります。パフォーマンスも一気に5倍以上短縮されました。逐次参照する場合は基本的にはこちらを使えば大丈夫でしょう。

1クエリで全部取得したい場合や関連先で条件絞り込みを行いたい場合はeager_loadを使う

しかしながら、preloadでは不利なケースや条件によっては利用出来ないケースがあります。

例えば、1対1の関連が複数ネストしているものを参照したい場合、preloadを使うとネストしているテーブル数だけクエリが発生して不利になります。

また、関連先であるコメントで条件下にある記事一覧とその条件に合うコメントを参照したい様な場合は記事一覧を取得する段階ではコメントを参照しておらず、絞り込みが行えないためpreloadは使用出来ません。

このような場合、eager_loadを使用すると効率よく参照できるケースがあります。実際に使用した例が以下の様になります:

> Article.benchmark{ Article.eager_load(:comments).each{|article| article.comments.to_a }}; nil
  SQL (8.6ms)  SELECT "articles"."id" AS t0_r0, "articles"."title" AS t0_r1,
      "articles"."body" AS t0_r2, "articles"."created_at" AS t0_r3, "articles"."updated_at" AS t0_r4,
      "comments"."id" AS t1_r0, "comments"."article_id" AS t1_r1, "comments"."body" AS t1_r2,
      "comments"."created_at" AS t1_r3, "comments"."updated_at" AS t1_r4
      FROM "articles" LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id"
Benchmarking (92.0ms)
=> nil

eager_loadを使うと、articlesテーブルにcommentsテーブルを左外部結合(LEFT OUTER JOIN)して関連付けされたカラムも全て一括取得することにより1カラムで取得できています。

パフォーマンスもN+1問題時よりは4倍程度短縮されて十分対策出来ています。ですが、preloadの取得よりは若干遅いようです。

また、結合なので関連先で絞り込みを行うことが出来ます:

> Article.benchmark{
 Article.eager_load(:comments).where('comments.body LIKE ?', '%コメント%')
        .each{ |article| article.comments.to_a }
}; nil
  SQL (19.3ms)  SELECT "articles"."id" AS t0_r0, "articles"."title" AS t0_r1,
      "articles"."body" AS t0_r2, "articles"."created_at" AS t0_r3, "articles"."updated_at" AS t0_r4,
      "comments"."id" AS t1_r0, "comments"."article_id" AS t1_r1, "comments"."body" AS t1_r2,
      "comments"."created_at" AS t1_r3, "comments"."updated_at" AS t1_r4
      FROM "articles" LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id"
      WHERE (comments.body LIKE '%コメント%')
Benchmarking (96.2ms)
=> nil

この時のarticle.commentsは条件にヒットしたコメントのみが参照出来ます。

ところで、1クエリで取得出来るんだし、だったら、preloadよりも常にeager_load使えば良いじゃ無いかという風にも考えられると思いますが、ArticleとCommentのカラムを外部結合するので今回なら記事100レコードに各コメント10件で1000レコードを取得することになり、DBからの転送データ量およびメモリ量の視点で不利になります。

しかしながら、関連先がbelongs_to等のように1対1で紐付けられている場合は外部結合した場合でも記事100レコードで外部結合したとしても100レコード取得には変わりないので、eager_loadを使っても特に問題は無いかと思います。

関連先で条件絞り込みだけ行って関連先のカラム参照の必要が無い時はjoinsを使う

eager_loadで関連先で条件を絞り込みを行えましたが、条件に合う記事一覧だけが必要でコメントは要らない場合はjoinsを使った方が有利になります。以下の様に利用します:

> Article.benchmark{
    Article.joins(:comments).where('comments.body LIKE ?', '%コメント%').distinct.to_a
}; nil
    Article Load (6.9ms)  SELECT DISTINCT "articles".* FROM "articles"
        INNER JOIN "comments" ON "comments"."article_id" = "articles"."id"
        WHERE (comments.body LIKE '%コメント%')
Benchmarking (13.2ms)
=> nil

distinctは、内部結合だと同一記事のコメントが複数ヒットしてしまうと両方とれてしまうので重複を避けるために付けています。同じことをuniq、および、eager_loadでもやってみます:

> Article.benchmark{ 
    Article.joins(:comments).where('comments.body LIKE ?', '%コメント%').uniq.to_a
}; nil
    Article Load (10.7ms)  SELECT "articles".* FROM "articles"
        INNER JOIN "comments" ON "comments"."article_id" = "articles"."id"
        WHERE (comments.body LIKE '%コメント%')
Benchmarking (52.3ms)
=> nil
> Article.benchmark{
    Article.eager_load(:comments).where('comments.body LIKE ?', '%コメント%').to_a
}; nil
    SQL (22.0ms)  SELECT "articles"."id" AS t0_r0, "articles"."title" AS t0_r1,
        "articles"."body" AS t0_r2, "articles"."created_at" AS t0_r3,
        "articles"."updated_at" AS t0_r4, "comments"."id" AS t1_r0,
        "comments"."article_id" AS t1_r1, "comments"."body" AS t1_r2,
        "comments"."created_at" AS t1_r3," comments"."updated_at" AS t1_r4
        FROM "articles"
        LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id"
        WHERE (comments.body LIKE '%コメント%')
Benchmarking (96.0ms)
=> nil

uniqは、重複したレコードを取得してしまってからオブジェクト上で絞り込んでいます。eager_loadの方は外部結合になってしまっているので必要の無いコメントカラムまで取得してしまっているためコストが多く掛かっていることが分かると思います。distinctを利用した方法がDBから取得するデータ量が一番少ないので記事を絞り込みたいだけの今回は使った方が良いです。

includesはpreloadかeager_loadを自動的に切り替えるが使わなくて良い

includesは関連先を一括読み込みする為にpreloadかeager_loadを自動的に切り替えて使うように内部的に振る舞います。基本的にはpreloadとして振る舞い、一括読み込み指定された関連先をwhere句などで絞り込みする場合はeager_loadとして振る舞います:

> Article.benchmark{ Article.includes(:comments).each{|article| article.comments.to_a }}; nil
  Article Load (3.6ms)  SELECT "articles".* FROM "articles"
  Comment Load (18.4ms)  SELECT "comments".* FROM "comments"
      WHERE "comments"."article_id" IN ($1, ... , $100) [["article_id", 1], ... , ["article_id", 100]]
Benchmarking (71.8ms)
=> nil

> Article.benchmark{
  Article.includes(:comments).where.not(comments: {body: nil}).each{ |article|
    article.comments.to_a
  }
}; nil
  SQL (11.5ms)  SELECT "articles"."id" AS t0_r0, "articles"."title" AS t0_r1,
      "articles"."body" AS t0_r2, "articles"."created_at" AS t0_r3,
      "articles"."updated_at" AS t0_r4, "comments"."id" AS t1_r0,
      "comments"."article_id" AS t1_r1, "comments"."body" AS t1_r2,
      "comments"."created_at" AS t1_r3, "comments"."updated_at" AS t1_r4
      FROM "articles" LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id"
      WHERE "comments"."body" IS NOT NULL
Benchmarking (81.3ms)
=> nil

なんだか自動的に切り替わるので便利そうでこれを使えばpreloadやeager_loadを使わなくて済むような気がしますが落とし穴があるのでincludesは個人的には使用しない方が良いと考えています。なぜなら、preloadとeager_loadの振り分け実装が不十分で正しいSQLを発行出来ずエラーが出る場合があるからです:

> Article.includes(:comments).where('comments.body LIKE ?', '%コメント%')
  Article Load (5.5ms)  SELECT "articles".* FROM "articles" 
    WHERE (comments.body LIKE '%コメント%') LIMIT $1  [["LIMIT", 11]]
Traceback (most recent call last):
ActiveRecord::StatementInvalid (PG::UndefinedTable: ERROR:  missing FROM-clause entry
for table "comments")
LINE 1: SELECT "articles".* FROM "articles" WHERE (comments.body LIK...

includesを使用した際にeager_loadを使用すると判断するのは現状では指定関連先をwhere句にハッシュ、つまりwhere(comments: xxx)の様に指定したときのみrails 6.0まででは判断するようです。

この問題を回避する為に絞り込みを行う為に関連先も利用する旨を指定(強制的にLEFT OUTER JOIN)するreferencesメソッドも用意されています:

> Article.benchmark {
  Article.includes(:comments).references(:comments)
         .where('comments.body LIKE ?', '%コメント%').each{|article| article.comments.to_a }
}; nil
      SQL (9.4ms)  SELECT "articles"."id" AS t0_r0, "articles"."title" AS t0_r1, 
          "articles"."body" AS t0_r2, "articles"."created_at" AS t0_r3,
          "articles"."updated_at" AS t0_r4, "comments"."id" AS t1_r0,
          "comments"."article_id" AS t1_r1, "comments"."body" AS t1_r2,
          "comments"."created_at" AS t1_r3, "comments"."updated_at" AS t1_r4
          FROM "articles"
          LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id"
          WHERE (comments.body LIKE '%コメント%')
Benchmarking (73.9ms)
=> nil

しかしながらこれはeager_loadそのものの振る舞いになっています。つまり、includesを使うと関連先を絞り込む等の条件によってpreloadなのかeager_loadなのかを判断してreferencesを記述する必要があることになり、それなら最初からpreloadとeager_loadを使用してた方がシンプルだし分かり易いです。

referencesの記述が必要無くなるくらいincludesの実装が枯れるまでは使用しない方が良いと個人的には考えます。

まとめ

最後にもう一度結論まとめておきます:

  • 多重度が多の関連先を持つモデルを関連先も含めて逐次参照すると一覧参照に加えてヒットしたレコード数のクエリが発生してしまう。これがN+1問題
  • N+1問題を解決するには先ずはpreloadを利用する。大体の場合で一番パフォーマンスが良い
  • 関連先で絞り込みを行いたい場合、関連が1対1の場合はeager_loadを利用する
  • 関連先で絞り込みを行うが関連先参照の必要が無い場合joinsを利用する
  • includesはpreloadとeager_loadを自動的に振り分けるが不十分な実装が多いので利用しない方が良い

いかがだったでしょうか。includesに関してはRails 5からleft_outer_joinsも利用出来るようになって自分でselectするっていう場合はそちらを使えば良く、結局railsどうしたいのって部分が垣間見え迷走しているように個人的には感じます。

エンジニアからすれば、うまくRDBSと付き合って行くにはActiveRecordのようなO/Rマッピングを使っていたとしてもSQLの理解を深めていってクエリを最適化していくことを意識することは大事なんだなと思います:)

正方形336

正方形336

-Web
-,

Copyright© さかなソフトブログ , 2024 All Rights Reserved.