💎

graphql-rubyのN+1を遅延ロードで解決する

2022/11/08に公開

現在開発中のプロダクトのバックエンドはRails + graphql-rubyを使っています。

GraphQLを使った際のデメリットとしてN+1が発生しやすいということがよく言われます。

弊社でも例外ではなく、随所でN+1が発生していました。

今まではフロントエンドからの使われ方を考慮して、サクッと実装できる先読み(preload等)でN+1を回避していたのですが、クエリー数が増加したことでクエリーごとに使われ方を把握するのは難しくなってきたのと、同じクエリーであったとしても使われ方が多様化して先読みでは非効率になることが懸念されました(先読みが非効率になる件は後述します)。

そこで重い腰をあげて、遅延ロードを導入するために調査を行うことにしました。

※この記事は執筆時点(2021/06)の情報で記載されています。また、検証は下記のバージョンで実施しています。

  • Ruby: 3.0.1
  • Rails: 6.1.3.2
  • graphql-ruby: 1.12.12

N+1になる例

具体例があったほうが良いので、この記事では下記のモデルとクエリーを使います。

モデル

色々なパターンを網羅できたほうが良いので、user起点で下記の関係を作れるように構成しました。

  • 1対1対多
    • users - profiles - skills
  • 1対多
    • users - portfolios
  • 多対多
    • users - books (user_booksが中間テーブル)
#
# users
# id, name
#
class User < ApplicationRecord
  has_one :profile
  has_many :portfolios
  has_many :user_books
  has_many :books, through: :user_books
end

#
# profiles
# id, user_id, address
#
class Profile < ApplicationRecord
  belongs_to :user
  has_many :skills
end

#
# skills
# id, profile_id, name
#
class Skill < ApplicationRecord
  belongs_to :profile
end

#
# portfolios
# id, user_id, name, url
#
class Portfolio < ApplicationRecord
  belongs_to :user
end

#
# user_books
# id, user_id, book_id
#
class UserBook < ApplicationRecord
  belongs_to :user
  belongs_to :book
end

#
# books
# id, title
#
class Book < ApplicationRecord
  has_many :user_books
end

GraphQL

usersを取得するクエリーを実装します。

検証用なので特に絞り込みなどはなく、usersテーブルの全件を返却することにします。

module Resolvers
  class Users < Resolvers::BaseResolver
    type Types::UserType.connection_type, null: false

    def resolve
      User.all
    end
  end
end

ObjectTypeは下記の通り。

module Types
  class UserType < Types::BaseObject
    field :id, Integer, null: false
    field :name, String, null: false
    field :profile, Types::ProfileType, null: true
    field :portfolios, Types::PortfolioType.connection_type, null: false
    field :books, Types::BookType.connection_type, null: false
  end

  class ProfileType < Types::BaseObject
    field :id, Integer, null: false
    field :address, String, null: true
    field :skills, Types::SkillType.connection_type, null: false
  end

  class SkillType < Types::BaseObject
    field :id, Integer, null: false
    field :name, String, null: false
  end

  class PortfolioType < Types::BaseObject
    field :id, Integer, null: false
    field :name, String, null: false
    field :url, String, null: false
  end

  class BookType < Types::BaseObject
    field :id, Integer, null: false
    field :title, String, null: false
  end
end

テーブル構造とほぼ一致していますが、中間テーブル(user_books)に対応するタイプは実装していません。

DBとタイプの紐付けについての方針は別途ブログにまとめているのでご興味がある方はご覧ください。

https://qiita.com/ham0215/items/fc503e652b3f2e029696

動作確認

下記のusersクエリーでユーザーを取得します。

query Users {
  users {
    nodes {
      name
      profile {
        address
        skills {
          nodes {
            name
          }
        }
      }
      portfolios {
        nodes {
          name
          url
        }
      }
      books {
        nodes {
          title
        }
      }
    }
  }
}

実行結果は下記の通り。2名のユーザーが登録されている状態で検証を進めます。

{
  "data": {
    "users": {
      "nodes": [
        {
          "id": 2,
          "name": "hoge",
          "profile": {
            "address": "住所だよ",
            "skills": {
              "nodes": [
                {
                  "name": "Ruby"
                },
                {
                  "name": "Javascript"
                },
                {
                  "name": "Python"
                }
              ]
            }
          },
          "portfolios": {
            "nodes": [
              {
                "name": "ポートフォリオ1",
                "url": "url1"
              },
              {
                "name": "ポートフォリオ2",
                "url": "url2"
              },
              {
                "name": "ポートフォリオ3",
                "url": "url3"
              }
            ]
          },
          "books": {

              {
                "title": "hoge本1"
              },
              {
                "title": "hoge本2"
              },
              {
                "title": "hoge本3"
              }
            ]
          }
        },
        {
          "id": 3,
          "name": "fuga",
          "profile": {
            "address": "住所だよ",
            "skills": {
              "nodes": [
                {
                  "name": "Ruby"
                },
                {
                  "name": "Javascript"
                },
                {
                  "name": "Python"
                }
              ]
            }
          },
          "portfolios": {
            "nodes": [
              {
                "name": "ポートフォリオ1",
                "url": "url1"
              },
              {
                "name": "ポートフォリオ2",
                "url": "url2"
              },
              {
                "name": "ポートフォリオ3",
                "url": "url3"
              }
            ]
          },
          "books": {
            "nodes": [
              {
                "title": "fuga本1"
              },
              {
                "title": "fuga本2"
              },
              {
                "title": "fuga本3"
              }
            ]
          }
        }
      ]
    }
  }
}

下記は実行時のログの中から発行されたSQLを抽出したものです。

user_idが2と3のユーザーに対してprofiles、skills、portfolios、booksを別々でリードしておりN+1になっていることがわかります。

SELECT `users`.* FROM `users`
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 2 LIMIT 1
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` = 1
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 2
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 2
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 3 LIMIT 1
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` = 2
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 3
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 3

先読み

遅延ロードの検証の前に先読みで解決する場合の実装方法と非効率になってしまう例を紹介します。

先読みの実装は簡単です。下記のように先読みしておきたいアソシエーションをpreloadで指定すればよいです。

def resolve
  User.all.preload(:portfolios, :books, { profile: :skills })
end

先ほどと同じクエリーを実行してみましょう。

先程はユーザーごとにリードしていましたが、今回はまとめてリードするようになりました。(user_booksのリードが増えてしまいましたが、全体的には減りました)

SELECT `users`.* FROM `users`
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3)
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (2, 3)
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (2, 3)
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` IN (1, 2)

大抵のN+1はこれで解決なのですが、GraphQLはそうはいきません。

GraphQLは使用側から取得したい項目を選択できるという特徴があります。そのため下記のようなクエリーでリクエストされることがあります。

query Users {
  users {
    nodes {
      id
      name
    }
  }
}

上記のクエリーの場合、usersテーブルだけリードすればよいのですが、今回preloadを付けてしまったことで不要なテーブルまでリードされてしまいます。

これがGraphQLの場合、先読みが非効率になってしまうパターンです。

これを回避するために遅延ロードという方法があります。次の章から遅延ロードについて記載します。

遅延ロード

遅延ロードとは、先読みとは逆に必要になってからリードする方式です。

先読みのように予めリードしておくわけではないので、使われないのにリードするということが発生しなくなります。

遅延ロードはRailsには組み込まれていないので、独自に実装するかライブラリを導入する必要があります。

独自に実装するのは非効率すぎるのでライブラリを探してみました。

対象ライブラリ

執筆時点(2021/06)で私が調査したところ、以下の4つのライブラリが見つかりました。

ライブラリを探す時、大抵の場合はメジャーなライブラリが1つ見つかってそれを導入すれば良さそうとなるのですが、今回の場合は甲乙つけがたいライブラリが複数出てきたのでそれぞれ実際に使ってみて使い心地を試すことにしました。

GraphQL::Batch

https://github.com/Shopify/graphql-batch

  • ☆1.2k
  • Shopifyが提供しているライブラリ

探したものの中で一番スターが付いていました。
GraphQL初期からあるライブラリのようで、ShopifyやGitHubでも使われているようで実績十分。
調査対象としました。

BatchLoader

https://github.com/exAspArk/batch-loader

  • ☆837
  • GitLabやNETFLIXで使われているライブラリ

graphql-batchより後発ではあるものの、リリースからは数年経過しており、有名企業でも使われているため実績十分。

後発のメリット(既存ライブラリのペインを解決している可能性が高い)があるかもしれないと思い調査対象としました。

Dataloader

https://github.com/sheerun/dataloader

  • ☆106
  • GraphQLの公式が提供しているdataloader をRuby用にカスタマイズしたもの

こちらはスターも少なく、2018年から更新が止まっているので調査対象外としました。

GraphQL Ruby付属のDataloader

https://graphql-ruby.org/dataloader/overview.html

graphql-rubyにもバージョン1.12からDataloaderが含まれるようになったようです。

graphql-rubyはGraphQLの実装に使っているので追加ライブラリなしで使えるという大きなメリットがあります。

ただし、現時点でドキュメントに下記のように書かれており不安が残ります。

⚠ Experimental ⚠
This feature may get big changes in future releases. Check the changelog for update notes.

不安はあるものの、graphql-ruby自体に含まれていることのメリットは大きいので調査対象としました。

GraphQL::Batchの調査

導入方法はREADMEに記載されている通りなので省略します。調査時点のバージョンは0.4.3でした。

まずは、examplesにあるAssociationLoaderを実装しました。

READMEに載っているRecordLoaderはhas_oneにしか対応していなかったのでAssociationLoaderを使います。当初はhas_oneとhas_manyでRecordLoaderとAssociationLoaderを使い分けていたのですが、has_oneでもAssociationLoaderで動作したので統一しました。

基本コピペしましたが、initializeにsuperに()がないと動作しなかったので()を追加しています。

# app/loader/association_loader.rb
class AssociationLoader < GraphQL::Batch::Loader
  def self.validate(model, association_name)
    new(model, association_name)
    nil
  end

  def initialize(model, association_name)
    super()
    @model = model
    @association_name = association_name
    validate
  end

  def load(record)
    raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
    return Promise.resolve(read_association(record)) if association_loaded?(record)
    super
  end

  # We want to load the associations on all records, even if they have the same id
  def cache_key(record)
    record.object_id
  end

  def perform(records)
    preload_association(records)
    records.each { |record| fulfill(record, read_association(record)) }
  end

  private

  def validate
    unless @model.reflect_on_association(@association_name)
      raise ArgumentError, "No association #{@association_name} on #{@model}"
    end
  end

  def preload_association(records)
    ::ActiveRecord::Associations::Preloader.new.preload(records, @association_name)
  end

  def read_association(record)
    record.public_send(@association_name)
  end

  def association_loaded?(record)
    record.association(@association_name).loaded?
  end
end

続いて、取得時に使用するloaderを定義します。

各モデルのアソシエーションに対応するxxx_loaderメソッドを定義しました。

AssociationLoaderのforにモデルクラスとアソシエーション名、loadにはアソシエーション元のオブジェクトを渡します。

# app/models/user.rb
def profile_loader
  AssociationLoader.for(User, :profile).load(self)
end

def portfolios_loader
  AssociationLoader.for(User, :portfolios).load(self)
end

def books_loader
  AssociationLoader.for(User, :books).load(self)
end

# app/models/profile.rb
def skills_loader
  AssociationLoader.for(Profile, :skills).load(self)
end

次にObjectTypeで取得メソッドを先程定義したloaderメソッドに変更します。

# app/graphql/types/user_type.rb
field :profile, Types::ProfileType, null: true, method: :profile_loader
field :portfolios, Types::PortfolioType.connection_type, null: false, method: :portfolios_loader
field :books, Types::BookType.connection_type, null: false, method: :books_loader

# app/graphql/types/profile_type.rb
field :skills, Types::SkillType.connection_type, null: false, method: :skills_loader

以上で完了です。

早速先程と同じクエリーを実行してみました。下記が実行時のSQLです。

見事に効率よく取得できています!

SELECT `users`.* FROM `users`
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (2, 3)
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3)
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (2, 3)
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` IN (1, 2)

もちろん、下記のようなクエリーの場合はusersに対するリードしか行われません。

query Users {
  users {
    nodes {
      id
      name
    }
  }
}

考察

一番最初にこのgemを検証したのですが、もうこれでいいじゃんと思って他の検証をやめようかと思いましたw

それほど使い勝手が良く感じました。

基本的には上記に書いた通りアソシエーションに対応するloaderを書いていけばよいだけで、一度書き方を覚えれば誰でも簡単に書くことができると思います。

チーム開発において簡単に書けることは重要な要素なので高評価です。

一方、Loaderクラスはコピペして作ったので導入コストはなかったですが、ライブラリ外の持ち物となっているのでバージョンアップ時にローダー周りの修正は手動で入れる必要があります。

Loaderクラスのようなライブラリ外だけどガッツリライブラリに依存しているクラスはバージョンアップ時に壊れやすいので注意が必要です。自動テストなどでカバーしましょう。

Dataloaderの調査

こちらも導入はREADMEを参照してください。調査時点のバージョンは2.0.1でした。

こちらはLoaderなどのクラスを別途作る必要はありませんでした。READMEのサンプル通りObjectTypeに直接取得メソッドを実装してみました。

# app/graphql/types/user_type.rb
def profile
  BatchLoader::GraphQL.for(object.id).batch do |user_ids, loader|
    Profile.where(user_id: user_ids).each { |profile| loader.call(profile.user_id, profile) }
  end
end

def portfolios
  BatchLoader::GraphQL.for(object.id).batch(default_value: []) do |user_ids, loader|
    Portfolio.where(user_id: user_ids).each do |portfolio|
      loader.call(portfolio.user_id) { _1 << portfolio }
    end
  end
end

def books
  BatchLoader::GraphQL.for(object.id).batch(default_value: []) do |user_ids, loader|
    UserBook.where(user_id: user_ids).preload(:book).each do |user_book|
      loader.call(user_book.user_id) { _1 << user_book.book }
    end
  end
end

# app/graphql/types/profile_type.rb
def skills
  BatchLoader::GraphQL.for(object.id).batch(default_value: []) do |profile_ids, loader|
    Skill.where(profile_id: profile_ids).each do |skill|
      loader.call(skill.profile_id) { _1 << skill }
    end
  end
end

パット見はgraphql-batchに似ていますが、こちらはLoaderクラスがなかった代わりにそれぞれの箇所に取得処理を書く必要があります。

単純なアソシエーション部分はほぼコピペですが、has_many-throughのところはpreloadをつけるなどN+1を解消するためには一手間必要でした。

あと、READMEに記載されているのですが、キャッシュがリクエストを跨いで残ってしまうので、下記のミドルウェアを追加してリクエストごとにクリアするようにしましょう。

# config/application.rb
config.middleware.use BatchLoader::Middleware

こちらでも同様のクエリーを実行してみます。下記の通りきちんとN+1が解消されています。

こちらも同様にusersのデータしか指定していないクエリーの場合はusersテーブルのリードのみとなります。

SELECT `users`.* FROM `users`
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (2, 3)
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3)
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (2, 3)
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` IN (1, 2)

考察

graphql-batchと同じことが実現できましたが、各箇所に実装する量はgraphql-batchより増えてしまうようです。(ラッパークラスを作ればもうすこし簡素化できるかもしれませんが)

has_many-throughのところはpreloadを自分で付ける必要があったので実装漏れをしてしまう可能性が高そうだなと感じました。preloadを付け忘れてもN+1が解消されないだけで動作するので気づきづらいです。

逆に、自分でカスタマイズする余地があるので、今後複雑な実装が必要になったときはこちらのほうが対応しやすいのかもと思いました。

GraphQL Ruby付属のDataloaderの調査

こちらはgraphql-rubyに同包されているためインストールは不要です。調査時点のバージョンは1.12.12でした。

ドキュメントにも記載されている通り、graphql-batchの影響を受けているようで、似たような実装ができました。

まずSourceを実装します。Sourceはgraphql-batchでいうLoaderのようなものです。

サンプルは単レコードのものになっていますが、graphql-batchのAssociationLoaderのような実装にしたかったので下記のように実装しました。

# app/graphql/sources/active_record_object.rb

class Sources::ActiveRecordObject < GraphQL::Dataloader::Source
  def initialize(model_class, association_name)
    @model_class = model_class
    @association_name = association_name
  end

  def fetch(ids)
    records = @model_class.where(id: ids).preload(@association_name)
    ids.map {|id| records.find { _1.id == id.to_i }&.public_send(@association_name) }
  end
end

あとはObjectTypeで使うように修正

# app/graphql/types/user_type.rb
def profile
  dataloader.with(Sources::ActiveRecordObject, User, :profile).load(object.id)
end

def portfolios
  dataloader.with(Sources::ActiveRecordObject, User, :portfolios).load(object.id)
end

def books
  dataloader.with(Sources::ActiveRecordObject, User, :books).load(object.id)
end

# app/graphql/types/profile_type.rb
def skills
  dataloader.with(Sources::ActiveRecordObject, Profile, :skills).load(object.id)
end

これで完璧と思ったのですが、実行してみたら全然ダメでした・・・

SELECT `users`.* FROM `users`
SELECT `users`.* FROM `users` WHERE `users`.`id` IN (2, 3)
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (2, 3)
SELECT `users`.* FROM `users` WHERE `users`.`id` IN (2, 3)
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3)
SELECT `users`.* FROM `users` WHERE `users`.`id` IN (2, 3)
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (2, 3)
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`id` IN (1, 2)
SELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` IN (1, 2)

確かにuser_idでまとめてリードされるのですが、アソシエーション元のデータもリードしています。

アソシエーション元のデータはパラメーターで渡しており手元になるのでリードする必要はありません。

Souceクラスは私が独自に実装しているので私の実装の問題ではあるのですが、現状でgraphql-batchのようにアソシエーションを効率よく取得する実装が思いつきませんでした・・・

(loadクラスなどをガッツリオーバーライドすればできるのかもしれませんがそこまでしたら沼にハマるのでやりません)

ということでこちらは今回やりたい実装が見つからなかったことと、前述した通り今後変更の可能性が高いようなので今回は見送ることにします。

まとめ

調査した結果、今回はGraphQL::Batchを採用することにしました。

理由は直近もメンテされている点や、スターの数、使用実績など色々ありますが、一番の理由は最もシンプルに実装できたという点です。

チーム開発においてシンプルに実装できることは、開発時のミスが減り品質Upや生産性Upが見込めます。

調査時はloaderメソッドをmodelに定義していましたが、GraphQL専用となりそうなので、DataloaderのサンプルのようにObjectTypeに定義したほうが良いかも?と思ったりしています。

このあたりも含めて、まだ調査で数日間触っただけなので今後業務で使いながらライブラリに対する知識をアップデートしていきたいと思います。

Appendix

GraphQL::Batchを決めてから更に気になったところを追加調査しました。

connection_typeで並び順を指定できるか?
connection_typeでページネーションを指定した場合の挙動
connection_typeで並び順を指定できるか?
今回の例の場合、portfoliosやbooksは並び順を指定していませんでしたが、実際に運用をしていくときは並び順の指定が必須だと思います。

並び順はアソシエーションのlambdaで指定することで実現できました。

例えば、idの降順で取得する場合は下記のように記載します。

# app/models/user.rb
class User < ApplicationRecord
  has_one :profile
  has_many :portfolios
  has_many :user_books
  has_many :books, through: :user_books

  # 下記のようにorderを指定したアソシエーションを追加する
  has_many :sorted_portfolios, -> { order(id: :desc) }, class_name: 'Portfolio'
  has_many :sorted_books, -> { order(id: :desc) }, through: :user_books, source: :book
end

あとはloaderでorderを付けたアソシエーションを指定すればOKです。

# app/models/user.rb
def portfolios_loader
  AssociationLoader.for(User, :sorted_portfolios).load(self)
end

def books_loader
  AssociationLoader.for(User, :sorted_books).load(self)
end

こちらで実行すると下記の通りorderが指定された状態で取得され、結果も降順に表示されました。

SELECT `users`.* FROM `users`
SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (2, 3)
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3) ORDER BY `portfolios`.`id` DESC
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (1, 2, 3, 4, 5, 6) ORDER BY `books`.`id` DESC
ELECT `skills`.* FROM `skills` WHERE `skills`.`profile_id` IN (1, 2)
  • connection_typeでページネーションを指定した場合の挙動
  • connection_typeではfirstやlastを指定することで取得する件数を指定することができます。

これらを指定した場合の挙動を確認します。

下記のクエリーを実行してみます。

query Users {
  users {
    nodes {
      portfolios(last:2) {
        nodes {
          name
          url
        }
      }
      books(first:2) {
        nodes {
          title
        }
      }
    }
  }
}

下記のようにSQLが発行されました。

SELECT `users`.* FROM `users`
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` IN (2, 3) ORDER BY `portfolios`.`id` DESC
SELECT COUNT(*) FROM `portfolios` WHERE `portfolios`.`user_id` = 2
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 2 ORDER BY `portfolios`.`id` DESC LIMIT 2 OFFSET 1
SELECT `user_books`.* FROM `user_books` WHERE `user_books`.`user_id` IN (2, 3)
SELECT `books`.* FROM `books` WHERE `books`.`id` IN (1, 2, 3, 4, 5, 6) ORDER BY `books`.`id` DESC
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 2 ORDER BY `books`.`id` DESC LIMIT 2
SELECT COUNT(*) FROM `portfolios` WHERE `portfolios`.`user_id` = 3
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 3 ORDER BY `portfolios`.`id` DESC LIMIT 2 OFFSET 1
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 3 ORDER BY `books`.`id` DESC LIMIT 2

細かく見ていくと、portfoliosとbooksの取得はuserごとに取得しているのでN+1の状態です。また、遅延ロード用の一括取得SQLも実行されています。

これは、遅延ロードが動作して一括取得したものの、ページネーションが指定されている場合はレスポンスを作るときに再度limitやoffsetがついたクエリーが再度発行されるためだと思います。

ただ、ページネーションを指定した場合、userごとに取得しないとlimitなどを考慮した取得が困難なのでこの挙動は仕方がないのかなと思いました。

逆に遅延ロードのためのクエリーが冗長になってしまうので、ページネーションを確実に使うようなところには遅延ロードは入れないほうが良いのかもしれません。

遅延ロードを入れなかった場合、下記のクエリーになります。(当然ですが)遅延ロードのクエリーが消えています。

SELECT `users`.* FROM `users`
SELECT COUNT(*) FROM `portfolios` WHERE `portfolios`.`user_id` = 2
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 2 LIMIT 2 OFFSET 1
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 2 LIMIT 2
SELECT COUNT(*) FROM `portfolios` WHERE `portfolios`.`user_id` = 3
SELECT `portfolios`.* FROM `portfolios` WHERE `portfolios`.`user_id` = 3 LIMIT 2 OFFSET 1
SELECT `books`.* FROM `books` INNER JOIN `user_books` ON `books`.`id` = `user_books`.`book_id` WHERE `user_books`.`user_id` = 3 LIMIT 2

この結果を踏まえると、遅延ロードはhas_oneアソシエーションや全件取得するhas_manyアソシエーションには効果を発揮しますが、件数が多いのhas_manyなどページネーション前提のfieldの場合は実施しないほうが効率が良さそうです。

Discussion